feat(pos-micro): Implement React PWA frontend

- 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 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2025-12-08 12:15:46 -06:00
parent 9bfc6fb152
commit e85b548b8f
29 changed files with 2193 additions and 0 deletions

View File

@ -0,0 +1,6 @@
# =============================================================================
# POS MICRO - Frontend Environment Variables
# =============================================================================
# API URL
VITE_API_URL=http://localhost:3000/api/v1

View File

@ -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"]

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#10b981" />
<meta name="description" content="POS Micro - Punto de venta ultra-simple" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>POS Micro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -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;
}

View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -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 <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
export default function App() {
return (
<Routes>
{/* Public routes */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<POSPage />
</ProtectedRoute>
}
/>
<Route
path="/reports"
element={
<ProtectedRoute>
<ReportsPage />
</ProtectedRoute>
}
/>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@ -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 (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<ShoppingCart className="w-16 h-16 mb-4" />
<p className="text-lg">Carrito vacio</p>
<p className="text-sm">Selecciona productos para agregar</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Cart items */}
<div className="flex-1 overflow-y-auto no-scrollbar p-4">
{items.map((item) => (
<CartItemRow
key={item.product.id}
item={item}
onUpdateQuantity={(qty) => updateQuantity(item.product.id, qty)}
onRemove={() => removeItem(item.product.id)}
/>
))}
</div>
{/* Cart summary */}
<div className="border-t border-gray-200 p-4 bg-white">
<div className="flex justify-between text-lg font-bold mb-4">
<span>Total ({itemCount} productos)</span>
<span className="text-primary-600">${total.toFixed(2)}</span>
</div>
<button
className="btn-primary w-full btn-lg"
onClick={onCheckout}
>
Cobrar
</button>
</div>
</div>
);
}
interface CartItemRowProps {
item: CartItem;
onUpdateQuantity: (quantity: number) => void;
onRemove: () => void;
}
function CartItemRow({ item, onUpdateQuantity, onRemove }: CartItemRowProps) {
return (
<div className="cart-item">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.product.name}</p>
<p className="text-sm text-gray-500">
${item.unitPrice.toFixed(2)} c/u
</p>
</div>
<div className="flex items-center gap-2 ml-4">
{/* Quantity controls */}
<button
className="quantity-btn bg-gray-100 hover:bg-gray-200 text-gray-700"
onClick={() => onUpdateQuantity(item.quantity - 1)}
>
<Minus className="w-4 h-4" />
</button>
<span className="w-8 text-center font-medium">{item.quantity}</span>
<button
className="quantity-btn bg-primary-100 hover:bg-primary-200 text-primary-700"
onClick={() => onUpdateQuantity(item.quantity + 1)}
>
<Plus className="w-4 h-4" />
</button>
{/* Subtotal */}
<span className="w-20 text-right font-medium">
${item.subtotal.toFixed(2)}
</span>
{/* Remove button */}
<button
className="p-2 text-red-500 hover:bg-red-50 rounded-full"
onClick={onRemove}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex gap-2 overflow-x-auto no-scrollbar py-2 px-4 bg-white border-b">
<button
className={clsx(
'px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors',
selectedCategoryId === null
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
)}
onClick={() => onSelect(null)}
>
Todos
</button>
<button
className={clsx(
'px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors',
selectedCategoryId === 'favorites'
? 'bg-yellow-400 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
)}
onClick={() => onSelect('favorites')}
>
Favoritos
</button>
{categories.map((category) => (
<button
key={category.id}
className={clsx(
'px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors',
selectedCategoryId === category.id
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
)}
style={
selectedCategoryId === category.id && category.color
? { backgroundColor: category.color }
: undefined
}
onClick={() => onSelect(category.id)}
>
{category.name}
</button>
))}
</div>
);
}

View File

@ -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 (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={handleClose}
/>
{/* Modal */}
<div className="relative bg-white w-full sm:max-w-md sm:rounded-2xl rounded-t-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-xl font-bold">Cobrar</h2>
<button
className="p-2 hover:bg-gray-100 rounded-full"
onClick={handleClose}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Total */}
<div className="bg-primary-50 p-6 text-center">
<p className="text-sm text-gray-600">Total a cobrar</p>
<p className="text-4xl font-bold text-primary-600">${total.toFixed(2)}</p>
</div>
{/* Content */}
<div className="p-4">
{step === 'payment' && (
<PaymentStep
paymentMethods={paymentMethods || []}
onSelect={handleSelectPayment}
/>
)}
{step === 'amount' && (
<AmountStep
total={total}
amountReceived={amountReceived}
onAmountChange={setAmountReceived}
onBack={() => setStep('payment')}
onConfirm={() => setStep('confirm')}
/>
)}
{step === 'confirm' && (
<ConfirmStep
total={total}
paymentMethod={paymentMethod!}
amountReceived={amountReceived}
change={change}
isLoading={createSale.isPending}
onBack={() => setStep(paymentMethod?.type === 'cash' ? 'amount' : 'payment')}
onConfirm={handleConfirm}
/>
)}
</div>
</div>
</div>
);
}
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 (
<div className="space-y-3">
<p className="text-sm text-gray-600 mb-4">Selecciona forma de pago</p>
{paymentMethods.map((method) => {
const Icon = getIcon(method.type);
return (
<button
key={method.id}
className="w-full flex items-center gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-primary-500 transition-colors"
onClick={() => onSelect(method)}
>
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center">
<Icon className="w-6 h-6 text-primary-600" />
</div>
<span className="text-lg font-medium">{method.name}</span>
</button>
);
})}
</div>
);
}
interface AmountStepProps {
total: number;
amountReceived: number;
onAmountChange: (amount: number) => void;
onBack: () => void;
onConfirm: () => void;
}
function AmountStep({ total, amountReceived, onAmountChange, onBack, onConfirm }: AmountStepProps) {
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">Cantidad recibida</p>
{/* Input */}
<input
type="number"
className="input text-center text-2xl font-bold"
value={amountReceived || ''}
onChange={(e) => onAmountChange(Number(e.target.value))}
placeholder="0.00"
autoFocus
/>
{/* Quick amounts */}
<div className="grid grid-cols-5 gap-2">
{QUICK_AMOUNTS.map((amount) => (
<button
key={amount}
className="py-2 px-3 bg-gray-100 rounded-lg font-medium hover:bg-gray-200"
onClick={() => onAmountChange(amount)}
>
${amount}
</button>
))}
</div>
{/* Exact amount button */}
<button
className="w-full py-2 text-primary-600 font-medium"
onClick={() => onAmountChange(total)}
>
Monto exacto (${total.toFixed(2)})
</button>
{/* Change preview */}
{amountReceived >= total && (
<div className="bg-green-50 p-4 rounded-xl text-center">
<p className="text-sm text-green-600">Cambio</p>
<p className="text-2xl font-bold text-green-600">
${(amountReceived - total).toFixed(2)}
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4">
<button className="btn-secondary flex-1" onClick={onBack}>
Atras
</button>
<button
className="btn-primary flex-1"
disabled={amountReceived < total}
onClick={onConfirm}
>
Continuar
</button>
</div>
</div>
);
}
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 (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Metodo de pago</span>
<span className="font-medium">{paymentMethod.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Total</span>
<span className="font-medium">${total.toFixed(2)}</span>
</div>
{paymentMethod.type === 'cash' && (
<>
<div className="flex justify-between">
<span className="text-gray-600">Recibido</span>
<span className="font-medium">${amountReceived.toFixed(2)}</span>
</div>
<div className="flex justify-between text-lg font-bold text-green-600">
<span>Cambio</span>
<span>${change.toFixed(2)}</span>
</div>
</>
)}
</div>
<div className="flex gap-3 pt-4">
<button className="btn-secondary flex-1" onClick={onBack} disabled={isLoading}>
Atras
</button>
<button
className={clsx('btn-primary flex-1 gap-2', isLoading && 'opacity-50')}
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? (
'Procesando...'
) : (
<>
<Check className="w-5 h-5" />
Confirmar
</>
)}
</button>
</div>
</div>
);
}

View File

@ -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 (
<>
<header className="bg-white border-b px-4 py-3 flex items-center justify-between safe-top">
<div className="flex items-center gap-3">
<button
className="p-2 hover:bg-gray-100 rounded-lg"
onClick={() => setIsMenuOpen(true)}
>
<Menu className="w-6 h-6" />
</button>
<div>
<h1 className="font-bold text-lg text-gray-900">POS Micro</h1>
<p className="text-xs text-gray-500">{tenant?.businessName}</p>
</div>
</div>
</header>
{/* Mobile menu overlay */}
{isMenuOpen && (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setIsMenuOpen(false)}
/>
<nav className="absolute left-0 top-0 bottom-0 w-64 bg-white shadow-xl">
<div className="p-4 border-b">
<h2 className="font-bold text-lg">Menu</h2>
</div>
<div className="p-2">
<NavLink
to="/"
icon={<BarChart3 className="w-5 h-5" />}
label="Punto de Venta"
onClick={() => setIsMenuOpen(false)}
/>
<NavLink
to="/reports"
icon={<BarChart3 className="w-5 h-5" />}
label="Reportes"
onClick={() => setIsMenuOpen(false)}
/>
<NavLink
to="/products"
icon={<Package className="w-5 h-5" />}
label="Productos"
onClick={() => setIsMenuOpen(false)}
/>
<NavLink
to="/settings"
icon={<Settings className="w-5 h-5" />}
label="Configuracion"
onClick={() => setIsMenuOpen(false)}
/>
<button
className="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg"
onClick={handleLogout}
>
<LogOut className="w-5 h-5" />
Cerrar sesion
</button>
</div>
</nav>
</div>
)}
</>
);
}
interface NavLinkProps {
to: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
}
function NavLink({ to, icon, label, onClick }: NavLinkProps) {
return (
<Link
to={to}
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-gray-100 rounded-lg"
onClick={onClick}
>
{icon}
{label}
</Link>
);
}

View File

@ -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 (
<div
className={clsx(
'product-card relative',
isOutOfStock && 'opacity-50 cursor-not-allowed'
)}
onClick={() => !isOutOfStock && onSelect(product)}
>
{/* Favorite button */}
{onToggleFavorite && (
<button
className="absolute top-1 right-1 p-1"
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(product.id);
}}
>
<Star
className={clsx(
'w-4 h-4',
product.isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'
)}
/>
</button>
)}
{/* Product image or placeholder */}
{product.imageUrl ? (
<img
src={product.imageUrl}
alt={product.name}
className="w-12 h-12 object-cover rounded-lg mb-2"
/>
) : (
<div className="w-12 h-12 bg-primary-100 rounded-lg mb-2 flex items-center justify-center">
<span className="text-primary-600 font-bold text-lg">
{product.name.charAt(0).toUpperCase()}
</span>
</div>
)}
{/* Product info */}
<p className="text-sm font-medium text-center line-clamp-2 leading-tight">
{product.name}
</p>
<p className="text-primary-600 font-bold mt-1">
${product.price.toFixed(2)}
</p>
{/* Stock indicator */}
{product.trackStock && (
<p
className={clsx(
'text-xs mt-1',
isOutOfStock ? 'text-red-500' : isLowStock ? 'text-yellow-600' : 'text-gray-400'
)}
>
{isOutOfStock ? 'Agotado' : `${product.currentStock} disponibles`}
</p>
)}
</div>
);
}

View File

@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white w-full max-w-sm mx-4 rounded-2xl overflow-hidden">
{/* Close button */}
<button
className="absolute top-4 right-4 p-2 hover:bg-gray-100 rounded-full"
onClick={onClose}
>
<X className="w-5 h-5" />
</button>
{/* Success icon */}
<div className="pt-8 pb-4 flex justify-center">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
{/* Content */}
<div className="text-center px-6 pb-6">
<h2 className="text-2xl font-bold text-gray-900">Venta completada!</h2>
<p className="text-gray-500 mt-1">Ticket #{sale.ticketNumber}</p>
<div className="mt-6 bg-gray-50 rounded-xl p-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Subtotal</span>
<span>${sale.subtotal.toFixed(2)}</span>
</div>
{sale.discount > 0 && (
<div className="flex justify-between text-sm mt-2">
<span className="text-gray-600">Descuento</span>
<span className="text-red-500">-${sale.discount.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-lg font-bold mt-3 pt-3 border-t">
<span>Total</span>
<span className="text-primary-600">${sale.total.toFixed(2)}</span>
</div>
{sale.change && sale.change > 0 && (
<div className="flex justify-between text-sm mt-2 text-green-600">
<span>Cambio</span>
<span>${sale.change.toFixed(2)}</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3 mt-6">
<button
className="btn-secondary flex-1 gap-2"
onClick={handlePrint}
>
<Printer className="w-4 h-4" />
Imprimir
</button>
<button
className="btn-secondary flex-1 gap-2"
onClick={handleShare}
>
<Share2 className="w-4 h-4" />
Compartir
</button>
</div>
<button
className="btn-primary w-full mt-3"
onClick={onClose}
>
Nueva venta
</button>
</div>
</div>
</div>
);
}

View File

@ -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<PaymentMethod[]>('/payments/methods');
return data;
},
staleTime: 1000 * 60 * 30, // 30 minutes - payment methods don't change often
});
}

View File

@ -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<Product[]>(`/products?${params}`);
return data;
},
});
}
export function useFavoriteProducts() {
return useQuery({
queryKey: ['products', 'favorites'],
queryFn: async () => {
const { data } = await api.get<Product[]>('/products/favorites');
return data;
},
});
}
export function useSearchProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (barcode: string) => {
const { data } = await api.get<Product>(`/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<Product>(`/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<Category[]>('/categories');
return data;
},
});
}

View File

@ -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<Sale>('/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<Sale[]>(`/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<DailySummary>(`/sales/summary/${queryDate}`);
return data;
},
});
}
export function useCancelSale() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (saleId: string) => {
const { data } = await api.patch<Sale>(`/sales/${saleId}/cancel`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales'] });
queryClient.invalidateQueries({ queryKey: ['daily-summary'] });
queryClient.invalidateQueries({ queryKey: ['products'] }); // Stock restored
},
});
}

View File

@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

View File

@ -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<AuthResponse>(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 (
<div className="min-h-screen bg-gradient-to-b from-primary-500 to-primary-700 flex flex-col items-center justify-center p-4">
{/* Logo */}
<div className="mb-8 text-center">
<div className="w-20 h-20 bg-white rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<ShoppingBag className="w-10 h-10 text-primary-600" />
</div>
<h1 className="text-3xl font-bold text-white">POS Micro</h1>
<p className="text-primary-100 mt-1">Punto de venta simple</p>
</div>
{/* Login Card */}
<div className="w-full max-w-sm bg-white rounded-2xl shadow-xl p-6">
<h2 className="text-xl font-bold text-gray-900 mb-6 text-center">
{isRegister ? 'Registrar negocio' : 'Iniciar sesion'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<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">
PIN ({isRegister ? '4-6 digitos' : '4-6 digitos'})
</label>
<div className="relative">
<input
type={showPin ? 'text' : 'password'}
className="input pr-10"
placeholder="****"
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
required
minLength={4}
maxLength={6}
inputMode="numeric"
pattern="[0-9]*"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
onClick={() => setShowPin(!showPin)}
>
{showPin ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
{error && (
<div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg">
{error}
</div>
)}
<button
type="submit"
className="btn-primary w-full btn-lg"
disabled={isLoading}
>
{isLoading ? 'Cargando...' : isRegister ? 'Registrar' : 'Entrar'}
</button>
</form>
<div className="mt-6 text-center">
<button
type="button"
className="text-primary-600 text-sm font-medium"
onClick={() => {
setIsRegister(!isRegister);
setError('');
}}
>
{isRegister
? 'Ya tengo cuenta, iniciar sesion'
: 'No tengo cuenta, registrarme'}
</button>
</div>
</div>
{/* Footer */}
<p className="mt-8 text-primary-200 text-sm">
$100 MXN/mes - Cancela cuando quieras
</p>
</div>
);
}

View File

@ -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<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [completedSale, setCompletedSale] = useState<Sale | null>(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 (
<div className="h-screen flex flex-col bg-gray-50">
<Header />
<div className="flex-1 flex overflow-hidden">
{/* Products Panel */}
<div className="flex-1 flex flex-col min-w-0">
{/* Search bar */}
<form onSubmit={handleBarcodeSearch} className="p-4 bg-white border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
className="input pl-10"
placeholder="Buscar producto o escanear codigo..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</form>
{/* Category tabs */}
<CategoryTabs
categories={categories || []}
selectedCategoryId={selectedCategory}
onSelect={setSelectedCategory}
/>
{/* Products grid */}
<div className="flex-1 overflow-y-auto p-4">
{productsLoading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full" />
</div>
) : filteredProducts?.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<Package className="w-16 h-16 mb-4" />
<p>No se encontraron productos</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filteredProducts?.map((product) => (
<ProductCard
key={product.id}
product={product}
onSelect={handleSelectProduct}
onToggleFavorite={() => toggleFavorite.mutate(product.id)}
/>
))}
</div>
)}
</div>
</div>
{/* Cart Panel - Hidden on mobile, shown on larger screens */}
<div className="hidden lg:flex w-96 border-l bg-white flex-col">
<CartPanel onCheckout={() => setIsCheckoutOpen(true)} />
</div>
</div>
{/* Mobile Cart Button */}
<MobileCartButton onCheckout={() => setIsCheckoutOpen(true)} />
{/* Checkout Modal */}
<CheckoutModal
isOpen={isCheckoutOpen}
onClose={() => setIsCheckoutOpen(false)}
onSuccess={handleCheckoutSuccess}
/>
{/* Success Modal */}
<SuccessModal
isOpen={!!completedSale}
sale={completedSale}
onClose={() => setCompletedSale(null)}
/>
</div>
);
}
function MobileCartButton({ onCheckout }: { onCheckout: () => void }) {
const { itemCount, total } = useCartStore();
if (itemCount === 0) return null;
return (
<div className="lg:hidden fixed bottom-0 left-0 right-0 p-4 bg-white border-t safe-bottom">
<button className="btn-primary w-full btn-lg" onClick={onCheckout}>
Cobrar ({itemCount}) - ${total.toFixed(2)}
</button>
</div>
);
}
// Import for the empty state
import { Package } from 'lucide-react';

View File

@ -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 (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b px-4 py-3 flex items-center gap-3 safe-top">
<Link to="/" className="p-2 hover:bg-gray-100 rounded-lg">
<ArrowLeft className="w-6 h-6" />
</Link>
<h1 className="font-bold text-lg">Reporte del dia</h1>
</header>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full" />
</div>
) : (
<div className="p-4 space-y-4">
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-4">
<SummaryCard
icon={<TrendingUp className="w-6 h-6 text-green-600" />}
label="Ventas totales"
value={`$${summary?.totalSales.toFixed(2) || '0.00'}`}
bgColor="bg-green-50"
/>
<SummaryCard
icon={<ShoppingCart className="w-6 h-6 text-blue-600" />}
label="Transacciones"
value={summary?.salesCount.toString() || '0'}
bgColor="bg-blue-50"
/>
<SummaryCard
icon={<XCircle className="w-6 h-6 text-red-600" />}
label="Canceladas"
value={summary?.cancelledCount.toString() || '0'}
bgColor="bg-red-50"
/>
<SummaryCard
icon={<TrendingUp className="w-6 h-6 text-purple-600" />}
label="Promedio"
value={`$${summary?.salesCount ? (summary.totalSales / summary.salesCount).toFixed(2) : '0.00'}`}
bgColor="bg-purple-50"
/>
</div>
{/* Payment breakdown */}
<div className="card p-4">
<h2 className="font-bold text-gray-900 mb-4">Por forma de pago</h2>
<div className="space-y-3">
<PaymentRow
icon={<Banknote className="w-5 h-5" />}
label="Efectivo"
amount={summary?.cashSales || 0}
/>
<PaymentRow
icon={<CreditCard className="w-5 h-5" />}
label="Tarjeta"
amount={summary?.cardSales || 0}
/>
<PaymentRow
icon={<Smartphone className="w-5 h-5" />}
label="Transferencia"
amount={summary?.transferSales || 0}
/>
</div>
</div>
{/* Recent sales */}
<div className="card">
<div className="p-4 border-b">
<h2 className="font-bold text-gray-900">Ventas de hoy</h2>
</div>
<div className="divide-y">
{sales?.length === 0 ? (
<div className="p-8 text-center text-gray-400">
No hay ventas registradas hoy
</div>
) : (
sales?.slice(0, 10).map((sale) => (
<div key={sale.id} className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">#{sale.ticketNumber}</p>
<p className="text-sm text-gray-500">
{new Date(sale.createdAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
})}
{' - '}
{sale.items.length} productos
</p>
</div>
<div className="text-right">
<p className={`font-bold ${sale.status === 'cancelled' ? 'text-red-500 line-through' : 'text-primary-600'}`}>
${sale.total.toFixed(2)}
</p>
{sale.status === 'cancelled' && (
<span className="text-xs text-red-500">Cancelada</span>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
);
}
interface SummaryCardProps {
icon: React.ReactNode;
label: string;
value: string;
bgColor: string;
}
function SummaryCard({ icon, label, value, bgColor }: SummaryCardProps) {
return (
<div className="card p-4">
<div className={`w-12 h-12 ${bgColor} rounded-xl flex items-center justify-center mb-3`}>
{icon}
</div>
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-bold text-gray-900">{value}</p>
</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>
);
}

View File

@ -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;

View File

@ -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<AuthState>()(
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,
}),
}
)
);

View File

@ -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<CartState>((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,
}),
}));

View File

@ -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;
}
}

View File

@ -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<T> {
data: T;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -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: [],
};

View File

@ -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" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -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,
},
});