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