From e85b548b8fac96f725181604f8a19579f10c94f0 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Mon, 8 Dec 2025 12:15:46 -0600 Subject: [PATCH] feat(pos-micro): Implement React PWA frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Vite + React 18 + TypeScript configuration - Implement PWA with offline support via vite-plugin-pwa - Create mobile-first UI with Tailwind CSS - Implement authentication flow with PIN-based login - Add product grid with category filtering and favorites - Create cart management with quantity controls - Implement checkout flow with multiple payment methods - Add daily sales reports page - Configure Zustand stores for auth and cart state - Add React Query for API data fetching - Include Docker and nginx configuration for production 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../products/pos-micro/frontend/.env.example | 6 + .../products/pos-micro/frontend/Dockerfile | 58 ++++ .../products/pos-micro/frontend/index.html | 19 ++ .../products/pos-micro/frontend/nginx.conf | 35 ++ .../products/pos-micro/frontend/package.json | 40 +++ .../pos-micro/frontend/postcss.config.js | 6 + .../products/pos-micro/frontend/src/App.tsx | 62 ++++ .../frontend/src/components/CartPanel.tsx | 103 ++++++ .../frontend/src/components/CategoryTabs.tsx | 58 ++++ .../frontend/src/components/CheckoutModal.tsx | 306 ++++++++++++++++++ .../frontend/src/components/Header.tsx | 102 ++++++ .../frontend/src/components/ProductCard.tsx | 77 +++++ .../frontend/src/components/SuccessModal.tsx | 114 +++++++ .../frontend/src/hooks/usePayments.ts | 14 + .../frontend/src/hooks/useProducts.ts | 67 ++++ .../pos-micro/frontend/src/hooks/useSales.ts | 73 +++++ .../products/pos-micro/frontend/src/main.tsx | 25 ++ .../frontend/src/pages/LoginPage.tsx | 142 ++++++++ .../pos-micro/frontend/src/pages/POSPage.tsx | 154 +++++++++ .../frontend/src/pages/ReportsPage.tsx | 157 +++++++++ .../pos-micro/frontend/src/services/api.ts | 61 ++++ .../pos-micro/frontend/src/store/auth.ts | 61 ++++ .../pos-micro/frontend/src/store/cart.ts | 125 +++++++ .../pos-micro/frontend/src/styles/index.css | 82 +++++ .../pos-micro/frontend/src/types/index.ts | 115 +++++++ .../pos-micro/frontend/tailwind.config.js | 26 ++ .../products/pos-micro/frontend/tsconfig.json | 25 ++ .../pos-micro/frontend/tsconfig.node.json | 10 + .../pos-micro/frontend/vite.config.ts | 70 ++++ 29 files changed, 2193 insertions(+) create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/.env.example create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/Dockerfile create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/index.html create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/nginx.conf create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/package.json create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/postcss.config.js create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/App.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/components/CartPanel.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/components/CategoryTabs.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/components/CheckoutModal.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/components/Header.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/components/ProductCard.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/components/SuccessModal.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/usePayments.ts create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/useProducts.ts create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/useSales.ts create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/main.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/pages/LoginPage.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/pages/POSPage.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/pages/ReportsPage.tsx create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/services/api.ts create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/store/auth.ts create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/store/cart.ts create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/styles/index.css create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/src/types/index.ts create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/tailwind.config.js create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/tsconfig.json create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/tsconfig.node.json create mode 100644 projects/erp-suite/apps/products/pos-micro/frontend/vite.config.ts diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/.env.example b/projects/erp-suite/apps/products/pos-micro/frontend/.env.example new file mode 100644 index 0000000..4ef686d --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/.env.example @@ -0,0 +1,6 @@ +# ============================================================================= +# POS MICRO - Frontend Environment Variables +# ============================================================================= + +# API URL +VITE_API_URL=http://localhost:3000/api/v1 diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/Dockerfile b/projects/erp-suite/apps/products/pos-micro/frontend/Dockerfile new file mode 100644 index 0000000..001fc2c --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/Dockerfile @@ -0,0 +1,58 @@ +# ============================================================================= +# POS MICRO - Frontend Dockerfile +# ============================================================================= + +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build application +RUN npm run build + +# Production stage - serve with nginx +FROM nginx:alpine AS production + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] + +# Development stage +FROM node:20-alpine AS development + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Expose port +EXPOSE 5173 + +# Start in development mode +CMD ["npm", "run", "dev"] diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/index.html b/projects/erp-suite/apps/products/pos-micro/frontend/index.html new file mode 100644 index 0000000..6c90275 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + POS Micro + + +
+ + + diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/nginx.conf b/projects/erp-suite/apps/products/pos-micro/frontend/nginx.conf new file mode 100644 index 0000000..ba3160c --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # PWA manifest and service worker - no cache + location ~* (manifest\.json|sw\.js)$ { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + # SPA fallback - all routes go to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/package.json b/projects/erp-suite/apps/products/pos-micro/frontend/package.json new file mode 100644 index 0000000..c3c674c --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/package.json @@ -0,0 +1,40 @@ +{ + "name": "pos-micro-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "zustand": "^4.4.7", + "@tanstack/react-query": "^5.8.4", + "axios": "^1.6.2", + "lucide-react": "^0.294.0", + "clsx": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.53.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^5.0.0", + "vite-plugin-pwa": "^0.17.4", + "workbox-window": "^7.0.0" + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/postcss.config.js b/projects/erp-suite/apps/products/pos-micro/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/App.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/App.tsx new file mode 100644 index 0000000..1815871 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/App.tsx @@ -0,0 +1,62 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth'; +import { POSPage } from '@/pages/POSPage'; +import { LoginPage } from '@/pages/LoginPage'; +import { ReportsPage } from '@/pages/ReportsPage'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +function PublicRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + if (isAuthenticated) { + return ; + } + + return <>{children}; +} + +export default function App() { + return ( + + {/* Public routes */} + + + + } + /> + + {/* Protected routes */} + + + + } + /> + + + + } + /> + + {/* Fallback */} + } /> + + ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CartPanel.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CartPanel.tsx new file mode 100644 index 0000000..8eb26a8 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CartPanel.tsx @@ -0,0 +1,103 @@ +import { Minus, Plus, Trash2, ShoppingCart } from 'lucide-react'; +import { useCartStore } from '@/store/cart'; +import type { CartItem } from '@/types'; + +interface CartPanelProps { + onCheckout: () => void; +} + +export function CartPanel({ onCheckout }: CartPanelProps) { + const { items, total, itemCount, updateQuantity, removeItem } = useCartStore(); + + if (items.length === 0) { + return ( +
+ +

Carrito vacio

+

Selecciona productos para agregar

+
+ ); + } + + return ( +
+ {/* Cart items */} +
+ {items.map((item) => ( + updateQuantity(item.product.id, qty)} + onRemove={() => removeItem(item.product.id)} + /> + ))} +
+ + {/* Cart summary */} +
+
+ Total ({itemCount} productos) + ${total.toFixed(2)} +
+ + +
+
+ ); +} + +interface CartItemRowProps { + item: CartItem; + onUpdateQuantity: (quantity: number) => void; + onRemove: () => void; +} + +function CartItemRow({ item, onUpdateQuantity, onRemove }: CartItemRowProps) { + return ( +
+
+

{item.product.name}

+

+ ${item.unitPrice.toFixed(2)} c/u +

+
+ +
+ {/* Quantity controls */} + + + {item.quantity} + + + + {/* Subtotal */} + + ${item.subtotal.toFixed(2)} + + + {/* Remove button */} + +
+
+ ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CategoryTabs.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CategoryTabs.tsx new file mode 100644 index 0000000..9b2d8a7 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CategoryTabs.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import type { Category } from '@/types'; + +interface CategoryTabsProps { + categories: Category[]; + selectedCategoryId: string | null; + onSelect: (categoryId: string | null) => void; +} + +export function CategoryTabs({ categories, selectedCategoryId, onSelect }: CategoryTabsProps) { + return ( +
+ + + + + {categories.map((category) => ( + + ))} +
+ ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CheckoutModal.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CheckoutModal.tsx new file mode 100644 index 0000000..082d7f5 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/CheckoutModal.tsx @@ -0,0 +1,306 @@ +import { useState } from 'react'; +import { X, CreditCard, Banknote, Smartphone, Check } from 'lucide-react'; +import clsx from 'clsx'; +import { useCartStore } from '@/store/cart'; +import { usePaymentMethods } from '@/hooks/usePayments'; +import { useCreateSale } from '@/hooks/useSales'; +import type { PaymentMethod, Sale } from '@/types'; + +interface CheckoutModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (sale: Sale) => void; +} + +const QUICK_AMOUNTS = [20, 50, 100, 200, 500]; + +export function CheckoutModal({ isOpen, onClose, onSuccess }: CheckoutModalProps) { + const { items, total, paymentMethod, setPaymentMethod, amountReceived, setAmountReceived } = useCartStore(); + const { data: paymentMethods } = usePaymentMethods(); + const createSale = useCreateSale(); + + const [step, setStep] = useState<'payment' | 'amount' | 'confirm'>('payment'); + + const change = paymentMethod?.type === 'cash' ? Math.max(0, amountReceived - total) : 0; + + const handleSelectPayment = (method: PaymentMethod) => { + setPaymentMethod(method); + if (method.type === 'cash') { + setStep('amount'); + } else { + setStep('confirm'); + } + }; + + const handleConfirm = async () => { + if (!paymentMethod) return; + + try { + const sale = await createSale.mutateAsync({ + items: items.map((item) => ({ + productId: item.product.id, + quantity: item.quantity, + unitPrice: item.unitPrice, + discount: item.discount, + })), + paymentMethodId: paymentMethod.id, + amountReceived: paymentMethod.type === 'cash' ? amountReceived : undefined, + }); + + onSuccess(sale); + setStep('payment'); + setAmountReceived(0); + } catch (error) { + console.error('Error creating sale:', error); + } + }; + + const handleClose = () => { + setStep('payment'); + setAmountReceived(0); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

Cobrar

+ +
+ + {/* Total */} +
+

Total a cobrar

+

${total.toFixed(2)}

+
+ + {/* Content */} +
+ {step === 'payment' && ( + + )} + + {step === 'amount' && ( + setStep('payment')} + onConfirm={() => setStep('confirm')} + /> + )} + + {step === 'confirm' && ( + setStep(paymentMethod?.type === 'cash' ? 'amount' : 'payment')} + onConfirm={handleConfirm} + /> + )} +
+
+
+ ); +} + +interface PaymentStepProps { + paymentMethods: PaymentMethod[]; + onSelect: (method: PaymentMethod) => void; +} + +function PaymentStep({ paymentMethods, onSelect }: PaymentStepProps) { + const getIcon = (type: string) => { + switch (type) { + case 'cash': + return Banknote; + case 'card': + return CreditCard; + case 'transfer': + return Smartphone; + default: + return Banknote; + } + }; + + return ( +
+

Selecciona forma de pago

+ {paymentMethods.map((method) => { + const Icon = getIcon(method.type); + return ( + + ); + })} +
+ ); +} + +interface AmountStepProps { + total: number; + amountReceived: number; + onAmountChange: (amount: number) => void; + onBack: () => void; + onConfirm: () => void; +} + +function AmountStep({ total, amountReceived, onAmountChange, onBack, onConfirm }: AmountStepProps) { + return ( +
+

Cantidad recibida

+ + {/* Input */} + onAmountChange(Number(e.target.value))} + placeholder="0.00" + autoFocus + /> + + {/* Quick amounts */} +
+ {QUICK_AMOUNTS.map((amount) => ( + + ))} +
+ + {/* Exact amount button */} + + + {/* Change preview */} + {amountReceived >= total && ( +
+

Cambio

+

+ ${(amountReceived - total).toFixed(2)} +

+
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} + +interface ConfirmStepProps { + total: number; + paymentMethod: PaymentMethod; + amountReceived: number; + change: number; + isLoading: boolean; + onBack: () => void; + onConfirm: () => void; +} + +function ConfirmStep({ + total, + paymentMethod, + amountReceived, + change, + isLoading, + onBack, + onConfirm, +}: ConfirmStepProps) { + return ( +
+
+
+ Metodo de pago + {paymentMethod.name} +
+
+ Total + ${total.toFixed(2)} +
+ {paymentMethod.type === 'cash' && ( + <> +
+ Recibido + ${amountReceived.toFixed(2)} +
+
+ Cambio + ${change.toFixed(2)} +
+ + )} +
+ +
+ + +
+
+ ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/components/Header.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/Header.tsx new file mode 100644 index 0000000..e1ef78c --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/Header.tsx @@ -0,0 +1,102 @@ +import { Menu, BarChart3, Package, Settings, LogOut } from 'lucide-react'; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth'; + +export function Header() { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { tenant, logout } = useAuthStore(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( + <> +
+
+ +
+

POS Micro

+

{tenant?.businessName}

+
+
+
+ + {/* Mobile menu overlay */} + {isMenuOpen && ( +
+
setIsMenuOpen(false)} + /> + +
+ )} + + ); +} + +interface NavLinkProps { + to: string; + icon: React.ReactNode; + label: string; + onClick: () => void; +} + +function NavLink({ to, icon, label, onClick }: NavLinkProps) { + return ( + + {icon} + {label} + + ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/components/ProductCard.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/ProductCard.tsx new file mode 100644 index 0000000..08dbac4 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/ProductCard.tsx @@ -0,0 +1,77 @@ +import { Star } from 'lucide-react'; +import clsx from 'clsx'; +import type { Product } from '@/types'; + +interface ProductCardProps { + product: Product; + onSelect: (product: Product) => void; + onToggleFavorite?: (productId: string) => void; +} + +export function ProductCard({ product, onSelect, onToggleFavorite }: ProductCardProps) { + const isLowStock = product.trackStock && product.currentStock <= product.minStock; + const isOutOfStock = product.trackStock && product.currentStock <= 0; + + return ( +
!isOutOfStock && onSelect(product)} + > + {/* Favorite button */} + {onToggleFavorite && ( + + )} + + {/* Product image or placeholder */} + {product.imageUrl ? ( + {product.name} + ) : ( +
+ + {product.name.charAt(0).toUpperCase()} + +
+ )} + + {/* Product info */} +

+ {product.name} +

+

+ ${product.price.toFixed(2)} +

+ + {/* Stock indicator */} + {product.trackStock && ( +

+ {isOutOfStock ? 'Agotado' : `${product.currentStock} disponibles`} +

+ )} +
+ ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/components/SuccessModal.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/SuccessModal.tsx new file mode 100644 index 0000000..710dc94 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/components/SuccessModal.tsx @@ -0,0 +1,114 @@ +import { CheckCircle, Printer, Share2, X } from 'lucide-react'; +import type { Sale } from '@/types'; + +interface SuccessModalProps { + isOpen: boolean; + sale: Sale | null; + onClose: () => void; +} + +export function SuccessModal({ isOpen, sale, onClose }: SuccessModalProps) { + if (!isOpen || !sale) return null; + + const handlePrint = () => { + // In a real app, this would trigger receipt printing + window.print(); + }; + + const handleShare = async () => { + const text = `Venta #${sale.ticketNumber}\nTotal: $${sale.total.toFixed(2)}\nGracias por su compra!`; + + if (navigator.share) { + try { + await navigator.share({ text }); + } catch { + // User cancelled or error + } + } else { + // Fallback: copy to clipboard + await navigator.clipboard.writeText(text); + } + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Close button */} + + + {/* Success icon */} +
+
+ +
+
+ + {/* Content */} +
+

Venta completada!

+

Ticket #{sale.ticketNumber}

+ +
+
+ Subtotal + ${sale.subtotal.toFixed(2)} +
+ {sale.discount > 0 && ( +
+ Descuento + -${sale.discount.toFixed(2)} +
+ )} +
+ Total + ${sale.total.toFixed(2)} +
+ {sale.change && sale.change > 0 && ( +
+ Cambio + ${sale.change.toFixed(2)} +
+ )} +
+ + {/* Actions */} +
+ + +
+ + +
+
+
+ ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/usePayments.ts b/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/usePayments.ts new file mode 100644 index 0000000..d829855 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/usePayments.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import api from '@/services/api'; +import type { PaymentMethod } from '@/types'; + +export function usePaymentMethods() { + return useQuery({ + queryKey: ['payment-methods'], + queryFn: async () => { + const { data } = await api.get('/payments/methods'); + return data; + }, + staleTime: 1000 * 60 * 30, // 30 minutes - payment methods don't change often + }); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/useProducts.ts b/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/useProducts.ts new file mode 100644 index 0000000..e80201f --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/useProducts.ts @@ -0,0 +1,67 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@/services/api'; +import type { Product, Category } from '@/types'; + +// Products +export function useProducts(categoryId?: string) { + return useQuery({ + queryKey: ['products', categoryId], + queryFn: async () => { + const params = new URLSearchParams(); + if (categoryId) params.append('categoryId', categoryId); + params.append('isActive', 'true'); + + const { data } = await api.get(`/products?${params}`); + return data; + }, + }); +} + +export function useFavoriteProducts() { + return useQuery({ + queryKey: ['products', 'favorites'], + queryFn: async () => { + const { data } = await api.get('/products/favorites'); + return data; + }, + }); +} + +export function useSearchProduct() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (barcode: string) => { + const { data } = await api.get(`/products/barcode/${barcode}`); + return data; + }, + onSuccess: (product) => { + queryClient.setQueryData(['product', product.id], product); + }, + }); +} + +export function useToggleFavorite() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (productId: string) => { + const { data } = await api.patch(`/products/${productId}/favorite`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products'] }); + }, + }); +} + +// Categories +export function useCategories() { + return useQuery({ + queryKey: ['categories'], + queryFn: async () => { + const { data } = await api.get('/categories'); + return data; + }, + }); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/useSales.ts b/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/useSales.ts new file mode 100644 index 0000000..0f830b3 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/hooks/useSales.ts @@ -0,0 +1,73 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import api from '@/services/api'; +import { useCartStore } from '@/store/cart'; +import type { Sale, DailySummary } from '@/types'; + +interface CreateSaleDto { + items: { + productId: string; + quantity: number; + unitPrice: number; + discount: number; + }[]; + paymentMethodId: string; + amountReceived?: number; + notes?: string; +} + +export function useCreateSale() { + const queryClient = useQueryClient(); + const clearCart = useCartStore((state) => state.clear); + + return useMutation({ + mutationFn: async (sale: CreateSaleDto) => { + const { data } = await api.post('/sales', sale); + return data; + }, + onSuccess: () => { + clearCart(); + queryClient.invalidateQueries({ queryKey: ['sales'] }); + queryClient.invalidateQueries({ queryKey: ['daily-summary'] }); + queryClient.invalidateQueries({ queryKey: ['products'] }); // Stock updated + }, + }); +} + +export function useTodaySales() { + return useQuery({ + queryKey: ['sales', 'today'], + queryFn: async () => { + const today = new Date().toISOString().split('T')[0]; + const { data } = await api.get(`/sales?date=${today}`); + return data; + }, + }); +} + +export function useDailySummary(date?: string) { + const queryDate = date || new Date().toISOString().split('T')[0]; + + return useQuery({ + queryKey: ['daily-summary', queryDate], + queryFn: async () => { + const { data } = await api.get(`/sales/summary/${queryDate}`); + return data; + }, + }); +} + +export function useCancelSale() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (saleId: string) => { + const { data } = await api.patch(`/sales/${saleId}/cancel`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sales'] }); + queryClient.invalidateQueries({ queryKey: ['daily-summary'] }); + queryClient.invalidateQueries({ queryKey: ['products'] }); // Stock restored + }, + }); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/main.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/main.tsx new file mode 100644 index 0000000..321d557 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/main.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './styles/index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 1, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +); diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/LoginPage.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..86406fb --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ShoppingBag, Eye, EyeOff } from 'lucide-react'; +import api from '@/services/api'; +import { useAuthStore } from '@/store/auth'; +import type { AuthResponse } from '@/types'; + +export function LoginPage() { + const [businessName, setBusinessName] = useState(''); + const [pin, setPin] = useState(''); + const [showPin, setShowPin] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [isRegister, setIsRegister] = useState(false); + + const login = useAuthStore((state) => state.login); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + const endpoint = isRegister ? '/auth/register' : '/auth/login'; + const payload = isRegister + ? { businessName, pin } + : { businessName, pin }; + + const { data } = await api.post(endpoint, payload); + + login(data.accessToken, data.refreshToken, data.user, data.tenant); + navigate('/'); + } catch (err: unknown) { + const errorMessage = + err instanceof Error + ? err.message + : (err as { response?: { data?: { message?: string } } })?.response?.data?.message || + (isRegister ? 'Error al registrar negocio' : 'PIN o negocio incorrecto'); + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Logo */} +
+
+ +
+

POS Micro

+

Punto de venta simple

+
+ + {/* Login Card */} +
+

+ {isRegister ? 'Registrar negocio' : 'Iniciar sesion'} +

+ +
+
+ + setBusinessName(e.target.value)} + required + /> +
+ +
+ +
+ setPin(e.target.value.replace(/\D/g, '').slice(0, 6))} + required + minLength={4} + maxLength={6} + inputMode="numeric" + pattern="[0-9]*" + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ +
+ +
+
+ + {/* Footer */} +

+ $100 MXN/mes - Cancela cuando quieras +

+
+ ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/POSPage.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/POSPage.tsx new file mode 100644 index 0000000..1af9414 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/POSPage.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { Search } from 'lucide-react'; +import { Header } from '@/components/Header'; +import { ProductCard } from '@/components/ProductCard'; +import { CartPanel } from '@/components/CartPanel'; +import { CategoryTabs } from '@/components/CategoryTabs'; +import { CheckoutModal } from '@/components/CheckoutModal'; +import { SuccessModal } from '@/components/SuccessModal'; +import { useProducts, useFavoriteProducts, useCategories, useToggleFavorite, useSearchProduct } from '@/hooks/useProducts'; +import { useCartStore } from '@/store/cart'; +import type { Product, Sale } from '@/types'; + +export function POSPage() { + const [selectedCategory, setSelectedCategory] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); + const [completedSale, setCompletedSale] = useState(null); + + const { data: categories } = useCategories(); + const { data: products, isLoading: productsLoading } = useProducts( + selectedCategory && selectedCategory !== 'favorites' ? selectedCategory : undefined + ); + const { data: favorites } = useFavoriteProducts(); + const toggleFavorite = useToggleFavorite(); + const searchProduct = useSearchProduct(); + const addItem = useCartStore((state) => state.addItem); + + const displayProducts = selectedCategory === 'favorites' ? favorites : products; + + const filteredProducts = displayProducts?.filter((product) => + product.name.toLowerCase().includes(searchQuery.toLowerCase()) || + product.barcode?.includes(searchQuery) + ); + + const handleSelectProduct = (product: Product) => { + addItem(product); + }; + + const handleBarcodeSearch = async (e: React.FormEvent) => { + e.preventDefault(); + if (!searchQuery) return; + + try { + const product = await searchProduct.mutateAsync(searchQuery); + if (product) { + addItem(product); + setSearchQuery(''); + } + } catch { + // Product not found - will show filtered list instead + } + }; + + const handleCheckoutSuccess = (sale: Sale) => { + setIsCheckoutOpen(false); + setCompletedSale(sale); + }; + + return ( +
+
+ +
+ {/* Products Panel */} +
+ {/* Search bar */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* Category tabs */} + + + {/* Products grid */} +
+ {productsLoading ? ( +
+
+
+ ) : filteredProducts?.length === 0 ? ( +
+ +

No se encontraron productos

+
+ ) : ( +
+ {filteredProducts?.map((product) => ( + toggleFavorite.mutate(product.id)} + /> + ))} +
+ )} +
+
+ + {/* Cart Panel - Hidden on mobile, shown on larger screens */} +
+ setIsCheckoutOpen(true)} /> +
+
+ + {/* Mobile Cart Button */} + setIsCheckoutOpen(true)} /> + + {/* Checkout Modal */} + setIsCheckoutOpen(false)} + onSuccess={handleCheckoutSuccess} + /> + + {/* Success Modal */} + setCompletedSale(null)} + /> +
+ ); +} + +function MobileCartButton({ onCheckout }: { onCheckout: () => void }) { + const { itemCount, total } = useCartStore(); + + if (itemCount === 0) return null; + + return ( +
+ +
+ ); +} + +// Import for the empty state +import { Package } from 'lucide-react'; diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/ReportsPage.tsx b/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..9ae09c3 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/pages/ReportsPage.tsx @@ -0,0 +1,157 @@ +import { ArrowLeft, TrendingUp, ShoppingCart, XCircle, Banknote, CreditCard, Smartphone } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { useDailySummary, useTodaySales } from '@/hooks/useSales'; + +export function ReportsPage() { + const { data: summary, isLoading: summaryLoading } = useDailySummary(); + const { data: sales, isLoading: salesLoading } = useTodaySales(); + + const isLoading = summaryLoading || salesLoading; + + return ( +
+ {/* Header */} +
+ + + +

Reporte del dia

+
+ + {isLoading ? ( +
+
+
+ ) : ( +
+ {/* Summary Cards */} +
+ } + label="Ventas totales" + value={`$${summary?.totalSales.toFixed(2) || '0.00'}`} + bgColor="bg-green-50" + /> + } + label="Transacciones" + value={summary?.salesCount.toString() || '0'} + bgColor="bg-blue-50" + /> + } + label="Canceladas" + value={summary?.cancelledCount.toString() || '0'} + bgColor="bg-red-50" + /> + } + label="Promedio" + value={`$${summary?.salesCount ? (summary.totalSales / summary.salesCount).toFixed(2) : '0.00'}`} + bgColor="bg-purple-50" + /> +
+ + {/* Payment breakdown */} +
+

Por forma de pago

+
+ } + label="Efectivo" + amount={summary?.cashSales || 0} + /> + } + label="Tarjeta" + amount={summary?.cardSales || 0} + /> + } + label="Transferencia" + amount={summary?.transferSales || 0} + /> +
+
+ + {/* Recent sales */} +
+
+

Ventas de hoy

+
+
+ {sales?.length === 0 ? ( +
+ No hay ventas registradas hoy +
+ ) : ( + sales?.slice(0, 10).map((sale) => ( +
+
+

#{sale.ticketNumber}

+

+ {new Date(sale.createdAt).toLocaleTimeString('es-MX', { + hour: '2-digit', + minute: '2-digit', + })} + {' - '} + {sale.items.length} productos +

+
+
+

+ ${sale.total.toFixed(2)} +

+ {sale.status === 'cancelled' && ( + Cancelada + )} +
+
+ )) + )} +
+
+
+ )} +
+ ); +} + +interface SummaryCardProps { + icon: React.ReactNode; + label: string; + value: string; + bgColor: string; +} + +function SummaryCard({ icon, label, value, bgColor }: SummaryCardProps) { + return ( +
+
+ {icon} +
+

{label}

+

{value}

+
+ ); +} + +interface PaymentRowProps { + icon: React.ReactNode; + label: string; + amount: number; +} + +function PaymentRow({ icon, label, amount }: PaymentRowProps) { + return ( +
+
+
+ {icon} +
+ {label} +
+ ${amount.toFixed(2)} +
+ ); +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/services/api.ts b/projects/erp-suite/apps/products/pos-micro/frontend/src/services/api.ts new file mode 100644 index 0000000..d1e0b0b --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/services/api.ts @@ -0,0 +1,61 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { useAuthStore } from '@/store/auth'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1'; + +export const api = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor - add auth token +api.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = useAuthStore.getState().accessToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +// Response interceptor - handle token refresh +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + // If 401 and we haven't retried yet + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = useAuthStore.getState().refreshToken; + if (!refreshToken) { + throw new Error('No refresh token'); + } + + const response = await axios.post(`${API_URL}/auth/refresh`, { + refreshToken, + }); + + const { accessToken, refreshToken: newRefreshToken } = response.data; + useAuthStore.getState().setTokens(accessToken, newRefreshToken); + + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return api(originalRequest); + } catch { + useAuthStore.getState().logout(); + window.location.href = '/login'; + return Promise.reject(error); + } + } + + return Promise.reject(error); + } +); + +export default api; diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/store/auth.ts b/projects/erp-suite/apps/products/pos-micro/frontend/src/store/auth.ts new file mode 100644 index 0000000..108494d --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/store/auth.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { User, Tenant } from '@/types'; + +interface AuthState { + accessToken: string | null; + refreshToken: string | null; + user: User | null; + tenant: Tenant | null; + isAuthenticated: boolean; + setTokens: (accessToken: string, refreshToken: string) => void; + setUser: (user: User, tenant: Tenant) => void; + login: (accessToken: string, refreshToken: string, user: User, tenant: Tenant) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + accessToken: null, + refreshToken: null, + user: null, + tenant: null, + isAuthenticated: false, + + setTokens: (accessToken, refreshToken) => + set({ accessToken, refreshToken }), + + setUser: (user, tenant) => + set({ user, tenant }), + + login: (accessToken, refreshToken, user, tenant) => + set({ + accessToken, + refreshToken, + user, + tenant, + isAuthenticated: true, + }), + + logout: () => + set({ + accessToken: null, + refreshToken: null, + user: null, + tenant: null, + isAuthenticated: false, + }), + }), + { + name: 'pos-micro-auth', + partialize: (state) => ({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + user: state.user, + tenant: state.tenant, + isAuthenticated: state.isAuthenticated, + }), + } + ) +); diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/store/cart.ts b/projects/erp-suite/apps/products/pos-micro/frontend/src/store/cart.ts new file mode 100644 index 0000000..f706fcc --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/store/cart.ts @@ -0,0 +1,125 @@ +import { create } from 'zustand'; +import type { Product, CartItem, PaymentMethod } from '@/types'; + +interface CartState { + items: CartItem[]; + paymentMethod: PaymentMethod | null; + amountReceived: number; + notes: string; + + // Computed + subtotal: number; + discount: number; + tax: number; + total: number; + itemCount: number; + + // Actions + addItem: (product: Product, quantity?: number) => void; + removeItem: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + applyItemDiscount: (productId: string, discount: number) => void; + setPaymentMethod: (method: PaymentMethod) => void; + setAmountReceived: (amount: number) => void; + setNotes: (notes: string) => void; + clear: () => void; +} + +const calculateTotals = (items: CartItem[]) => { + 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; + const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); + + return { subtotal, discount, tax, total, itemCount }; +}; + +export const useCartStore = create((set, get) => ({ + items: [], + paymentMethod: null, + amountReceived: 0, + notes: '', + subtotal: 0, + discount: 0, + tax: 0, + total: 0, + itemCount: 0, + + addItem: (product, quantity = 1) => { + const { items } = get(); + const existingIndex = items.findIndex((item) => item.product.id === product.id); + + let newItems: CartItem[]; + + 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 + ); + } else { + // Add new item + const newItem: CartItem = { + product, + quantity, + unitPrice: product.price, + discount: 0, + subtotal: quantity * product.price, + }; + newItems = [...items, newItem]; + } + + set({ items: newItems, ...calculateTotals(newItems) }); + }, + + removeItem: (productId) => { + const newItems = get().items.filter((item) => item.product.id !== productId); + set({ items: newItems, ...calculateTotals(newItems) }); + }, + + updateQuantity: (productId, quantity) => { + if (quantity <= 0) { + get().removeItem(productId); + return; + } + + const newItems = get().items.map((item) => + item.product.id === productId + ? { ...item, quantity, subtotal: quantity * item.unitPrice } + : item + ); + set({ items: newItems, ...calculateTotals(newItems) }); + }, + + applyItemDiscount: (productId, discount) => { + const newItems = get().items.map((item) => + item.product.id === productId ? { ...item, discount } : item + ); + set({ items: newItems, ...calculateTotals(newItems) }); + }, + + setPaymentMethod: (method) => set({ paymentMethod: method }), + + setAmountReceived: (amount) => set({ amountReceived: amount }), + + setNotes: (notes) => set({ notes }), + + clear: () => + set({ + items: [], + paymentMethod: null, + amountReceived: 0, + notes: '', + subtotal: 0, + discount: 0, + tax: 0, + total: 0, + itemCount: 0, + }), +})); diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/styles/index.css b/projects/erp-suite/apps/products/pos-micro/frontend/src/styles/index.css new file mode 100644 index 0000000..142cf4e --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/styles/index.css @@ -0,0 +1,82 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: 'Inter', system-ui, sans-serif; + -webkit-tap-highlight-color: transparent; + } + + body { + @apply bg-gray-50 text-gray-900 antialiased; + touch-action: manipulation; + } + + /* Prevent pull-to-refresh on mobile */ + html, body { + overscroll-behavior-y: contain; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-primary { + @apply btn bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500 active:bg-primary-700; + } + + .btn-secondary { + @apply btn bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500; + } + + .btn-danger { + @apply btn bg-red-500 text-white hover:bg-red-600 focus:ring-red-500; + } + + .btn-lg { + @apply px-6 py-3 text-lg; + } + + .card { + @apply bg-white rounded-xl shadow-sm border border-gray-100; + } + + .input { + @apply w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none transition-colors; + } + + .product-card { + @apply card p-3 flex flex-col items-center justify-center cursor-pointer hover:shadow-md transition-shadow active:scale-95; + min-height: 100px; + } + + .cart-item { + @apply flex items-center justify-between py-3 border-b border-gray-100 last:border-0; + } + + .quantity-btn { + @apply w-10 h-10 rounded-full flex items-center justify-center text-lg font-bold transition-colors; + } +} + +@layer utilities { + .safe-bottom { + padding-bottom: env(safe-area-inset-bottom); + } + + .safe-top { + padding-top: env(safe-area-inset-top); + } + + /* Hide scrollbar but keep functionality */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/src/types/index.ts b/projects/erp-suite/apps/products/pos-micro/frontend/src/types/index.ts new file mode 100644 index 0000000..5829222 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/src/types/index.ts @@ -0,0 +1,115 @@ +// ============================================================================= +// POS MICRO - TypeScript Types +// ============================================================================= + +export interface Product { + id: string; + name: string; + barcode?: string; + price: number; + cost?: number; + categoryId?: string; + category?: Category; + imageUrl?: string; + trackStock: boolean; + currentStock: number; + minStock: number; + isFavorite: boolean; + isActive: boolean; +} + +export interface Category { + id: string; + name: string; + color?: string; + sortOrder: number; + productCount?: number; +} + +export interface CartItem { + product: Product; + quantity: number; + unitPrice: number; + discount: number; + subtotal: number; +} + +export interface Sale { + id: string; + ticketNumber: string; + items: SaleItem[]; + subtotal: number; + discount: number; + tax: number; + total: number; + paymentMethod: PaymentMethod; + amountReceived?: number; + change?: number; + status: 'completed' | 'cancelled'; + notes?: string; + createdAt: string; +} + +export interface SaleItem { + id: string; + productId: string; + productName: string; + quantity: number; + unitPrice: number; + discount: number; + subtotal: number; +} + +export interface PaymentMethod { + id: string; + name: string; + type: 'cash' | 'card' | 'transfer' | 'other'; + isActive: boolean; + requiresReference: boolean; +} + +export interface DailySummary { + date: string; + totalSales: number; + salesCount: number; + cancelledCount: number; + cashSales: number; + cardSales: number; + transferSales: number; +} + +export interface User { + id: string; + name: string; + role: 'owner' | 'cashier'; +} + +export interface Tenant { + id: string; + businessName: string; + planType: 'pos_micro'; + maxProducts: number; + maxSalesPerMonth: number; + currentMonthSales: number; +} + +export interface AuthResponse { + accessToken: string; + refreshToken: string; + user: User; + tenant: Tenant; +} + +// API Response types +export interface ApiResponse { + data: T; + message?: string; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/tailwind.config.js b/projects/erp-suite/apps/products/pos-micro/frontend/tailwind.config.js new file mode 100644 index 0000000..3d46f8b --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/tailwind.config.js @@ -0,0 +1,26 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + primary: { + 50: '#ecfdf5', + 100: '#d1fae5', + 200: '#a7f3d0', + 300: '#6ee7b7', + 400: '#34d399', + 500: '#10b981', + 600: '#059669', + 700: '#047857', + 800: '#065f46', + 900: '#064e3b', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +}; diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/tsconfig.json b/projects/erp-suite/apps/products/pos-micro/frontend/tsconfig.json new file mode 100644 index 0000000..5413626 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/tsconfig.node.json b/projects/erp-suite/apps/products/pos-micro/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/projects/erp-suite/apps/products/pos-micro/frontend/vite.config.ts b/projects/erp-suite/apps/products/pos-micro/frontend/vite.config.ts new file mode 100644 index 0000000..e602774 --- /dev/null +++ b/projects/erp-suite/apps/products/pos-micro/frontend/vite.config.ts @@ -0,0 +1,70 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { VitePWA } from 'vite-plugin-pwa'; +import path from 'path'; + +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'], + manifest: { + name: 'POS Micro', + short_name: 'POS', + description: 'Punto de venta ultra-simple para negocios pequeños', + theme_color: '#10b981', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: '/', + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable', + }, + ], + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + runtimeCaching: [ + { + urlPattern: /^https:\/\/api\./i, + handler: 'NetworkFirst', + options: { + cacheName: 'api-cache', + expiration: { + maxEntries: 100, + maxAgeSeconds: 60 * 60 * 24, // 24 hours + }, + cacheableResponse: { + statuses: [0, 200], + }, + }, + }, + ], + }, + }), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 5173, + host: true, + }, +});