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:
parent
9bfc6fb152
commit
e85b548b8f
@ -0,0 +1,6 @@
|
||||
# =============================================================================
|
||||
# POS MICRO - Frontend Environment Variables
|
||||
# =============================================================================
|
||||
|
||||
# API URL
|
||||
VITE_API_URL=http://localhost:3000/api/v1
|
||||
@ -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"]
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -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,
|
||||
}),
|
||||
}));
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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: [],
|
||||
};
|
||||
@ -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" }]
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user