Migración desde michangarrito/frontend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
663614c75e
commit
67d3eef6a5
29
.dockerignore
Normal file
29
.dockerignore
Normal file
@ -0,0 +1,29 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Build output
|
||||
dist
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
9
.env
Normal file
9
.env
Normal file
@ -0,0 +1,9 @@
|
||||
# MiChangarrito Frontend - Environment Variables
|
||||
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:3141
|
||||
VITE_API_PREFIX=/api/v1
|
||||
|
||||
# App Configuration
|
||||
VITE_APP_NAME=MiChangarrito
|
||||
VITE_APP_VERSION=1.0.0
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
63
Dockerfile
Normal file
63
Dockerfile
Normal file
@ -0,0 +1,63 @@
|
||||
# =============================================================================
|
||||
# MiChangarrito - Frontend Dockerfile
|
||||
# =============================================================================
|
||||
# Multi-stage build for React + Vite application
|
||||
# Served by nginx
|
||||
# Puerto: 80 (interno) -> 3140 (host)
|
||||
# =============================================================================
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Build argument for API URL
|
||||
ARG VITE_API_URL=http://localhost:3141/api/v1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Set environment variable for build
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Build application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage with nginx
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Labels
|
||||
LABEL maintainer="ISEM"
|
||||
LABEL description="MiChangarrito Frontend Web"
|
||||
LABEL version="1.0.0"
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nginx-user && \
|
||||
adduser -S nginx-user -u 1001 -G nginx-user && \
|
||||
chown -R nginx-user:nginx-user /usr/share/nginx/html && \
|
||||
chown -R nginx-user:nginx-user /var/cache/nginx && \
|
||||
chown -R nginx-user:nginx-user /var/log/nginx && \
|
||||
touch /var/run/nginx.pid && \
|
||||
chown -R nginx-user:nginx-user /var/run/nginx.pid
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
74
README.md
74
README.md
@ -1,3 +1,73 @@
|
||||
# michangarrito-frontend-v2
|
||||
# React + TypeScript + Vite
|
||||
|
||||
Frontend de michangarrito - Workspace V2
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
144
e2e/auth.spec.ts
Normal file
144
e2e/auth.spec.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E Tests - Autenticacion
|
||||
* MiChangarrito Frontend
|
||||
*/
|
||||
|
||||
test.describe('Autenticacion', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('debe mostrar la pagina de login', async ({ page }) => {
|
||||
// Verificar titulo
|
||||
await expect(page.locator('h1')).toContainText('MiChangarrito');
|
||||
await expect(page.locator('h2')).toContainText('Iniciar sesion');
|
||||
|
||||
// Verificar campos del formulario
|
||||
await expect(page.locator('input[name="phone"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="pin"]')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
||||
|
||||
// Verificar link a registro
|
||||
await expect(page.locator('a[href="/register"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('debe validar campos requeridos', async ({ page }) => {
|
||||
// Intentar enviar formulario vacio
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Los campos tienen required, el navegador debe validar
|
||||
const phoneInput = page.locator('input[name="phone"]');
|
||||
await expect(phoneInput).toHaveAttribute('required', '');
|
||||
});
|
||||
|
||||
test('debe aceptar solo numeros en telefono', async ({ page }) => {
|
||||
const phoneInput = page.locator('input[name="phone"]');
|
||||
|
||||
// Escribir texto con letras
|
||||
await phoneInput.fill('abc123def456');
|
||||
|
||||
// Debe filtrar solo numeros
|
||||
await expect(phoneInput).toHaveValue('123456');
|
||||
});
|
||||
|
||||
test('debe limitar telefono a 10 digitos', async ({ page }) => {
|
||||
const phoneInput = page.locator('input[name="phone"]');
|
||||
|
||||
await phoneInput.fill('12345678901234');
|
||||
|
||||
// maxLength=10
|
||||
await expect(phoneInput).toHaveAttribute('maxLength', '10');
|
||||
});
|
||||
|
||||
test('debe aceptar solo numeros en PIN', async ({ page }) => {
|
||||
const pinInput = page.locator('input[name="pin"]');
|
||||
|
||||
await pinInput.fill('abc1234');
|
||||
|
||||
// Debe filtrar solo numeros
|
||||
await expect(pinInput).toHaveValue('1234');
|
||||
});
|
||||
|
||||
test('debe mostrar error con credenciales invalidas', async ({ page }) => {
|
||||
// Llenar formulario con datos invalidos
|
||||
await page.locator('input[name="phone"]').fill('5500000000');
|
||||
await page.locator('input[name="pin"]').fill('0000');
|
||||
|
||||
// Enviar
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Esperar respuesta del servidor
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Debe mostrar mensaje de error (si el backend esta corriendo)
|
||||
// Si no hay backend, verificamos que el boton vuelve a estar habilitado
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('debe mostrar estado de carga al enviar', async ({ page }) => {
|
||||
// Llenar formulario
|
||||
await page.locator('input[name="phone"]').fill('5512345678');
|
||||
await page.locator('input[name="pin"]').fill('1234');
|
||||
|
||||
// Enviar y verificar estado de carga
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Verificar que muestra "Ingresando..." mientras carga
|
||||
// Nota: Esto es rapido, puede no capturarse siempre
|
||||
await expect(submitButton).toContainText(/Ingresar|Ingresando/);
|
||||
});
|
||||
|
||||
test('debe navegar a registro', async ({ page }) => {
|
||||
await page.locator('a[href="/register"]').click();
|
||||
|
||||
await expect(page).toHaveURL('/register');
|
||||
});
|
||||
|
||||
test('debe redirigir a login si no esta autenticado', async ({ page }) => {
|
||||
// Intentar acceder a ruta protegida
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Debe redirigir a login
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('debe redirigir a login desde rutas protegidas', async ({ page }) => {
|
||||
const protectedRoutes = [
|
||||
'/dashboard',
|
||||
'/products',
|
||||
'/orders',
|
||||
'/customers',
|
||||
'/fiado',
|
||||
'/inventory',
|
||||
'/settings',
|
||||
];
|
||||
|
||||
for (const route of protectedRoutes) {
|
||||
await page.goto(route);
|
||||
await expect(page).toHaveURL('/login');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Registro', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
});
|
||||
|
||||
test('debe mostrar la pagina de registro', async ({ page }) => {
|
||||
await expect(page.locator('h1')).toContainText('MiChangarrito');
|
||||
|
||||
// Verificar link a login
|
||||
await expect(page.locator('a[href="/login"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('debe navegar a login desde registro', async ({ page }) => {
|
||||
await page.locator('a[href="/login"]').click();
|
||||
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
48
e2e/fixtures/test-data.ts
Normal file
48
e2e/fixtures/test-data.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Test Data Fixtures - MiChangarrito E2E Tests
|
||||
*/
|
||||
|
||||
export const TEST_USER = {
|
||||
phone: '5512345678',
|
||||
pin: '1234',
|
||||
name: 'Usuario Test',
|
||||
businessName: 'Tienda Test',
|
||||
};
|
||||
|
||||
export const TEST_PRODUCT = {
|
||||
name: 'Coca-Cola 600ml',
|
||||
sku: 'CC600',
|
||||
barcode: '7501055300000',
|
||||
price: 18.00,
|
||||
cost: 12.00,
|
||||
stock: 50,
|
||||
category: 'Bebidas',
|
||||
};
|
||||
|
||||
export const TEST_CUSTOMER = {
|
||||
name: 'Juan Perez',
|
||||
phone: '5598765432',
|
||||
email: 'juan@test.com',
|
||||
creditLimit: 500,
|
||||
};
|
||||
|
||||
export const TEST_ORDER = {
|
||||
items: [
|
||||
{ productId: '1', quantity: 2, price: 18.00 },
|
||||
{ productId: '2', quantity: 1, price: 25.00 },
|
||||
],
|
||||
total: 61.00,
|
||||
paymentMethod: 'cash',
|
||||
};
|
||||
|
||||
export const ROUTES = {
|
||||
login: '/login',
|
||||
register: '/register',
|
||||
dashboard: '/dashboard',
|
||||
products: '/products',
|
||||
orders: '/orders',
|
||||
customers: '/customers',
|
||||
fiado: '/fiado',
|
||||
inventory: '/inventory',
|
||||
settings: '/settings',
|
||||
};
|
||||
120
e2e/navigation.spec.ts
Normal file
120
e2e/navigation.spec.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E Tests - Navegacion
|
||||
* MiChangarrito Frontend
|
||||
*
|
||||
* Nota: Estos tests requieren autenticacion
|
||||
* Se usan con un usuario de prueba pre-configurado
|
||||
*/
|
||||
|
||||
test.describe('Navegacion Publica', () => {
|
||||
test('debe cargar la aplicacion', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Al no estar autenticado, debe redirigir a login
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('rutas invalidas redirigen a login', async ({ page }) => {
|
||||
await page.goto('/ruta-que-no-existe');
|
||||
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('login page tiene titulo correcto', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page).toHaveTitle(/MiChangarrito/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('UI Responsiva', () => {
|
||||
test('login se adapta a mobile', async ({ page }) => {
|
||||
// Viewport mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/login');
|
||||
|
||||
// El formulario debe ser visible y usable
|
||||
await expect(page.locator('form')).toBeVisible();
|
||||
await expect(page.locator('input[name="phone"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="pin"]')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('login se adapta a tablet', async ({ page }) => {
|
||||
// Viewport tablet
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page.locator('form')).toBeVisible();
|
||||
});
|
||||
|
||||
test('login se adapta a desktop', async ({ page }) => {
|
||||
// Viewport desktop
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page.locator('form')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accesibilidad', () => {
|
||||
test('campos tienen labels', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Verificar que los inputs tienen labels asociados
|
||||
const phoneLabel = page.locator('label[for="phone"]');
|
||||
const pinLabel = page.locator('label[for="pin"]');
|
||||
|
||||
await expect(phoneLabel).toBeVisible();
|
||||
await expect(pinLabel).toBeVisible();
|
||||
});
|
||||
|
||||
test('formulario es navegable con teclado', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Tab al primer campo
|
||||
await page.keyboard.press('Tab');
|
||||
const phoneInput = page.locator('input[name="phone"]');
|
||||
await expect(phoneInput).toBeFocused();
|
||||
|
||||
// Tab al segundo campo
|
||||
await page.keyboard.press('Tab');
|
||||
const pinInput = page.locator('input[name="pin"]');
|
||||
await expect(pinInput).toBeFocused();
|
||||
|
||||
// Tab al boton
|
||||
await page.keyboard.press('Tab');
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await expect(submitButton).toBeFocused();
|
||||
});
|
||||
|
||||
test('formulario puede enviarse con Enter', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Llenar campos
|
||||
await page.locator('input[name="phone"]').fill('5512345678');
|
||||
await page.locator('input[name="pin"]').fill('1234');
|
||||
|
||||
// Enviar con Enter
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// El formulario debe intentar enviarse (el boton estara en estado loading)
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
// Puede estar disabled durante el envio o ya haber terminado
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Performance', () => {
|
||||
test('login carga en menos de 3 segundos', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto('/login');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
expect(loadTime).toBeLessThan(3000);
|
||||
});
|
||||
});
|
||||
149
e2e/orders.spec.ts
Normal file
149
e2e/orders.spec.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_ORDER, TEST_CUSTOMER, ROUTES } from './fixtures/test-data';
|
||||
|
||||
/**
|
||||
* E2E Tests - Pedidos y Fiado
|
||||
* MiChangarrito Frontend
|
||||
*
|
||||
* Nota: Estos tests requieren autenticacion previa
|
||||
* y un backend corriendo con datos de prueba
|
||||
*/
|
||||
|
||||
test.describe('Pedidos - Sin Autenticacion', () => {
|
||||
test('redirige a login si no esta autenticado', async ({ page }) => {
|
||||
await page.goto(ROUTES.orders);
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('fiado redirige a login sin autenticacion', async ({ page }) => {
|
||||
await page.goto(ROUTES.fiado);
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('clientes redirige a login sin autenticacion', async ({ page }) => {
|
||||
await page.goto(ROUTES.customers);
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Pedidos - Estructura de Datos', () => {
|
||||
test('datos de pedido de prueba tienen estructura correcta', () => {
|
||||
expect(TEST_ORDER.items).toBeInstanceOf(Array);
|
||||
expect(TEST_ORDER.items.length).toBeGreaterThan(0);
|
||||
expect(TEST_ORDER.total).toBeGreaterThan(0);
|
||||
expect(TEST_ORDER.paymentMethod).toBeDefined();
|
||||
|
||||
// Verificar estructura de items
|
||||
const item = TEST_ORDER.items[0];
|
||||
expect(item.productId).toBeDefined();
|
||||
expect(item.quantity).toBeGreaterThan(0);
|
||||
expect(item.price).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('datos de cliente de prueba tienen estructura correcta', () => {
|
||||
expect(TEST_CUSTOMER.name).toBeDefined();
|
||||
expect(TEST_CUSTOMER.phone).toBeDefined();
|
||||
expect(TEST_CUSTOMER.phone.length).toBe(10);
|
||||
expect(TEST_CUSTOMER.creditLimit).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('total del pedido es correcto', () => {
|
||||
// Verificar calculo: 2 * 18.00 + 1 * 25.00 = 61.00
|
||||
const calculatedTotal = TEST_ORDER.items.reduce(
|
||||
(sum, item) => sum + item.quantity * item.price,
|
||||
0
|
||||
);
|
||||
expect(calculatedTotal).toBe(TEST_ORDER.total);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Sistema de Fiado', () => {
|
||||
test('rutas de fiado protegidas', async ({ page }) => {
|
||||
const fiadoRoutes = [
|
||||
'/fiado',
|
||||
'/customers',
|
||||
];
|
||||
|
||||
for (const route of fiadoRoutes) {
|
||||
await page.goto(route);
|
||||
await expect(page).toHaveURL('/login');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Historial de Pedidos', () => {
|
||||
test('ruta de pedidos protegida', async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('ruta de dashboard protegida', async ({ page }) => {
|
||||
await page.goto(ROUTES.dashboard);
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Clientes', () => {
|
||||
test('ruta de clientes protegida', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('validacion de limite de credito', () => {
|
||||
// El limite de credito debe ser un numero positivo o cero
|
||||
expect(TEST_CUSTOMER.creditLimit).toBeGreaterThanOrEqual(0);
|
||||
expect(typeof TEST_CUSTOMER.creditLimit).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Metodos de Pago', () => {
|
||||
test('metodo de pago valido', () => {
|
||||
const validPaymentMethods = ['cash', 'card', 'transfer', 'fiado'];
|
||||
expect(validPaymentMethods).toContain(TEST_ORDER.paymentMethod);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Configuracion', () => {
|
||||
test('ruta de configuracion protegida', async ({ page }) => {
|
||||
await page.goto(ROUTES.settings);
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rutas Completas', () => {
|
||||
test('todas las rutas definidas en fixtures existen', () => {
|
||||
const expectedRoutes = [
|
||||
'login',
|
||||
'register',
|
||||
'dashboard',
|
||||
'products',
|
||||
'orders',
|
||||
'customers',
|
||||
'fiado',
|
||||
'inventory',
|
||||
'settings',
|
||||
];
|
||||
|
||||
for (const route of expectedRoutes) {
|
||||
expect(ROUTES[route as keyof typeof ROUTES]).toBeDefined();
|
||||
expect(ROUTES[route as keyof typeof ROUTES]).toMatch(/^\//);
|
||||
}
|
||||
});
|
||||
|
||||
test('todas las rutas protegidas redirigen a login', async ({ page }) => {
|
||||
const protectedRoutes = [
|
||||
ROUTES.dashboard,
|
||||
ROUTES.products,
|
||||
ROUTES.orders,
|
||||
ROUTES.customers,
|
||||
ROUTES.fiado,
|
||||
ROUTES.inventory,
|
||||
ROUTES.settings,
|
||||
];
|
||||
|
||||
for (const route of protectedRoutes) {
|
||||
await page.goto(route);
|
||||
await expect(page).toHaveURL('/login');
|
||||
}
|
||||
});
|
||||
});
|
||||
103
e2e/pos.spec.ts
Normal file
103
e2e/pos.spec.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_PRODUCT, ROUTES } from './fixtures/test-data';
|
||||
|
||||
/**
|
||||
* E2E Tests - Punto de Venta (POS)
|
||||
* MiChangarrito Frontend
|
||||
*
|
||||
* Nota: Estos tests requieren autenticacion previa
|
||||
* y un backend corriendo con datos de prueba
|
||||
*/
|
||||
|
||||
test.describe('Punto de Venta - Sin Autenticacion', () => {
|
||||
test('redirige a login si no esta autenticado', async ({ page }) => {
|
||||
await page.goto('/pos');
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Punto de Venta - UI Basica', () => {
|
||||
// Estos tests verifican la estructura esperada del POS
|
||||
// cuando el usuario esta autenticado
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock de autenticacion para tests
|
||||
// En un escenario real, se usaria un fixture de login
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('pagina de productos existe', async ({ page }) => {
|
||||
await page.goto(ROUTES.products);
|
||||
// Sin auth, redirige a login
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Punto de Venta - Flujo de Venta', () => {
|
||||
// Tests de flujo completo de venta
|
||||
// Requieren setup de autenticacion
|
||||
|
||||
test('estructura del formulario de busqueda', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Verificar que la pagina de login tiene estructura correcta
|
||||
// antes de poder probar el POS
|
||||
await expect(page.locator('form')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Carrito de Compras', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('login tiene campos necesarios para acceder al POS', async ({ page }) => {
|
||||
// Verificar campos necesarios
|
||||
await expect(page.locator('input[name="phone"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="pin"]')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Busqueda de Productos', () => {
|
||||
test('rutas de productos protegidas', async ({ page }) => {
|
||||
// Verificar que todas las rutas relacionadas al POS estan protegidas
|
||||
const posRoutes = [
|
||||
'/products',
|
||||
'/pos',
|
||||
'/inventory',
|
||||
];
|
||||
|
||||
for (const route of posRoutes) {
|
||||
await page.goto(route);
|
||||
await expect(page).toHaveURL('/login');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Metodos de Pago', () => {
|
||||
test('verificar existencia de ruta de pedidos', async ({ page }) => {
|
||||
await page.goto(ROUTES.orders);
|
||||
// Sin auth, redirige
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Recibos y Tickets', () => {
|
||||
test('estructura de datos de producto de prueba', () => {
|
||||
// Verificar que los datos de prueba tienen la estructura correcta
|
||||
expect(TEST_PRODUCT.name).toBeDefined();
|
||||
expect(TEST_PRODUCT.price).toBeGreaterThan(0);
|
||||
expect(TEST_PRODUCT.cost).toBeGreaterThan(0);
|
||||
expect(TEST_PRODUCT.stock).toBeGreaterThanOrEqual(0);
|
||||
expect(TEST_PRODUCT.sku).toBeDefined();
|
||||
expect(TEST_PRODUCT.barcode).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Inventario desde POS', () => {
|
||||
test('ruta de inventario protegida', async ({ page }) => {
|
||||
await page.goto(ROUTES.inventory);
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<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" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
nginx.conf
Normal file
42
nginx.conf
Normal file
@ -0,0 +1,42 @@
|
||||
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;
|
||||
|
||||
# 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;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
4317
package-lock.json
generated
Normal file
4317
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
53
playwright.config.ts
Normal file
53
playwright.config.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright E2E Test Configuration - MiChangarrito Frontend
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html', { outputFolder: 'playwright-report' }],
|
||||
['list'],
|
||||
],
|
||||
use: {
|
||||
baseURL: 'http://localhost:3140',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3140',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
95
src/App.tsx
Normal file
95
src/App.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Products } from './pages/Products';
|
||||
import { Orders } from './pages/Orders';
|
||||
import { Customers } from './pages/Customers';
|
||||
import { Fiado } from './pages/Fiado';
|
||||
import { Inventory } from './pages/Inventory';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Referrals } from './pages/Referrals';
|
||||
import { Invoices } from './pages/Invoices';
|
||||
import { Marketplace } from './pages/Marketplace';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Public route wrapper (redirect if authenticated)
|
||||
function PublicRoute() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isAuthenticated ? <Navigate to="/dashboard" replace /> : <Outlet />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Route>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="customers" element={<Customers />} />
|
||||
<Route path="fiado" element={<Fiado />} />
|
||||
<Route path="inventory" element={<Inventory />} />
|
||||
<Route path="referrals" element={<Referrals />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="marketplace" element={<Marketplace />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
173
src/components/Layout.tsx
Normal file
173
src/components/Layout.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
CreditCard,
|
||||
Boxes,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
Store,
|
||||
LogOut,
|
||||
Gift,
|
||||
FileText,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Productos', href: '/products', icon: Package },
|
||||
{ name: 'Pedidos', href: '/orders', icon: ShoppingCart },
|
||||
{ name: 'Clientes', href: '/customers', icon: Users },
|
||||
{ name: 'Fiado', href: '/fiado', icon: CreditCard },
|
||||
{ name: 'Inventario', href: '/inventory', icon: Boxes },
|
||||
{ name: 'Facturacion', href: '/invoices', icon: FileText },
|
||||
{ name: 'Proveedores', href: '/marketplace', icon: Truck },
|
||||
{ name: 'Referidos', href: '/referrals', icon: Gift },
|
||||
{ name: 'Ajustes', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { user, tenant, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-50 lg:hidden',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-900/80"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<div className="fixed inset-y-0 left-0 w-64 bg-white shadow-xl">
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="h-8 w-8 text-primary-500" />
|
||||
<span className="font-bold text-lg">MiChangarrito</span>
|
||||
</div>
|
||||
<button onClick={() => setSidebarOpen(false)}>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="p-4 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
|
||||
<div className="flex h-16 items-center px-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="h-8 w-8 text-primary-500" />
|
||||
<span className="font-bold text-lg">MiChangarrito</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<span className="text-emerald-600 font-semibold">
|
||||
{tenant ? getInitials(tenant.name) : 'MC'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{tenant?.name || 'Mi Negocio'}</p>
|
||||
<p className="text-xs text-gray-500">{user?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="Cerrar sesion"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Mobile header */}
|
||||
<div className="sticky top-0 z-40 flex h-16 items-center gap-4 bg-white border-b px-4 lg:hidden">
|
||||
<button onClick={() => setSidebarOpen(true)}>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="h-6 w-6 text-primary-500" />
|
||||
<span className="font-bold">MiChangarrito</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-4 lg:p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/payments/ClabeDisplay.tsx
Normal file
112
src/components/payments/ClabeDisplay.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Copy, Check, Building } from 'lucide-react';
|
||||
import { codiSpeiApi } from '../../lib/api';
|
||||
|
||||
interface ClabeDisplayProps {
|
||||
showCreateButton?: boolean;
|
||||
beneficiaryName?: string;
|
||||
}
|
||||
|
||||
export function ClabeDisplay({ showCreateButton = true, beneficiaryName }: ClabeDisplayProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['clabe'],
|
||||
queryFn: async () => {
|
||||
const res = await codiSpeiApi.getClabe();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => codiSpeiApi.createClabe(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clabe'] });
|
||||
},
|
||||
});
|
||||
|
||||
const copyClabe = async () => {
|
||||
if (data?.clabe) {
|
||||
await navigator.clipboard.writeText(data.clabe);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const formatClabe = (clabe: string) => {
|
||||
// Format: XXX XXX XXXX XXXX XXXX
|
||||
return clabe.replace(/(\d{3})(\d{3})(\d{4})(\d{4})(\d{4})/, '$1 $2 $3 $4 $5');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.clabe && showCreateButton) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Building className="h-6 w-6 text-gray-400" />
|
||||
<div>
|
||||
<h4 className="font-medium">CLABE Virtual</h4>
|
||||
<p className="text-sm text-gray-500">Recibe transferencias SPEI</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createMutation.mutate(beneficiaryName || 'Mi Negocio')}
|
||||
disabled={createMutation.isPending}
|
||||
className="w-full px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Creando...' : 'Crear CLABE Virtual'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.clabe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-800">Tu CLABE para recibir SPEI</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyClabe}
|
||||
className="p-2 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
title="Copiar CLABE"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-blue-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<p className="font-mono text-lg text-center font-bold tracking-wider">
|
||||
{formatClabe(data.clabe)}
|
||||
</p>
|
||||
{data.beneficiaryName && (
|
||||
<p className="text-sm text-gray-500 text-center mt-1">
|
||||
{data.beneficiaryName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-blue-600 text-center mt-2">
|
||||
Las transferencias se reflejan automaticamente
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
src/components/payments/CodiQR.tsx
Normal file
167
src/components/payments/CodiQR.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { QrCode, Clock, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { codiSpeiApi } from '../../lib/api';
|
||||
|
||||
interface CodiQRProps {
|
||||
amount: number;
|
||||
description?: string;
|
||||
saleId?: string;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function CodiQR({ amount, description, saleId, onSuccess, onCancel }: CodiQRProps) {
|
||||
const [transactionId, setTransactionId] = useState<string | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState<number>(300); // 5 minutes in seconds
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: () => codiSpeiApi.generateQr({ amount, description, saleId }),
|
||||
onSuccess: (res) => {
|
||||
setTransactionId(res.data.id);
|
||||
setTimeLeft(300);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: status, refetch } = useQuery({
|
||||
queryKey: ['codi-status', transactionId],
|
||||
queryFn: async () => {
|
||||
if (!transactionId) return null;
|
||||
const res = await codiSpeiApi.getCodiStatus(transactionId);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!transactionId,
|
||||
refetchInterval: 3000, // Poll every 3 seconds
|
||||
});
|
||||
|
||||
// Generate QR on mount
|
||||
useEffect(() => {
|
||||
generateMutation.mutate();
|
||||
}, []);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (timeLeft <= 0) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft((prev) => prev - 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [timeLeft]);
|
||||
|
||||
// Handle status changes
|
||||
useEffect(() => {
|
||||
if (status?.status === 'confirmed') {
|
||||
onSuccess?.();
|
||||
} else if (status?.status === 'expired' || status?.status === 'cancelled') {
|
||||
// QR expired or cancelled
|
||||
}
|
||||
}, [status?.status]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
generateMutation.mutate();
|
||||
};
|
||||
|
||||
if (generateMutation.isPending) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||
<p className="text-gray-500">Generando QR de cobro...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status?.status === 'confirmed') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Check className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-green-600 mb-2">Pago Confirmado</h3>
|
||||
<p className="text-gray-500">El pago de ${amount.toFixed(2)} fue recibido</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status?.status === 'expired' || timeLeft <= 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<X className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-red-600 mb-2">QR Expirado</h3>
|
||||
<p className="text-gray-500 mb-4">El codigo QR ha expirado</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Generar nuevo QR
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center p-6">
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-lg font-bold mb-1">Pagar con CoDi</h3>
|
||||
<p className="text-2xl font-bold text-primary-600">${amount.toFixed(2)}</p>
|
||||
{description && <p className="text-sm text-gray-500">{description}</p>}
|
||||
</div>
|
||||
|
||||
{/* QR Code Display */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-lg mb-4">
|
||||
<div className="w-48 h-48 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
{/* In production, generate actual QR code from qrData */}
|
||||
<div className="text-center">
|
||||
<QrCode className="h-32 w-32 text-gray-800 mx-auto" />
|
||||
<p className="text-xs text-gray-400 mt-2">Escanea con tu app bancaria</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="flex items-center gap-2 text-gray-600 mb-4">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Expira en: {formatTime(timeLeft)}</span>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-center text-sm text-gray-500 mb-4">
|
||||
<p>1. Abre la app de tu banco</p>
|
||||
<p>2. Escanea el codigo QR</p>
|
||||
<p>3. Confirma el pago</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 w-full">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Regenerar
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 text-red-600 border border-red-300 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reference */}
|
||||
{status?.reference && (
|
||||
<p className="text-xs text-gray-400 mt-4">Ref: {status.reference}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/contexts/AuthContext.tsx
Normal file
118
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { authApi } from '../lib/api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
businessType: string;
|
||||
subscriptionStatus: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
tenant: Tenant | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (phone: string, pin: string) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
name: string;
|
||||
ownerName: string;
|
||||
businessType: string;
|
||||
phone: string;
|
||||
pin: string;
|
||||
email?: string;
|
||||
whatsapp?: string;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing session
|
||||
const storedUser = localStorage.getItem('user');
|
||||
const storedTenant = localStorage.getItem('tenant');
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
|
||||
if (storedUser && storedTenant && accessToken) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
setTenant(JSON.parse(storedTenant));
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (phone: string, pin: string) => {
|
||||
const response = await authApi.login({ phone, pin });
|
||||
const { accessToken, refreshToken, user: userData, tenant: tenantData } = response.data;
|
||||
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
localStorage.setItem('tenant', JSON.stringify(tenantData));
|
||||
|
||||
setUser(userData);
|
||||
setTenant(tenantData);
|
||||
};
|
||||
|
||||
const register = async (data: RegisterData) => {
|
||||
const response = await authApi.register(data);
|
||||
const { accessToken, refreshToken, user: userData, tenant: tenantData } = response.data;
|
||||
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
localStorage.setItem('tenant', JSON.stringify(tenantData));
|
||||
|
||||
setUser(userData);
|
||||
setTenant(tenantData);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('tenant');
|
||||
setUser(null);
|
||||
setTenant(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
tenant,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
53
src/index.css
Normal file
53
src/index.css
Normal file
@ -0,0 +1,53 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #fff7ed;
|
||||
--color-primary-100: #ffedd5;
|
||||
--color-primary-200: #fed7aa;
|
||||
--color-primary-300: #fdba74;
|
||||
--color-primary-400: #fb923c;
|
||||
--color-primary-500: #f97316;
|
||||
--color-primary-600: #ea580c;
|
||||
--color-primary-700: #c2410c;
|
||||
--color-primary-800: #9a3412;
|
||||
--color-primary-900: #7c2d12;
|
||||
|
||||
--color-secondary-50: #f0fdf4;
|
||||
--color-secondary-100: #dcfce7;
|
||||
--color-secondary-200: #bbf7d0;
|
||||
--color-secondary-300: #86efac;
|
||||
--color-secondary-400: #4ade80;
|
||||
--color-secondary-500: #22c55e;
|
||||
--color-secondary-600: #16a34a;
|
||||
--color-secondary-700: #15803d;
|
||||
--color-secondary-800: #166534;
|
||||
--color-secondary-900: #14532d;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-500 hover:bg-primary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary-500 hover:bg-secondary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply border border-gray-300 hover:border-gray-400 text-gray-700 font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-4;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500;
|
||||
}
|
||||
}
|
||||
216
src/lib/api.ts
Normal file
216
src/lib/api.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
|
||||
// Use proxy in development (vite will forward to backend)
|
||||
// In production, use VITE_API_URL
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
const API_PREFIX = import.meta.env.VITE_API_PREFIX || '/api/v1';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_BASE}${API_PREFIX}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth interceptor - attach token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor - handle 401 errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Try refresh token
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE}${API_PREFIX}/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
const { accessToken, refreshToken: newRefresh } = response.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', newRefresh);
|
||||
|
||||
// Retry original request
|
||||
if (error.config) {
|
||||
error.config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return axios(error.config);
|
||||
}
|
||||
} catch {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('tenant');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else {
|
||||
// No refresh token, redirect to login
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('tenant');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
register: (data: {
|
||||
name: string;
|
||||
ownerName: string;
|
||||
businessType: string;
|
||||
phone: string;
|
||||
pin: string;
|
||||
email?: string;
|
||||
whatsapp?: string;
|
||||
}) => api.post('/auth/register', data),
|
||||
|
||||
login: (data: { phone: string; pin: string }) =>
|
||||
api.post('/auth/login', data),
|
||||
|
||||
refresh: (refreshToken: string) =>
|
||||
api.post('/auth/refresh', { refreshToken }),
|
||||
|
||||
changePin: (data: { currentPin: string; newPin: string }) =>
|
||||
api.post('/auth/change-pin', data),
|
||||
};
|
||||
|
||||
// Products API
|
||||
export const productsApi = {
|
||||
getAll: (params?: { category?: string; search?: string }) =>
|
||||
api.get('/products', { params }),
|
||||
getById: (id: string) => api.get(`/products/${id}`),
|
||||
create: (data: any) => api.post('/products', data),
|
||||
update: (id: string, data: any) => api.patch(`/products/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/products/${id}`),
|
||||
};
|
||||
|
||||
// Orders API
|
||||
export const ordersApi = {
|
||||
getAll: (params?: { status?: string; date?: string }) =>
|
||||
api.get('/orders', { params }),
|
||||
getById: (id: string) => api.get(`/orders/${id}`),
|
||||
create: (data: any) => api.post('/orders', data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.patch(`/orders/${id}/status`, { status }),
|
||||
};
|
||||
|
||||
// Customers API
|
||||
export const customersApi = {
|
||||
getAll: (params?: { search?: string }) =>
|
||||
api.get('/customers', { params }),
|
||||
getById: (id: string) => api.get(`/customers/${id}`),
|
||||
create: (data: any) => api.post('/customers', data),
|
||||
update: (id: string, data: any) => api.patch(`/customers/${id}`, data),
|
||||
getFiado: (id: string) => api.get(`/customers/${id}/fiado`),
|
||||
};
|
||||
|
||||
// Inventory API
|
||||
export const inventoryApi = {
|
||||
getMovements: (params?: { productId?: string; type?: string }) =>
|
||||
api.get('/inventory/movements', { params }),
|
||||
createMovement: (data: any) => api.post('/inventory/movements', data),
|
||||
getLowStock: () => api.get('/inventory/low-stock'),
|
||||
getAlerts: () => api.get('/inventory/alerts'),
|
||||
};
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardApi = {
|
||||
getStats: () => api.get('/dashboard/stats'),
|
||||
getSalesChart: (period: string) => api.get('/dashboard/sales', { params: { period } }),
|
||||
getTopProducts: () => api.get('/dashboard/top-products'),
|
||||
};
|
||||
|
||||
// Referrals API
|
||||
export const referralsApi = {
|
||||
getMyCode: () => api.get('/referrals/my-code'),
|
||||
generateCode: () => api.post('/referrals/generate-code'),
|
||||
validateCode: (code: string) => api.get(`/referrals/validate/${code}`),
|
||||
applyCode: (code: string) => api.post('/referrals/apply-code', { code }),
|
||||
getMyReferrals: () => api.get('/referrals/list'),
|
||||
getStats: () => api.get('/referrals/stats'),
|
||||
getRewards: () => api.get('/referrals/rewards'),
|
||||
getAvailableMonths: () => api.get('/referrals/rewards/available-months'),
|
||||
getDiscount: () => api.get('/referrals/discount'),
|
||||
};
|
||||
|
||||
// CoDi/SPEI API
|
||||
export const codiSpeiApi = {
|
||||
// CoDi
|
||||
generateQr: (data: { amount: number; description?: string; saleId?: string }) =>
|
||||
api.post('/codi/generate-qr', data),
|
||||
getCodiStatus: (id: string) => api.get(`/codi/status/${id}`),
|
||||
getCodiTransactions: (limit?: number) =>
|
||||
api.get('/codi/transactions', { params: { limit } }),
|
||||
|
||||
// SPEI
|
||||
getClabe: () => api.get('/spei/clabe'),
|
||||
createClabe: (beneficiaryName: string) =>
|
||||
api.post('/spei/create-clabe', { beneficiaryName }),
|
||||
getSpeiTransactions: (limit?: number) =>
|
||||
api.get('/spei/transactions', { params: { limit } }),
|
||||
|
||||
// Summary
|
||||
getSummary: (date?: string) =>
|
||||
api.get('/payments/summary', { params: { date } }),
|
||||
};
|
||||
|
||||
// Invoices API (SAT/CFDI)
|
||||
export const invoicesApi = {
|
||||
// Tax Config
|
||||
getTaxConfig: () => api.get('/invoices/tax-config'),
|
||||
saveTaxConfig: (data: any) => api.post('/invoices/tax-config', data),
|
||||
|
||||
// Invoices
|
||||
getAll: (params?: { status?: string; from?: string; to?: string; limit?: number }) =>
|
||||
api.get('/invoices', { params }),
|
||||
getById: (id: string) => api.get(`/invoices/${id}`),
|
||||
create: (data: any) => api.post('/invoices', data),
|
||||
stamp: (id: string) => api.post(`/invoices/${id}/stamp`),
|
||||
cancel: (id: string, reason: string, uuidReplacement?: string) =>
|
||||
api.post(`/invoices/${id}/cancel`, { reason, uuidReplacement }),
|
||||
send: (id: string, email?: string) =>
|
||||
api.post(`/invoices/${id}/send`, { email }),
|
||||
getSummary: (month?: string) =>
|
||||
api.get('/invoices/summary', { params: { month } }),
|
||||
};
|
||||
|
||||
// Marketplace API
|
||||
export const marketplaceApi = {
|
||||
// Suppliers
|
||||
getSuppliers: (params?: { category?: string; zipCode?: string; search?: string; limit?: number }) =>
|
||||
api.get('/marketplace/suppliers', { params }),
|
||||
getSupplier: (id: string) => api.get(`/marketplace/suppliers/${id}`),
|
||||
getSupplierProducts: (id: string, params?: { category?: string; search?: string }) =>
|
||||
api.get(`/marketplace/suppliers/${id}/products`, { params }),
|
||||
getSupplierReviews: (id: string, params?: { limit?: number }) =>
|
||||
api.get(`/marketplace/suppliers/${id}/reviews`, { params }),
|
||||
|
||||
// Orders
|
||||
createOrder: (data: any) => api.post('/marketplace/orders', data),
|
||||
getOrders: (params?: { status?: string; supplierId?: string; limit?: number }) =>
|
||||
api.get('/marketplace/orders', { params }),
|
||||
getOrder: (id: string) => api.get(`/marketplace/orders/${id}`),
|
||||
cancelOrder: (id: string, reason: string) =>
|
||||
api.put(`/marketplace/orders/${id}/cancel`, { reason }),
|
||||
|
||||
// Reviews
|
||||
createReview: (data: any) => api.post('/marketplace/reviews', data),
|
||||
|
||||
// Favorites
|
||||
getFavorites: () => api.get('/marketplace/favorites'),
|
||||
addFavorite: (supplierId: string) => api.post(`/marketplace/favorites/${supplierId}`),
|
||||
removeFavorite: (supplierId: string) => api.delete(`/marketplace/favorites/${supplierId}`),
|
||||
|
||||
// Stats
|
||||
getStats: () => api.get('/marketplace/stats'),
|
||||
};
|
||||
72
src/lib/i18n.ts
Normal file
72
src/lib/i18n.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import translations
|
||||
import esMX from '../locales/es-MX';
|
||||
import esCO from '../locales/es-CO';
|
||||
import esAR from '../locales/es-AR';
|
||||
import ptBR from '../locales/pt-BR';
|
||||
|
||||
const resources = {
|
||||
'es-MX': { translation: esMX },
|
||||
'es-CO': { translation: esCO },
|
||||
'es-AR': { translation: esAR },
|
||||
'pt-BR': { translation: ptBR },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'es-MX',
|
||||
defaultNS: 'translation',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
// Currency formatting by locale
|
||||
export const currencyConfig: Record<string, { currency: string; symbol: string }> = {
|
||||
'es-MX': { currency: 'MXN', symbol: '$' },
|
||||
'es-CO': { currency: 'COP', symbol: '$' },
|
||||
'es-AR': { currency: 'ARS', symbol: '$' },
|
||||
'pt-BR': { currency: 'BRL', symbol: 'R$' },
|
||||
};
|
||||
|
||||
export function formatCurrency(amount: number, locale: string = 'es-MX'): string {
|
||||
const config = currencyConfig[locale] || currencyConfig['es-MX'];
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: config.currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string, locale: string = 'es-MX'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string, locale: string = 'es-MX'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
63
src/locales/es-AR/index.ts
Normal file
63
src/locales/es-AR/index.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import esMX from '../es-MX';
|
||||
|
||||
// Argentina Spanish - Override specific terms
|
||||
const esAR = {
|
||||
...esMX,
|
||||
|
||||
// Navigation overrides
|
||||
nav: {
|
||||
...esMX.nav,
|
||||
// Same in Argentina
|
||||
},
|
||||
|
||||
// Auth overrides
|
||||
auth: {
|
||||
...esMX.auth,
|
||||
phone: 'Celular',
|
||||
enterPhone: 'Ingresa tu celular',
|
||||
},
|
||||
|
||||
// Dashboard overrides
|
||||
dashboard: {
|
||||
...esMX.dashboard,
|
||||
// Same in Argentina
|
||||
},
|
||||
|
||||
// Products overrides - Argentine terminology
|
||||
products: {
|
||||
...esMX.products,
|
||||
subtitle: 'Administra tu stock',
|
||||
stock: 'Stock',
|
||||
},
|
||||
|
||||
// Fiado overrides - Argentine terminology
|
||||
fiado: {
|
||||
...esMX.fiado,
|
||||
title: 'Cuenta',
|
||||
subtitle: 'Control de cuentas corrientes',
|
||||
registerPayment: 'Registrar pago',
|
||||
},
|
||||
|
||||
// Payments overrides
|
||||
payments: {
|
||||
...esMX.payments,
|
||||
fiado: 'Cuenta corriente',
|
||||
transfer: 'Transferencia',
|
||||
change: 'Vuelto',
|
||||
},
|
||||
|
||||
// Business Types - Argentine terminology
|
||||
businessTypes: {
|
||||
tienda: 'Almacen',
|
||||
papeleria: 'Libreria',
|
||||
farmacia: 'Farmacia',
|
||||
ferreteria: 'Ferreteria',
|
||||
carniceria: 'Carniceria',
|
||||
verduleria: 'Verduleria',
|
||||
panaderia: 'Panaderia',
|
||||
tortilleria: 'Rotiseria',
|
||||
otro: 'Otro',
|
||||
},
|
||||
};
|
||||
|
||||
export default esAR;
|
||||
60
src/locales/es-CO/index.ts
Normal file
60
src/locales/es-CO/index.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import esMX from '../es-MX';
|
||||
|
||||
// Colombia Spanish - Override specific terms
|
||||
const esCO = {
|
||||
...esMX,
|
||||
|
||||
// Navigation overrides
|
||||
nav: {
|
||||
...esMX.nav,
|
||||
// Same in Colombia
|
||||
},
|
||||
|
||||
// Auth overrides
|
||||
auth: {
|
||||
...esMX.auth,
|
||||
phone: 'Celular',
|
||||
enterPhone: 'Ingresa tu celular',
|
||||
},
|
||||
|
||||
// Dashboard overrides
|
||||
dashboard: {
|
||||
...esMX.dashboard,
|
||||
// Same in Colombia
|
||||
},
|
||||
|
||||
// Products overrides - Colombian terminology
|
||||
products: {
|
||||
...esMX.products,
|
||||
subtitle: 'Administra tu inventario',
|
||||
},
|
||||
|
||||
// Fiado overrides
|
||||
fiado: {
|
||||
...esMX.fiado,
|
||||
title: 'Credito',
|
||||
subtitle: 'Control de creditos a clientes',
|
||||
},
|
||||
|
||||
// Payments overrides
|
||||
payments: {
|
||||
...esMX.payments,
|
||||
fiado: 'Credito',
|
||||
transfer: 'Transferencia bancaria',
|
||||
},
|
||||
|
||||
// Business Types - Colombian terminology
|
||||
businessTypes: {
|
||||
tienda: 'Tienda',
|
||||
papeleria: 'Papeleria',
|
||||
farmacia: 'Drogueria',
|
||||
ferreteria: 'Ferreteria',
|
||||
carniceria: 'Carniceria',
|
||||
verduleria: 'Fruteria',
|
||||
panaderia: 'Panaderia',
|
||||
tortilleria: 'Areperia',
|
||||
otro: 'Otro',
|
||||
},
|
||||
};
|
||||
|
||||
export default esCO;
|
||||
231
src/locales/es-MX/index.ts
Normal file
231
src/locales/es-MX/index.ts
Normal file
@ -0,0 +1,231 @@
|
||||
export default {
|
||||
// Common
|
||||
common: {
|
||||
save: 'Guardar',
|
||||
cancel: 'Cancelar',
|
||||
delete: 'Eliminar',
|
||||
edit: 'Editar',
|
||||
add: 'Agregar',
|
||||
search: 'Buscar',
|
||||
loading: 'Cargando...',
|
||||
error: 'Error',
|
||||
success: 'Exito',
|
||||
confirm: 'Confirmar',
|
||||
back: 'Volver',
|
||||
next: 'Siguiente',
|
||||
close: 'Cerrar',
|
||||
yes: 'Si',
|
||||
no: 'No',
|
||||
all: 'Todos',
|
||||
none: 'Ninguno',
|
||||
required: 'Requerido',
|
||||
optional: 'Opcional',
|
||||
},
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
products: 'Productos',
|
||||
orders: 'Pedidos',
|
||||
customers: 'Clientes',
|
||||
fiado: 'Fiado',
|
||||
inventory: 'Inventario',
|
||||
referrals: 'Referidos',
|
||||
settings: 'Ajustes',
|
||||
logout: 'Cerrar sesion',
|
||||
},
|
||||
|
||||
// Auth
|
||||
auth: {
|
||||
login: 'Iniciar sesion',
|
||||
register: 'Registrarse',
|
||||
phone: 'Telefono',
|
||||
pin: 'PIN',
|
||||
enterPhone: 'Ingresa tu telefono',
|
||||
enterPin: 'Ingresa tu PIN',
|
||||
forgotPin: 'Olvide mi PIN',
|
||||
noAccount: 'No tienes cuenta?',
|
||||
hasAccount: 'Ya tienes cuenta?',
|
||||
createAccount: 'Crear cuenta',
|
||||
businessName: 'Nombre del negocio',
|
||||
ownerName: 'Tu nombre',
|
||||
businessType: 'Tipo de negocio',
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
dashboard: {
|
||||
title: 'Dashboard',
|
||||
todaySales: 'Ventas de hoy',
|
||||
weekSales: 'Ventas de la semana',
|
||||
monthSales: 'Ventas del mes',
|
||||
transactions: 'transacciones',
|
||||
lowStock: 'Stock bajo',
|
||||
pendingOrders: 'Pedidos pendientes',
|
||||
pendingCredits: 'Fiados pendientes',
|
||||
topProducts: 'Productos mas vendidos',
|
||||
},
|
||||
|
||||
// Products
|
||||
products: {
|
||||
title: 'Productos',
|
||||
subtitle: 'Administra tu catalogo',
|
||||
addProduct: 'Agregar producto',
|
||||
editProduct: 'Editar producto',
|
||||
name: 'Nombre',
|
||||
price: 'Precio',
|
||||
cost: 'Costo',
|
||||
stock: 'Stock',
|
||||
sku: 'SKU',
|
||||
barcode: 'Codigo de barras',
|
||||
category: 'Categoria',
|
||||
description: 'Descripcion',
|
||||
noProducts: 'No hay productos',
|
||||
scanBarcode: 'Escanear codigo',
|
||||
},
|
||||
|
||||
// Orders
|
||||
orders: {
|
||||
title: 'Pedidos',
|
||||
subtitle: 'Gestiona los pedidos',
|
||||
newOrder: 'Nuevo pedido',
|
||||
orderNumber: 'Pedido #{{number}}',
|
||||
status: 'Estado',
|
||||
pending: 'Pendiente',
|
||||
preparing: 'Preparando',
|
||||
ready: 'Listo',
|
||||
delivered: 'Entregado',
|
||||
cancelled: 'Cancelado',
|
||||
total: 'Total',
|
||||
items: 'articulos',
|
||||
noOrders: 'No hay pedidos',
|
||||
},
|
||||
|
||||
// Customers
|
||||
customers: {
|
||||
title: 'Clientes',
|
||||
subtitle: 'Administra tus clientes',
|
||||
addCustomer: 'Agregar cliente',
|
||||
name: 'Nombre',
|
||||
phone: 'Telefono',
|
||||
email: 'Correo',
|
||||
creditLimit: 'Limite de credito',
|
||||
currentBalance: 'Saldo actual',
|
||||
noCustomers: 'No hay clientes',
|
||||
},
|
||||
|
||||
// Fiado (Credit)
|
||||
fiado: {
|
||||
title: 'Fiado',
|
||||
subtitle: 'Control de creditos',
|
||||
totalOwed: 'Total adeudado',
|
||||
overdueAmount: 'Vencido',
|
||||
customersWithCredit: 'Clientes con fiado',
|
||||
registerPayment: 'Registrar abono',
|
||||
paymentHistory: 'Historial de pagos',
|
||||
dueDate: 'Fecha de vencimiento',
|
||||
overdue: 'Vencido',
|
||||
noCredits: 'No hay fiados pendientes',
|
||||
},
|
||||
|
||||
// Inventory
|
||||
inventory: {
|
||||
title: 'Inventario',
|
||||
subtitle: 'Control de existencias',
|
||||
movements: 'Movimientos',
|
||||
addMovement: 'Agregar movimiento',
|
||||
entry: 'Entrada',
|
||||
exit: 'Salida',
|
||||
adjustment: 'Ajuste',
|
||||
lowStockAlerts: 'Alertas de stock bajo',
|
||||
reorder: 'Reabastecer',
|
||||
noMovements: 'No hay movimientos',
|
||||
},
|
||||
|
||||
// Referrals
|
||||
referrals: {
|
||||
title: 'Programa de Referidos',
|
||||
subtitle: 'Invita amigos y gana',
|
||||
yourCode: 'Tu codigo de referido',
|
||||
copy: 'Copiar',
|
||||
share: 'Compartir',
|
||||
shareWhatsApp: 'Compartir por WhatsApp',
|
||||
invited: 'Invitados',
|
||||
converted: 'Convertidos',
|
||||
monthsEarned: 'Meses ganados',
|
||||
monthsAvailable: 'Disponibles',
|
||||
howItWorks: 'Como funciona',
|
||||
step1: 'Comparte tu codigo',
|
||||
step1Desc: 'Envia tu codigo a amigos por WhatsApp',
|
||||
step2: 'Tu amigo se registra',
|
||||
step2Desc: 'Obtiene 50% de descuento en su primer mes',
|
||||
step3: 'Tu ganas 1 mes gratis',
|
||||
step3Desc: 'Cuando tu amigo paga su primer mes',
|
||||
yourReferrals: 'Tus referidos',
|
||||
noReferrals: 'Aun no tienes referidos',
|
||||
},
|
||||
|
||||
// Settings
|
||||
settings: {
|
||||
title: 'Ajustes',
|
||||
subtitle: 'Configura tu tienda',
|
||||
businessInfo: 'Informacion del negocio',
|
||||
fiadoSettings: 'Configuracion de fiado',
|
||||
whatsapp: 'WhatsApp Business',
|
||||
notifications: 'Notificaciones',
|
||||
subscription: 'Suscripcion',
|
||||
language: 'Idioma',
|
||||
enableFiado: 'Habilitar fiado',
|
||||
defaultCreditLimit: 'Limite de credito por defecto',
|
||||
gracePeriod: 'Dias de gracia',
|
||||
connected: 'Conectado',
|
||||
autoResponses: 'Respuestas automaticas',
|
||||
lowStockAlerts: 'Alertas de stock bajo',
|
||||
overdueAlerts: 'Alertas de fiados vencidos',
|
||||
newOrderAlerts: 'Alertas de nuevos pedidos',
|
||||
currentPlan: 'Plan actual',
|
||||
upgradePlan: 'Mejorar plan',
|
||||
},
|
||||
|
||||
// Payments
|
||||
payments: {
|
||||
cash: 'Efectivo',
|
||||
card: 'Tarjeta',
|
||||
transfer: 'Transferencia',
|
||||
fiado: 'Fiado',
|
||||
codi: 'CoDi',
|
||||
spei: 'SPEI',
|
||||
change: 'Cambio',
|
||||
total: 'Total',
|
||||
subtotal: 'Subtotal',
|
||||
tax: 'IVA',
|
||||
discount: 'Descuento',
|
||||
payNow: 'Pagar ahora',
|
||||
generateQR: 'Generar QR',
|
||||
scanQR: 'Escanea el QR con tu app bancaria',
|
||||
paymentReceived: 'Pago recibido',
|
||||
paymentFailed: 'Pago fallido',
|
||||
},
|
||||
|
||||
// Errors
|
||||
errors: {
|
||||
generic: 'Algo salio mal',
|
||||
networkError: 'Error de conexion',
|
||||
unauthorized: 'No autorizado',
|
||||
notFound: 'No encontrado',
|
||||
validationError: 'Error de validacion',
|
||||
serverError: 'Error del servidor',
|
||||
},
|
||||
|
||||
// Business Types
|
||||
businessTypes: {
|
||||
tienda: 'Tienda de abarrotes',
|
||||
papeleria: 'Papeleria',
|
||||
farmacia: 'Farmacia',
|
||||
ferreteria: 'Ferreteria',
|
||||
carniceria: 'Carniceria',
|
||||
verduleria: 'Verduleria',
|
||||
panaderia: 'Panaderia',
|
||||
tortilleria: 'Tortilleria',
|
||||
otro: 'Otro',
|
||||
},
|
||||
};
|
||||
234
src/locales/pt-BR/index.ts
Normal file
234
src/locales/pt-BR/index.ts
Normal file
@ -0,0 +1,234 @@
|
||||
// Brazilian Portuguese
|
||||
const ptBR = {
|
||||
// Common
|
||||
common: {
|
||||
save: 'Salvar',
|
||||
cancel: 'Cancelar',
|
||||
delete: 'Excluir',
|
||||
edit: 'Editar',
|
||||
add: 'Adicionar',
|
||||
search: 'Buscar',
|
||||
loading: 'Carregando...',
|
||||
error: 'Erro',
|
||||
success: 'Sucesso',
|
||||
confirm: 'Confirmar',
|
||||
back: 'Voltar',
|
||||
next: 'Proximo',
|
||||
close: 'Fechar',
|
||||
yes: 'Sim',
|
||||
no: 'Nao',
|
||||
all: 'Todos',
|
||||
none: 'Nenhum',
|
||||
required: 'Obrigatorio',
|
||||
optional: 'Opcional',
|
||||
},
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: 'Painel',
|
||||
products: 'Produtos',
|
||||
orders: 'Pedidos',
|
||||
customers: 'Clientes',
|
||||
fiado: 'Fiado',
|
||||
inventory: 'Estoque',
|
||||
referrals: 'Indicacoes',
|
||||
settings: 'Configuracoes',
|
||||
logout: 'Sair',
|
||||
},
|
||||
|
||||
// Auth
|
||||
auth: {
|
||||
login: 'Entrar',
|
||||
register: 'Cadastrar',
|
||||
phone: 'Celular',
|
||||
pin: 'PIN',
|
||||
enterPhone: 'Digite seu celular',
|
||||
enterPin: 'Digite seu PIN',
|
||||
forgotPin: 'Esqueci meu PIN',
|
||||
noAccount: 'Nao tem conta?',
|
||||
hasAccount: 'Ja tem conta?',
|
||||
createAccount: 'Criar conta',
|
||||
businessName: 'Nome do negocio',
|
||||
ownerName: 'Seu nome',
|
||||
businessType: 'Tipo de negocio',
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
dashboard: {
|
||||
title: 'Painel',
|
||||
todaySales: 'Vendas de hoje',
|
||||
weekSales: 'Vendas da semana',
|
||||
monthSales: 'Vendas do mes',
|
||||
transactions: 'transacoes',
|
||||
lowStock: 'Estoque baixo',
|
||||
pendingOrders: 'Pedidos pendentes',
|
||||
pendingCredits: 'Fiados pendentes',
|
||||
topProducts: 'Produtos mais vendidos',
|
||||
},
|
||||
|
||||
// Products
|
||||
products: {
|
||||
title: 'Produtos',
|
||||
subtitle: 'Gerencie seu catalogo',
|
||||
addProduct: 'Adicionar produto',
|
||||
editProduct: 'Editar produto',
|
||||
name: 'Nome',
|
||||
price: 'Preco',
|
||||
cost: 'Custo',
|
||||
stock: 'Estoque',
|
||||
sku: 'SKU',
|
||||
barcode: 'Codigo de barras',
|
||||
category: 'Categoria',
|
||||
description: 'Descricao',
|
||||
noProducts: 'Nenhum produto',
|
||||
scanBarcode: 'Escanear codigo',
|
||||
},
|
||||
|
||||
// Orders
|
||||
orders: {
|
||||
title: 'Pedidos',
|
||||
subtitle: 'Gerencie os pedidos',
|
||||
newOrder: 'Novo pedido',
|
||||
orderNumber: 'Pedido #{{number}}',
|
||||
status: 'Status',
|
||||
pending: 'Pendente',
|
||||
preparing: 'Preparando',
|
||||
ready: 'Pronto',
|
||||
delivered: 'Entregue',
|
||||
cancelled: 'Cancelado',
|
||||
total: 'Total',
|
||||
items: 'itens',
|
||||
noOrders: 'Nenhum pedido',
|
||||
},
|
||||
|
||||
// Customers
|
||||
customers: {
|
||||
title: 'Clientes',
|
||||
subtitle: 'Gerencie seus clientes',
|
||||
addCustomer: 'Adicionar cliente',
|
||||
name: 'Nome',
|
||||
phone: 'Celular',
|
||||
email: 'Email',
|
||||
creditLimit: 'Limite de credito',
|
||||
currentBalance: 'Saldo atual',
|
||||
noCustomers: 'Nenhum cliente',
|
||||
},
|
||||
|
||||
// Fiado (Credit)
|
||||
fiado: {
|
||||
title: 'Fiado',
|
||||
subtitle: 'Controle de creditos',
|
||||
totalOwed: 'Total devido',
|
||||
overdueAmount: 'Vencido',
|
||||
customersWithCredit: 'Clientes com fiado',
|
||||
registerPayment: 'Registrar pagamento',
|
||||
paymentHistory: 'Historico de pagamentos',
|
||||
dueDate: 'Data de vencimento',
|
||||
overdue: 'Vencido',
|
||||
noCredits: 'Nenhum fiado pendente',
|
||||
},
|
||||
|
||||
// Inventory
|
||||
inventory: {
|
||||
title: 'Estoque',
|
||||
subtitle: 'Controle de estoque',
|
||||
movements: 'Movimentacoes',
|
||||
addMovement: 'Adicionar movimentacao',
|
||||
entry: 'Entrada',
|
||||
exit: 'Saida',
|
||||
adjustment: 'Ajuste',
|
||||
lowStockAlerts: 'Alertas de estoque baixo',
|
||||
reorder: 'Repor',
|
||||
noMovements: 'Nenhuma movimentacao',
|
||||
},
|
||||
|
||||
// Referrals
|
||||
referrals: {
|
||||
title: 'Programa de Indicacoes',
|
||||
subtitle: 'Indique amigos e ganhe',
|
||||
yourCode: 'Seu codigo de indicacao',
|
||||
copy: 'Copiar',
|
||||
share: 'Compartilhar',
|
||||
shareWhatsApp: 'Compartilhar por WhatsApp',
|
||||
invited: 'Indicados',
|
||||
converted: 'Convertidos',
|
||||
monthsEarned: 'Meses ganhos',
|
||||
monthsAvailable: 'Disponiveis',
|
||||
howItWorks: 'Como funciona',
|
||||
step1: 'Compartilhe seu codigo',
|
||||
step1Desc: 'Envie seu codigo para amigos por WhatsApp',
|
||||
step2: 'Seu amigo se cadastra',
|
||||
step2Desc: 'Ganha 50% de desconto no primeiro mes',
|
||||
step3: 'Voce ganha 1 mes gratis',
|
||||
step3Desc: 'Quando seu amigo paga o primeiro mes',
|
||||
yourReferrals: 'Suas indicacoes',
|
||||
noReferrals: 'Voce ainda nao tem indicacoes',
|
||||
},
|
||||
|
||||
// Settings
|
||||
settings: {
|
||||
title: 'Configuracoes',
|
||||
subtitle: 'Configure sua loja',
|
||||
businessInfo: 'Informacoes do negocio',
|
||||
fiadoSettings: 'Configuracao de fiado',
|
||||
whatsapp: 'WhatsApp Business',
|
||||
notifications: 'Notificacoes',
|
||||
subscription: 'Assinatura',
|
||||
language: 'Idioma',
|
||||
enableFiado: 'Habilitar fiado',
|
||||
defaultCreditLimit: 'Limite de credito padrao',
|
||||
gracePeriod: 'Dias de carencia',
|
||||
connected: 'Conectado',
|
||||
autoResponses: 'Respostas automaticas',
|
||||
lowStockAlerts: 'Alertas de estoque baixo',
|
||||
overdueAlerts: 'Alertas de fiados vencidos',
|
||||
newOrderAlerts: 'Alertas de novos pedidos',
|
||||
currentPlan: 'Plano atual',
|
||||
upgradePlan: 'Melhorar plano',
|
||||
},
|
||||
|
||||
// Payments
|
||||
payments: {
|
||||
cash: 'Dinheiro',
|
||||
card: 'Cartao',
|
||||
transfer: 'Transferencia',
|
||||
fiado: 'Fiado',
|
||||
codi: 'Pix',
|
||||
spei: 'TED',
|
||||
change: 'Troco',
|
||||
total: 'Total',
|
||||
subtotal: 'Subtotal',
|
||||
tax: 'Impostos',
|
||||
discount: 'Desconto',
|
||||
payNow: 'Pagar agora',
|
||||
generateQR: 'Gerar QR',
|
||||
scanQR: 'Escaneie o QR com seu app do banco',
|
||||
paymentReceived: 'Pagamento recebido',
|
||||
paymentFailed: 'Pagamento falhou',
|
||||
},
|
||||
|
||||
// Errors
|
||||
errors: {
|
||||
generic: 'Algo deu errado',
|
||||
networkError: 'Erro de conexao',
|
||||
unauthorized: 'Nao autorizado',
|
||||
notFound: 'Nao encontrado',
|
||||
validationError: 'Erro de validacao',
|
||||
serverError: 'Erro do servidor',
|
||||
},
|
||||
|
||||
// Business Types
|
||||
businessTypes: {
|
||||
tienda: 'Loja',
|
||||
papeleria: 'Papelaria',
|
||||
farmacia: 'Farmacia',
|
||||
ferreteria: 'Ferragem',
|
||||
carniceria: 'Acougue',
|
||||
verduleria: 'Hortifruti',
|
||||
panaderia: 'Padaria',
|
||||
tortilleria: 'Tapiocaria',
|
||||
otro: 'Outro',
|
||||
},
|
||||
};
|
||||
|
||||
export default ptBR;
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
134
src/pages/Customers.tsx
Normal file
134
src/pages/Customers.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, Plus, Phone, Mail, MapPin, CreditCard } from 'lucide-react';
|
||||
|
||||
const mockCustomers = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Maria Lopez',
|
||||
phone: '5551234567',
|
||||
email: 'maria@email.com',
|
||||
address: 'Calle 1 #123',
|
||||
totalPurchases: 45,
|
||||
totalSpent: 3450.00,
|
||||
fiadoBalance: 150.00,
|
||||
fiadoLimit: 500.00,
|
||||
lastVisit: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Juan Perez',
|
||||
phone: '5559876543',
|
||||
email: null,
|
||||
address: null,
|
||||
totalPurchases: 23,
|
||||
totalSpent: 1890.00,
|
||||
fiadoBalance: 0,
|
||||
fiadoLimit: 300.00,
|
||||
lastVisit: '2024-01-14',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Ana Garcia',
|
||||
phone: '5555555555',
|
||||
email: 'ana@email.com',
|
||||
address: 'Av. Principal #456',
|
||||
totalPurchases: 67,
|
||||
totalSpent: 5670.00,
|
||||
fiadoBalance: 320.00,
|
||||
fiadoLimit: 1000.00,
|
||||
lastVisit: '2024-01-15',
|
||||
},
|
||||
];
|
||||
|
||||
export function Customers() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredCustomers = mockCustomers.filter((customer) =>
|
||||
customer.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
customer.phone.includes(search)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
|
||||
<p className="text-gray-500">{mockCustomers.length} clientes registrados</p>
|
||||
</div>
|
||||
<button className="btn-primary flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Nuevo Cliente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre o telefono..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Customers List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredCustomers.map((customer) => (
|
||||
<div key={customer.id} className="card hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{customer.name}</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Phone className="h-4 w-4" />
|
||||
{customer.phone}
|
||||
</div>
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Mail className="h-4 w-4" />
|
||||
{customer.email}
|
||||
</div>
|
||||
)}
|
||||
{customer.address && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{customer.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">Ultima visita</p>
|
||||
<p className="text-sm font-medium">{customer.lastVisit}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Compras</p>
|
||||
<p className="text-lg font-bold">{customer.totalPurchases}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total gastado</p>
|
||||
<p className="text-lg font-bold">${customer.totalSpent.toFixed(0)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Fiado</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<CreditCard className={`h-4 w-4 ${
|
||||
customer.fiadoBalance > 0 ? 'text-orange-500' : 'text-green-500'
|
||||
}`} />
|
||||
<p className={`text-lg font-bold ${
|
||||
customer.fiadoBalance > 0 ? 'text-orange-600' : 'text-green-600'
|
||||
}`}>
|
||||
${customer.fiadoBalance.toFixed(0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/pages/Dashboard.tsx
Normal file
131
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import {
|
||||
TrendingUp,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
CreditCard,
|
||||
Package,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const stats = [
|
||||
{ name: 'Ventas Hoy', value: '$1,240', change: '+12%', icon: TrendingUp, color: 'green' },
|
||||
{ name: 'Pedidos', value: '23', change: '+5', icon: ShoppingCart, color: 'blue' },
|
||||
{ name: 'Clientes', value: '156', change: '+3', icon: Users, color: 'purple' },
|
||||
{ name: 'Fiados Pendientes', value: '$2,100', change: '-$450', icon: CreditCard, color: 'orange' },
|
||||
];
|
||||
|
||||
const recentOrders = [
|
||||
{ id: 'MCH-001', customer: 'Maria Lopez', total: 156.00, status: 'ready', time: '10:30 AM' },
|
||||
{ id: 'MCH-002', customer: 'Juan Perez', total: 89.50, status: 'preparing', time: '10:45 AM' },
|
||||
{ id: 'MCH-003', customer: 'Ana Garcia', total: 234.00, status: 'pending', time: '11:00 AM' },
|
||||
];
|
||||
|
||||
const lowStockProducts = [
|
||||
{ name: 'Coca-Cola 600ml', stock: 5, minStock: 10 },
|
||||
{ name: 'Pan Bimbo', stock: 2, minStock: 5 },
|
||||
{ name: 'Leche Lala 1L', stock: 3, minStock: 8 },
|
||||
];
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
preparing: 'bg-blue-100 text-blue-800',
|
||||
ready: 'bg-green-100 text-green-800',
|
||||
completed: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500">Bienvenido a MiChangarrito</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.name} className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{stat.name}</p>
|
||||
<p className="text-2xl font-bold">{stat.value}</p>
|
||||
<p className={`text-sm ${
|
||||
stat.change.startsWith('+') ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stat.change}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-full bg-${stat.color}-100`}>
|
||||
<stat.icon className={`h-6 w-6 text-${stat.color}-600`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Recent Orders */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Pedidos Recientes
|
||||
</h2>
|
||||
<a href="/orders" className="text-sm text-primary-600 hover:underline">
|
||||
Ver todos
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{order.id}</p>
|
||||
<p className="text-sm text-gray-500">{order.customer}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">${order.total.toFixed(2)}</p>
|
||||
<span className={`inline-block px-2 py-1 text-xs rounded-full ${statusColors[order.status]}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Low Stock Alert */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-orange-500" />
|
||||
Stock Bajo
|
||||
</h2>
|
||||
<a href="/inventory" className="text-sm text-primary-600 hover:underline">
|
||||
Ver inventario
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{lowStockProducts.map((product) => (
|
||||
<div
|
||||
key={product.name}
|
||||
className="flex items-center justify-between p-3 bg-orange-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-orange-500" />
|
||||
<p className="font-medium">{product.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-orange-600">{product.stock} unidades</p>
|
||||
<p className="text-xs text-gray-500">Min: {product.minStock}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/pages/Fiado.tsx
Normal file
180
src/pages/Fiado.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import { useState } from 'react';
|
||||
import { CreditCard, AlertTriangle, CheckCircle, Clock, Plus } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const mockFiados = [
|
||||
{
|
||||
id: '1',
|
||||
customer: 'Maria Lopez',
|
||||
phone: '5551234567',
|
||||
amount: 150.00,
|
||||
description: 'Compra del 15/01',
|
||||
status: 'pending',
|
||||
dueDate: '2024-01-30',
|
||||
createdAt: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
customer: 'Ana Garcia',
|
||||
phone: '5555555555',
|
||||
amount: 320.00,
|
||||
description: 'Productos varios',
|
||||
status: 'overdue',
|
||||
dueDate: '2024-01-10',
|
||||
createdAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
customer: 'Pedro Martinez',
|
||||
phone: '5553334444',
|
||||
amount: 89.50,
|
||||
description: 'Bebidas y botanas',
|
||||
status: 'pending',
|
||||
dueDate: '2024-02-01',
|
||||
createdAt: '2024-01-14',
|
||||
},
|
||||
];
|
||||
|
||||
const recentPayments = [
|
||||
{ customer: 'Juan Perez', amount: 200.00, date: '2024-01-15' },
|
||||
{ customer: 'Laura Sanchez', amount: 150.00, date: '2024-01-14' },
|
||||
{ customer: 'Carlos Ruiz', amount: 75.00, date: '2024-01-13' },
|
||||
];
|
||||
|
||||
export function Fiado() {
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'overdue'>('all');
|
||||
|
||||
const totalPending = mockFiados.reduce((sum, f) => sum + f.amount, 0);
|
||||
const overdueCount = mockFiados.filter(f => f.status === 'overdue').length;
|
||||
|
||||
const filteredFiados = filter === 'all'
|
||||
? mockFiados
|
||||
: mockFiados.filter(f => f.status === filter);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Fiado</h1>
|
||||
<p className="text-gray-500">Gestiona las cuentas de credito de tus clientes</p>
|
||||
</div>
|
||||
<button className="btn-primary flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Nuevo Fiado
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="card bg-orange-50 border-orange-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-8 w-8 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm text-orange-700">Total Pendiente</p>
|
||||
<p className="text-2xl font-bold text-orange-800">${totalPending.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-red-50 border-red-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700">Vencidos</p>
|
||||
<p className="text-2xl font-bold text-red-800">{overdueCount} cuentas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-green-50 border-green-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm text-green-700">Cobrado este mes</p>
|
||||
<p className="text-2xl font-bold text-green-800">$425.00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Fiados List */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'pending', 'overdue'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||||
filter === status
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{status === 'all' && 'Todos'}
|
||||
{status === 'pending' && 'Pendientes'}
|
||||
{status === 'overdue' && 'Vencidos'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredFiados.map((fiado) => (
|
||||
<div key={fiado.id} className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{fiado.customer}</h3>
|
||||
<p className="text-sm text-gray-500">{fiado.phone}</p>
|
||||
<p className="text-gray-600 mt-1">{fiado.description}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
${fiado.amount.toFixed(2)}
|
||||
</p>
|
||||
<div className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mt-1',
|
||||
fiado.status === 'overdue'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
)}>
|
||||
{fiado.status === 'overdue' ? (
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
) : (
|
||||
<Clock className="h-3 w-3" />
|
||||
)}
|
||||
{fiado.status === 'overdue' ? 'Vencido' : 'Pendiente'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
<span>Creado: {fiado.createdAt}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>Vence: {fiado.dueDate}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-outline text-sm py-1">Enviar recordatorio</button>
|
||||
<button className="btn-primary text-sm py-1">Registrar pago</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Payments */}
|
||||
<div className="card h-fit">
|
||||
<h2 className="font-bold text-lg mb-4">Pagos Recientes</h2>
|
||||
<div className="space-y-3">
|
||||
{recentPayments.map((payment, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{payment.customer}</p>
|
||||
<p className="text-sm text-gray-500">{payment.date}</p>
|
||||
</div>
|
||||
<p className="font-bold text-green-600">+${payment.amount.toFixed(2)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
src/pages/Inventory.tsx
Normal file
175
src/pages/Inventory.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { Package, TrendingUp, TrendingDown, AlertTriangle, Plus, Minus } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const mockInventory = [
|
||||
{ id: '1', name: 'Coca-Cola 600ml', category: 'bebidas', stock: 24, minStock: 10, maxStock: 50, cost: 12.00, price: 18.00 },
|
||||
{ id: '2', name: 'Sabritas Original', category: 'botanas', stock: 12, minStock: 5, maxStock: 30, cost: 10.00, price: 15.00 },
|
||||
{ id: '3', name: 'Leche Lala 1L', category: 'lacteos', stock: 3, minStock: 8, maxStock: 20, cost: 22.00, price: 28.00 },
|
||||
{ id: '4', name: 'Pan Bimbo Grande', category: 'panaderia', stock: 2, minStock: 5, maxStock: 15, cost: 35.00, price: 45.00 },
|
||||
{ id: '5', name: 'Fabuloso 1L', category: 'limpieza', stock: 6, minStock: 5, maxStock: 20, cost: 25.00, price: 32.00 },
|
||||
];
|
||||
|
||||
const recentMovements = [
|
||||
{ product: 'Coca-Cola 600ml', type: 'sale', quantity: -2, date: '10:30 AM' },
|
||||
{ product: 'Sabritas', type: 'sale', quantity: -1, date: '10:15 AM' },
|
||||
{ product: 'Leche Lala 1L', type: 'purchase', quantity: +12, date: '09:00 AM' },
|
||||
{ product: 'Pan Bimbo', type: 'sale', quantity: -3, date: '08:45 AM' },
|
||||
];
|
||||
|
||||
export function Inventory() {
|
||||
const [showLowStock, setShowLowStock] = useState(false);
|
||||
|
||||
const lowStockItems = mockInventory.filter(item => item.stock <= item.minStock);
|
||||
const totalValue = mockInventory.reduce((sum, item) => sum + (item.stock * item.cost), 0);
|
||||
const totalItems = mockInventory.reduce((sum, item) => sum + item.stock, 0);
|
||||
|
||||
const displayItems = showLowStock
|
||||
? lowStockItems
|
||||
: mockInventory;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
|
||||
<p className="text-gray-500">Control de existencias</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-outline flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Entrada
|
||||
</button>
|
||||
<button className="btn-outline flex items-center gap-2">
|
||||
<Minus className="h-5 w-5" />
|
||||
Salida
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-8 w-8 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total Unidades</p>
|
||||
<p className="text-2xl font-bold">{totalItems}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="h-8 w-8 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Valor del Inventario</p>
|
||||
<p className="text-2xl font-bold">${totalValue.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'card cursor-pointer transition-colors',
|
||||
showLowStock ? 'bg-orange-50 border-orange-200' : ''
|
||||
)}
|
||||
onClick={() => setShowLowStock(!showLowStock)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-8 w-8 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Stock Bajo</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{lowStockItems.length} productos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Inventory Table */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card overflow-hidden p-0">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium text-gray-600">Producto</th>
|
||||
<th className="text-center p-4 font-medium text-gray-600">Stock</th>
|
||||
<th className="text-center p-4 font-medium text-gray-600">Min/Max</th>
|
||||
<th className="text-right p-4 font-medium text-gray-600">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{displayItems.map((item) => {
|
||||
const isLow = item.stock <= item.minStock;
|
||||
const percentage = (item.stock / item.maxStock) * 100;
|
||||
|
||||
return (
|
||||
<tr key={item.id} className={clsx(isLow && 'bg-orange-50')}>
|
||||
<td className="p-4">
|
||||
<p className="font-medium">{item.name}</p>
|
||||
<p className="text-sm text-gray-500 capitalize">{item.category}</p>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className={clsx(
|
||||
'text-lg font-bold',
|
||||
isLow ? 'text-orange-600' : 'text-gray-900'
|
||||
)}>
|
||||
{item.stock}
|
||||
</span>
|
||||
<div className="w-20 h-2 bg-gray-200 rounded-full mt-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full',
|
||||
percentage < 30 ? 'bg-red-500' :
|
||||
percentage < 50 ? 'bg-orange-500' : 'bg-green-500'
|
||||
)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-center text-sm text-gray-500">
|
||||
{item.minStock} / {item.maxStock}
|
||||
</td>
|
||||
<td className="p-4 text-right font-medium">
|
||||
${(item.stock * item.cost).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Movements */}
|
||||
<div className="card h-fit">
|
||||
<h2 className="font-bold text-lg mb-4">Movimientos Recientes</h2>
|
||||
<div className="space-y-3">
|
||||
{recentMovements.map((mov, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{mov.type === 'sale' ? (
|
||||
<TrendingDown className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<TrendingUp className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{mov.product}</p>
|
||||
<p className="text-xs text-gray-500">{mov.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'font-bold',
|
||||
mov.quantity > 0 ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{mov.quantity > 0 ? '+' : ''}{mov.quantity}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
668
src/pages/Invoices.tsx
Normal file
668
src/pages/Invoices.tsx
Normal file
@ -0,0 +1,668 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
FileText,
|
||||
Send,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Search,
|
||||
Plus,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { invoicesApi } from '../lib/api';
|
||||
|
||||
const statusConfig = {
|
||||
draft: { label: 'Borrador', color: 'gray', icon: Clock },
|
||||
pending: { label: 'Pendiente', color: 'yellow', icon: Clock },
|
||||
stamped: { label: 'Timbrada', color: 'green', icon: CheckCircle },
|
||||
sent: { label: 'Enviada', color: 'blue', icon: Send },
|
||||
cancelled: { label: 'Cancelada', color: 'red', icon: XCircle },
|
||||
};
|
||||
|
||||
export function Invoices() {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showNewInvoice, setShowNewInvoice] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery({
|
||||
queryKey: ['invoices', filter],
|
||||
queryFn: async () => {
|
||||
const params = filter !== 'all' ? { status: filter } : {};
|
||||
const response = await invoicesApi.getAll(params);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['invoices-summary'],
|
||||
queryFn: async () => {
|
||||
const response = await invoicesApi.getSummary();
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: taxConfig } = useQuery({
|
||||
queryKey: ['tax-config'],
|
||||
queryFn: async () => {
|
||||
const response = await invoicesApi.getTaxConfig();
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const stampMutation = useMutation({
|
||||
mutationFn: (id: string) => invoicesApi.stamp(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
},
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (id: string) => invoicesApi.send(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
},
|
||||
});
|
||||
|
||||
const hasActiveConfig = taxConfig?.status === 'active';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Facturacion</h1>
|
||||
<p className="text-gray-500">Emite facturas electronicas (CFDI)</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowConfig(true)}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuracion
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewInvoice(true)}
|
||||
disabled={!hasActiveConfig}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva Factura
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert if no config */}
|
||||
{!hasActiveConfig && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800">Configuracion fiscal requerida</p>
|
||||
<p className="text-sm text-yellow-700">
|
||||
Para emitir facturas, primero configura tus datos fiscales y certificados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<p className="text-sm text-gray-500">Facturas del mes</p>
|
||||
<p className="text-2xl font-bold">{summary?.total_invoices || 0}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-sm text-gray-500">Monto facturado</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
${(summary?.total_amount || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-sm text-gray-500">Canceladas</p>
|
||||
<p className="text-2xl font-bold text-red-600">{summary?.total_cancelled || 0}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-sm text-gray-500">RFC Emisor</p>
|
||||
<p className="text-lg font-mono">{taxConfig?.rfc || 'No configurado'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
|
||||
filter === 'all'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
{Object.entries(statusConfig).map(([status, config]) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
|
||||
filter === status
|
||||
? `bg-${config.color}-100 text-${config.color}-700`
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Invoices List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="text-gray-500 mt-2">Cargando facturas...</p>
|
||||
</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="text-center py-12 card">
|
||||
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900">No hay facturas</h3>
|
||||
<p className="text-gray-500">Las facturas emitidas apareceran aqui</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{invoices.map((invoice: any) => {
|
||||
const status = statusConfig[invoice.status as keyof typeof statusConfig] || statusConfig.draft;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div key={invoice.id} className="card">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full bg-${status.color}-100`}>
|
||||
<StatusIcon className={`h-6 w-6 text-${status.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-bold text-lg">
|
||||
{invoice.serie}-{invoice.folio}
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full bg-${status.color}-100 text-${status.color}-700`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600">{invoice.receptorNombre}</p>
|
||||
<p className="text-sm text-gray-500 font-mono">{invoice.receptorRfc}</p>
|
||||
{invoice.uuid && (
|
||||
<p className="text-xs text-gray-400 font-mono mt-1">
|
||||
UUID: {invoice.uuid}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">
|
||||
${Number(invoice.total).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(invoice.createdAt).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{invoice.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => stampMutation.mutate(invoice.id)}
|
||||
disabled={stampMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
Timbrar
|
||||
</button>
|
||||
)}
|
||||
{invoice.status === 'stamped' && (
|
||||
<button
|
||||
onClick={() => sendMutation.mutate(invoice.id)}
|
||||
disabled={sendMutation.isPending}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Enviar
|
||||
</button>
|
||||
)}
|
||||
{(invoice.status === 'stamped' || invoice.status === 'sent') && (
|
||||
<button className="btn-secondary flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
PDF
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tax Config Modal */}
|
||||
{showConfig && (
|
||||
<TaxConfigModal
|
||||
config={taxConfig}
|
||||
onClose={() => setShowConfig(false)}
|
||||
onSave={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tax-config'] });
|
||||
setShowConfig(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Invoice Modal */}
|
||||
{showNewInvoice && (
|
||||
<NewInvoiceModal
|
||||
onClose={() => setShowNewInvoice(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setShowNewInvoice(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaxConfigModal({
|
||||
config,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
config: any;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
rfc: config?.rfc || '',
|
||||
razonSocial: config?.razonSocial || '',
|
||||
regimenFiscal: config?.regimenFiscal || '601',
|
||||
codigoPostal: config?.codigoPostal || '',
|
||||
serie: config?.serie || 'A',
|
||||
pacProvider: config?.pacProvider || 'facturapi',
|
||||
pacSandbox: config?.pacSandbox ?? true,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: any) => invoicesApi.saveTaxConfig(data),
|
||||
onSuccess: onSave,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-xl font-bold">Configuracion Fiscal</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate(formData);
|
||||
}}
|
||||
className="p-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">RFC</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.rfc}
|
||||
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
|
||||
className="input"
|
||||
maxLength={13}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Razon Social</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.razonSocial}
|
||||
onChange={(e) => setFormData({ ...formData, razonSocial: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Regimen Fiscal</label>
|
||||
<select
|
||||
value={formData.regimenFiscal}
|
||||
onChange={(e) => setFormData({ ...formData, regimenFiscal: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="601">General de Ley PM</option>
|
||||
<option value="603">Personas Morales sin Fines de Lucro</option>
|
||||
<option value="605">Sueldos y Salarios</option>
|
||||
<option value="606">Arrendamiento</option>
|
||||
<option value="612">Personas Fisicas con Actividades Empresariales</option>
|
||||
<option value="621">Incorporacion Fiscal</option>
|
||||
<option value="625">Regimen de Actividades Agricolas</option>
|
||||
<option value="626">Regimen Simplificado de Confianza</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo Postal</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.codigoPostal}
|
||||
onChange={(e) => setFormData({ ...formData, codigoPostal: e.target.value })}
|
||||
className="input"
|
||||
maxLength={5}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Serie</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serie}
|
||||
onChange={(e) => setFormData({ ...formData, serie: e.target.value.toUpperCase() })}
|
||||
className="input"
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Proveedor PAC</label>
|
||||
<select
|
||||
value={formData.pacProvider}
|
||||
onChange={(e) => setFormData({ ...formData, pacProvider: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="facturapi">Facturapi</option>
|
||||
<option value="swsapien">SW Sapien</option>
|
||||
<option value="finkok">Finkok</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sandbox"
|
||||
checked={formData.pacSandbox}
|
||||
onChange={(e) => setFormData({ ...formData, pacSandbox: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="sandbox" className="text-sm text-gray-700">
|
||||
Modo sandbox (pruebas)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="button" onClick={onClose} className="btn-secondary flex-1">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" disabled={mutation.isPending} className="btn-primary flex-1">
|
||||
{mutation.isPending ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewInvoiceModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
receptorRfc: '',
|
||||
receptorNombre: '',
|
||||
receptorRegimenFiscal: '601',
|
||||
receptorCodigoPostal: '',
|
||||
receptorUsoCfdi: 'G03',
|
||||
receptorEmail: '',
|
||||
formaPago: '01',
|
||||
metodoPago: 'PUE',
|
||||
items: [{ descripcion: '', cantidad: 1, valorUnitario: 0, claveProdServ: '01010101', claveUnidad: 'H87' }],
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: any) => invoicesApi.create(data),
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const addItem = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
items: [
|
||||
...formData.items,
|
||||
{ descripcion: '', cantidad: 1, valorUnitario: 0, claveProdServ: '01010101', claveUnidad: 'H87' },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: string, value: any) => {
|
||||
const newItems = [...formData.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setFormData({ ...formData, items: newItems });
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
if (formData.items.length > 1) {
|
||||
setFormData({
|
||||
...formData,
|
||||
items: formData.items.filter((_, i) => i !== index),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const total = formData.items.reduce(
|
||||
(sum, item) => sum + item.cantidad * item.valorUnitario * 1.16,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-xl font-bold">Nueva Factura</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate(formData);
|
||||
}}
|
||||
className="p-6 space-y-6"
|
||||
>
|
||||
{/* Receptor */}
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 mb-3">Datos del Receptor</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">RFC</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.receptorRfc}
|
||||
onChange={(e) => setFormData({ ...formData, receptorRfc: e.target.value.toUpperCase() })}
|
||||
className="input"
|
||||
maxLength={13}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre/Razon Social</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.receptorNombre}
|
||||
onChange={(e) => setFormData({ ...formData, receptorNombre: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo Postal</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.receptorCodigoPostal}
|
||||
onChange={(e) => setFormData({ ...formData, receptorCodigoPostal: e.target.value })}
|
||||
className="input"
|
||||
maxLength={5}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Uso CFDI</label>
|
||||
<select
|
||||
value={formData.receptorUsoCfdi}
|
||||
onChange={(e) => setFormData({ ...formData, receptorUsoCfdi: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="G01">Adquisicion de mercancias</option>
|
||||
<option value="G03">Gastos en general</option>
|
||||
<option value="P01">Por definir</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email (opcional)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.receptorEmail}
|
||||
onChange={(e) => setFormData({ ...formData, receptorEmail: e.target.value })}
|
||||
className="input"
|
||||
placeholder="cliente@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pago */}
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 mb-3">Metodo de Pago</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Forma de Pago</label>
|
||||
<select
|
||||
value={formData.formaPago}
|
||||
onChange={(e) => setFormData({ ...formData, formaPago: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="01">Efectivo</option>
|
||||
<option value="03">Transferencia</option>
|
||||
<option value="04">Tarjeta de Credito</option>
|
||||
<option value="28">Tarjeta de Debito</option>
|
||||
<option value="99">Por definir</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Metodo de Pago</label>
|
||||
<select
|
||||
value={formData.metodoPago}
|
||||
onChange={(e) => setFormData({ ...formData, metodoPago: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="PUE">Pago en una sola exhibicion</option>
|
||||
<option value="PPD">Pago en parcialidades</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-gray-900">Conceptos</h3>
|
||||
<button type="button" onClick={addItem} className="text-primary-600 text-sm font-medium">
|
||||
+ Agregar concepto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{formData.items.map((item, index) => (
|
||||
<div key={index} className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="grid grid-cols-12 gap-3">
|
||||
<div className="col-span-6">
|
||||
<input
|
||||
type="text"
|
||||
value={item.descripcion}
|
||||
onChange={(e) => updateItem(index, 'descripcion', e.target.value)}
|
||||
className="input"
|
||||
placeholder="Descripcion"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
value={item.cantidad}
|
||||
onChange={(e) => updateItem(index, 'cantidad', Number(e.target.value))}
|
||||
className="input"
|
||||
placeholder="Cant"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<input
|
||||
type="number"
|
||||
value={item.valorUnitario}
|
||||
onChange={(e) => updateItem(index, 'valorUnitario', Number(e.target.value))}
|
||||
className="input"
|
||||
placeholder="Precio"
|
||||
min={0}
|
||||
step={0.01}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
{formData.items.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<XCircle className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="text-right">
|
||||
<p className="text-gray-500">Total (IVA incluido)</p>
|
||||
<p className="text-3xl font-bold">
|
||||
${total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="button" onClick={onClose} className="btn-secondary flex-1">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" disabled={mutation.isPending} className="btn-primary flex-1">
|
||||
{mutation.isPending ? 'Creando...' : 'Crear Factura'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/pages/Login.tsx
Normal file
103
src/pages/Login.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export default function Login() {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(phone, pin);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al iniciar sesion');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h1 className="text-center text-3xl font-bold text-emerald-600">
|
||||
MiChangarrito
|
||||
</h1>
|
||||
<h2 className="mt-6 text-center text-2xl font-bold text-gray-900">
|
||||
Iniciar sesion
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
O{' '}
|
||||
<Link to="/register" className="font-medium text-emerald-600 hover:text-emerald-500">
|
||||
registra tu negocio
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||
Telefono
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
maxLength={10}
|
||||
pattern="[0-9]{10}"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="5512345678"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="pin" className="block text-sm font-medium text-gray-700">
|
||||
PIN
|
||||
</label>
|
||||
<input
|
||||
id="pin"
|
||||
name="pin"
|
||||
type="password"
|
||||
required
|
||||
maxLength={6}
|
||||
minLength={4}
|
||||
pattern="[0-9]{4,6}"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="****"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Ingresando...' : 'Ingresar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
731
src/pages/Marketplace.tsx
Normal file
731
src/pages/Marketplace.tsx
Normal file
@ -0,0 +1,731 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Store,
|
||||
Search,
|
||||
Star,
|
||||
Heart,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
Truck,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MapPin,
|
||||
Phone,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { marketplaceApi } from '../lib/api';
|
||||
|
||||
const categories = [
|
||||
{ id: 'bebidas', name: 'Bebidas', icon: '🥤' },
|
||||
{ id: 'botanas', name: 'Botanas', icon: '🍿' },
|
||||
{ id: 'lacteos', name: 'Lacteos', icon: '🥛' },
|
||||
{ id: 'pan', name: 'Pan', icon: '🍞' },
|
||||
{ id: 'abarrotes', name: 'Abarrotes', icon: '🛒' },
|
||||
{ id: 'limpieza', name: 'Limpieza', icon: '🧹' },
|
||||
];
|
||||
|
||||
const orderStatusConfig = {
|
||||
pending: { label: 'Pendiente', color: 'yellow', icon: Clock },
|
||||
confirmed: { label: 'Confirmado', color: 'blue', icon: CheckCircle },
|
||||
preparing: { label: 'Preparando', color: 'indigo', icon: Package },
|
||||
shipped: { label: 'En camino', color: 'purple', icon: Truck },
|
||||
delivered: { label: 'Entregado', color: 'green', icon: CheckCircle },
|
||||
cancelled: { label: 'Cancelado', color: 'red', icon: XCircle },
|
||||
rejected: { label: 'Rechazado', color: 'red', icon: XCircle },
|
||||
};
|
||||
|
||||
export function Marketplace() {
|
||||
const [view, setView] = useState<'suppliers' | 'orders' | 'favorites'>('suppliers');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<any>(null);
|
||||
const [showCart, setShowCart] = useState(false);
|
||||
const [cart, setCart] = useState<{ product: any; quantity: number }[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: suppliers = [], isLoading: loadingSuppliers } = useQuery({
|
||||
queryKey: ['suppliers', selectedCategory, searchQuery],
|
||||
queryFn: async () => {
|
||||
const response = await marketplaceApi.getSuppliers({
|
||||
category: selectedCategory || undefined,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: orders = [], isLoading: loadingOrders } = useQuery({
|
||||
queryKey: ['marketplace-orders'],
|
||||
queryFn: async () => {
|
||||
const response = await marketplaceApi.getOrders();
|
||||
return response.data;
|
||||
},
|
||||
enabled: view === 'orders',
|
||||
});
|
||||
|
||||
const { data: favorites = [] } = useQuery({
|
||||
queryKey: ['supplier-favorites'],
|
||||
queryFn: async () => {
|
||||
const response = await marketplaceApi.getFavorites();
|
||||
return response.data;
|
||||
},
|
||||
enabled: view === 'favorites',
|
||||
});
|
||||
|
||||
const addToCart = (product: any) => {
|
||||
const existing = cart.find((item) => item.product.id === product.id);
|
||||
if (existing) {
|
||||
setCart(
|
||||
cart.map((item) =>
|
||||
item.product.id === product.id
|
||||
? { ...item, quantity: item.quantity + 1 }
|
||||
: item
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setCart([...cart, { product, quantity: 1 }]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFromCart = (productId: string) => {
|
||||
setCart(cart.filter((item) => item.product.id !== productId));
|
||||
};
|
||||
|
||||
const updateCartQuantity = (productId: string, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
removeFromCart(productId);
|
||||
} else {
|
||||
setCart(
|
||||
cart.map((item) =>
|
||||
item.product.id === productId ? { ...item, quantity } : item
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const cartTotal = cart.reduce(
|
||||
(sum, item) => sum + Number(item.product.unitPrice) * item.quantity,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Marketplace</h1>
|
||||
<p className="text-gray-500">Encuentra proveedores para tu negocio</p>
|
||||
</div>
|
||||
|
||||
{cart.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowCart(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Carrito ({cart.length})
|
||||
<span className="ml-2 font-bold">
|
||||
${cartTotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b">
|
||||
<button
|
||||
onClick={() => setView('suppliers')}
|
||||
className={clsx(
|
||||
'px-4 py-2 font-medium border-b-2 -mb-px transition-colors',
|
||||
view === 'suppliers'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
<Store className="h-4 w-4 inline mr-2" />
|
||||
Proveedores
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('orders')}
|
||||
className={clsx(
|
||||
'px-4 py-2 font-medium border-b-2 -mb-px transition-colors',
|
||||
view === 'orders'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
<Package className="h-4 w-4 inline mr-2" />
|
||||
Mis Pedidos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('favorites')}
|
||||
className={clsx(
|
||||
'px-4 py-2 font-medium border-b-2 -mb-px transition-colors',
|
||||
view === 'favorites'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
<Heart className="h-4 w-4 inline mr-2" />
|
||||
Favoritos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view === 'suppliers' && (
|
||||
<>
|
||||
{/* Search & Filter */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Buscar proveedores..."
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
|
||||
!selectedCategory
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors flex items-center gap-2',
|
||||
selectedCategory === category.id
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
<span>{category.icon}</span>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Suppliers Grid */}
|
||||
{loadingSuppliers ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="text-gray-500 mt-2">Buscando proveedores...</p>
|
||||
</div>
|
||||
) : suppliers.length === 0 ? (
|
||||
<div className="text-center py-12 card">
|
||||
<Store className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900">No hay proveedores</h3>
|
||||
<p className="text-gray-500">Pronto habra mas proveedores disponibles</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{suppliers.map((supplier: any) => (
|
||||
<SupplierCard
|
||||
key={supplier.id}
|
||||
supplier={supplier}
|
||||
onClick={() => setSelectedSupplier(supplier)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'orders' && (
|
||||
<div className="space-y-4">
|
||||
{loadingOrders ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="text-center py-12 card">
|
||||
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900">No hay pedidos</h3>
|
||||
<p className="text-gray-500">Tus pedidos a proveedores apareceran aqui</p>
|
||||
</div>
|
||||
) : (
|
||||
orders.map((order: any) => (
|
||||
<OrderCard key={order.id} order={order} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'favorites' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{favorites.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 card">
|
||||
<Heart className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Sin favoritos</h3>
|
||||
<p className="text-gray-500">Agrega proveedores a tus favoritos</p>
|
||||
</div>
|
||||
) : (
|
||||
favorites.map((supplier: any) => (
|
||||
<SupplierCard
|
||||
key={supplier.id}
|
||||
supplier={supplier}
|
||||
onClick={() => setSelectedSupplier(supplier)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supplier Detail Modal */}
|
||||
{selectedSupplier && (
|
||||
<SupplierDetailModal
|
||||
supplier={selectedSupplier}
|
||||
onClose={() => setSelectedSupplier(null)}
|
||||
onAddToCart={addToCart}
|
||||
cart={cart}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cart Modal */}
|
||||
{showCart && (
|
||||
<CartModal
|
||||
cart={cart}
|
||||
supplier={selectedSupplier}
|
||||
onClose={() => setShowCart(false)}
|
||||
onUpdateQuantity={updateCartQuantity}
|
||||
onRemove={removeFromCart}
|
||||
onOrderSuccess={() => {
|
||||
setCart([]);
|
||||
setShowCart(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['marketplace-orders'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SupplierCard({
|
||||
supplier,
|
||||
onClick,
|
||||
}: {
|
||||
supplier: any;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="card cursor-pointer hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{supplier.logoUrl ? (
|
||||
<img src={supplier.logoUrl} alt={supplier.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Store className="h-8 w-8 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-bold text-gray-900 truncate">{supplier.name}</h3>
|
||||
{supplier.verified && (
|
||||
<CheckCircle className="h-4 w-4 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex items-center text-yellow-500">
|
||||
<Star className="h-4 w-4 fill-current" />
|
||||
<span className="ml-1 text-sm font-medium">{Number(supplier.rating).toFixed(1)}</span>
|
||||
</div>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-sm text-gray-500">{supplier.totalReviews} resenas</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{supplier.categories?.slice(0, 3).map((cat: string) => (
|
||||
<span key={cat} className="px-2 py-0.5 text-xs bg-gray-100 rounded-full text-gray-600">
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>Min: ${Number(supplier.minOrderAmount).toFixed(0)}</span>
|
||||
{Number(supplier.deliveryFee) > 0 ? (
|
||||
<span>Envio: ${Number(supplier.deliveryFee).toFixed(0)}</span>
|
||||
) : (
|
||||
<span className="text-green-600">Envio gratis</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderCard({ order }: { order: any }) {
|
||||
const status = orderStatusConfig[order.status as keyof typeof orderStatusConfig] || orderStatusConfig.pending;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full bg-${status.color}-100`}>
|
||||
<StatusIcon className={`h-6 w-6 text-${status.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold">Pedido #{order.orderNumber}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full bg-${status.color}-100 text-${status.color}-700`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600">{order.supplier?.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{order.items?.length} productos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">
|
||||
${Number(order.total).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(order.createdAt).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SupplierDetailModal({
|
||||
supplier,
|
||||
onClose,
|
||||
onAddToCart,
|
||||
cart,
|
||||
}: {
|
||||
supplier: any;
|
||||
onClose: () => void;
|
||||
onAddToCart: (product: any) => void;
|
||||
cart: { product: any; quantity: number }[];
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: products = [], isLoading } = useQuery({
|
||||
queryKey: ['supplier-products', supplier.id, search],
|
||||
queryFn: async () => {
|
||||
const response = await marketplaceApi.getSupplierProducts(supplier.id, {
|
||||
search: search || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: () => marketplaceApi.addFavorite(supplier.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['supplier-favorites'] });
|
||||
},
|
||||
});
|
||||
|
||||
const getCartQuantity = (productId: string) => {
|
||||
const item = cart.find((i) => i.product.id === productId);
|
||||
return item?.quantity || 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-20 h-20 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{supplier.logoUrl ? (
|
||||
<img src={supplier.logoUrl} alt={supplier.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Store className="h-10 w-10 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{supplier.name}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<XCircle className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Star className="h-4 w-4 text-yellow-500 fill-current" />
|
||||
<span className="font-medium">{Number(supplier.rating).toFixed(1)}</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-gray-500">{supplier.totalReviews} resenas</span>
|
||||
</div>
|
||||
{supplier.address && (
|
||||
<p className="text-sm text-gray-500 mt-1 flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{supplier.city}, {supplier.state}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => favoriteMutation.mutate()}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Heart className="h-4 w-4" />
|
||||
Agregar a favoritos
|
||||
</button>
|
||||
{supplier.contactPhone && (
|
||||
<a href={`tel:${supplier.contactPhone}`} className="btn-secondary flex items-center gap-2">
|
||||
<Phone className="h-4 w-4" />
|
||||
Llamar
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar productos..."
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No hay productos disponibles
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{products.map((product: any) => {
|
||||
const inCart = getCartQuantity(product.id);
|
||||
return (
|
||||
<div key={product.id} className="flex gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="w-16 h-16 rounded bg-white flex items-center justify-center overflow-hidden">
|
||||
{product.imageUrl ? (
|
||||
<img src={product.imageUrl} alt={product.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Package className="h-6 w-6 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 truncate">{product.name}</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
Min: {product.minQuantity} {product.unitType}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="font-bold text-primary-600">
|
||||
${Number(product.unitPrice).toFixed(2)}
|
||||
</span>
|
||||
{inCart > 0 ? (
|
||||
<span className="text-sm text-green-600">
|
||||
{inCart} en carrito
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAddToCart(product)}
|
||||
className="text-sm text-primary-600 font-medium"
|
||||
>
|
||||
+ Agregar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartModal({
|
||||
cart,
|
||||
supplier,
|
||||
onClose,
|
||||
onUpdateQuantity,
|
||||
onRemove,
|
||||
onOrderSuccess,
|
||||
}: {
|
||||
cart: { product: any; quantity: number }[];
|
||||
supplier: any;
|
||||
onClose: () => void;
|
||||
onUpdateQuantity: (productId: string, quantity: number) => void;
|
||||
onRemove: (productId: string) => void;
|
||||
onOrderSuccess: () => void;
|
||||
}) {
|
||||
const [deliveryAddress, setDeliveryAddress] = useState('');
|
||||
const [deliveryPhone, setDeliveryPhone] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const subtotal = cart.reduce(
|
||||
(sum, item) => sum + Number(item.product.unitPrice) * item.quantity,
|
||||
0
|
||||
);
|
||||
|
||||
const orderMutation = useMutation({
|
||||
mutationFn: (data: any) => marketplaceApi.createOrder(data),
|
||||
onSuccess: onOrderSuccess,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const supplierId = cart[0]?.product?.supplierId;
|
||||
if (!supplierId) return;
|
||||
|
||||
orderMutation.mutate({
|
||||
supplierId,
|
||||
items: cart.map((item) => ({
|
||||
productId: item.product.id,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
deliveryAddress,
|
||||
deliveryPhone,
|
||||
notes,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">Tu Carrito</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<XCircle className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Items */}
|
||||
<div className="space-y-3">
|
||||
{cart.map((item) => (
|
||||
<div key={item.product.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.product.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
${Number(item.product.unitPrice).toFixed(2)} x {item.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateQuantity(item.product.id, item.quantity - 1)}
|
||||
className="w-8 h-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-8 text-center font-medium">{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateQuantity(item.product.id, item.quantity + 1)}
|
||||
className="w-8 h-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-bold w-20 text-right">
|
||||
${(Number(item.product.unitPrice) * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delivery Info */}
|
||||
<div className="pt-4 border-t space-y-4">
|
||||
<h3 className="font-medium">Datos de entrega</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Direccion de entrega
|
||||
</label>
|
||||
<textarea
|
||||
value={deliveryAddress}
|
||||
onChange={(e) => setDeliveryAddress(e.target.value)}
|
||||
className="input"
|
||||
rows={2}
|
||||
required
|
||||
placeholder="Calle, numero, colonia, CP"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Telefono de contacto
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={deliveryPhone}
|
||||
onChange={(e) => setDeliveryPhone(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
placeholder="5512345678"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="input"
|
||||
rows={2}
|
||||
placeholder="Instrucciones especiales..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span>${subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="button" onClick={onClose} className="btn-secondary flex-1">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={orderMutation.isPending || cart.length === 0}
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{orderMutation.isPending ? 'Enviando...' : 'Enviar Pedido'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/pages/Orders.tsx
Normal file
181
src/pages/Orders.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, ChefHat, Package } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const mockOrders = [
|
||||
{
|
||||
id: 'MCH-001',
|
||||
customer: 'Maria Lopez',
|
||||
phone: '5551234567',
|
||||
items: [
|
||||
{ name: 'Coca-Cola 600ml', qty: 2, price: 18.00 },
|
||||
{ name: 'Sabritas', qty: 3, price: 15.00 },
|
||||
],
|
||||
total: 81.00,
|
||||
status: 'pending',
|
||||
source: 'whatsapp',
|
||||
createdAt: '2024-01-15T10:30:00',
|
||||
},
|
||||
{
|
||||
id: 'MCH-002',
|
||||
customer: 'Juan Perez',
|
||||
phone: '5559876543',
|
||||
items: [
|
||||
{ name: 'Leche Lala 1L', qty: 2, price: 28.00 },
|
||||
{ name: 'Pan Bimbo', qty: 1, price: 45.00 },
|
||||
],
|
||||
total: 101.00,
|
||||
status: 'preparing',
|
||||
source: 'pos',
|
||||
createdAt: '2024-01-15T10:45:00',
|
||||
},
|
||||
{
|
||||
id: 'MCH-003',
|
||||
customer: 'Ana Garcia',
|
||||
phone: '5555555555',
|
||||
items: [
|
||||
{ name: 'Fabuloso 1L', qty: 1, price: 32.00 },
|
||||
],
|
||||
total: 32.00,
|
||||
status: 'ready',
|
||||
source: 'whatsapp',
|
||||
createdAt: '2024-01-15T11:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig = {
|
||||
pending: { label: 'Pendiente', color: 'yellow', icon: Clock },
|
||||
confirmed: { label: 'Confirmado', color: 'blue', icon: CheckCircle },
|
||||
preparing: { label: 'Preparando', color: 'indigo', icon: ChefHat },
|
||||
ready: { label: 'Listo', color: 'green', icon: Package },
|
||||
completed: { label: 'Completado', color: 'gray', icon: CheckCircle },
|
||||
cancelled: { label: 'Cancelado', color: 'red', icon: XCircle },
|
||||
};
|
||||
|
||||
const statusFlow = ['pending', 'confirmed', 'preparing', 'ready', 'completed'];
|
||||
|
||||
export function Orders() {
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
const filteredOrders = filter === 'all'
|
||||
? mockOrders
|
||||
: mockOrders.filter(o => o.status === filter);
|
||||
|
||||
const updateStatus = (orderId: string, currentStatus: string) => {
|
||||
const currentIndex = statusFlow.indexOf(currentStatus);
|
||||
if (currentIndex < statusFlow.length - 1) {
|
||||
const nextStatus = statusFlow[currentIndex + 1];
|
||||
console.log(`Updating ${orderId} to ${nextStatus}`);
|
||||
// API call would go here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Pedidos</h1>
|
||||
<p className="text-gray-500">Gestiona los pedidos de tu tienda</p>
|
||||
</div>
|
||||
|
||||
{/* Status Tabs */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
|
||||
filter === 'all'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
Todos ({mockOrders.length})
|
||||
</button>
|
||||
{Object.entries(statusConfig).slice(0, 4).map(([status, config]) => {
|
||||
const count = mockOrders.filter(o => o.status === status).length;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
|
||||
filter === status
|
||||
? `bg-${config.color}-100 text-${config.color}-700`
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{config.label} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Orders List */}
|
||||
<div className="space-y-4">
|
||||
{filteredOrders.map((order) => {
|
||||
const status = statusConfig[order.status as keyof typeof statusConfig];
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div key={order.id} className="card">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full bg-${status.color}-100`}>
|
||||
<StatusIcon className={`h-6 w-6 text-${status.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-lg">{order.id}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full bg-${status.color}-100 text-${status.color}-700`}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">
|
||||
{order.source === 'whatsapp' ? 'WhatsApp' : 'POS'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600">{order.customer}</p>
|
||||
<p className="text-sm text-gray-500">{order.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 lg:px-8">
|
||||
<div className="text-sm text-gray-600">
|
||||
{order.items.map((item, i) => (
|
||||
<span key={i}>
|
||||
{item.qty}x {item.name}
|
||||
{i < order.items.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">${order.total.toFixed(2)}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(order.createdAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{order.status !== 'completed' && order.status !== 'cancelled' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, order.status)}
|
||||
className="btn-primary whitespace-nowrap"
|
||||
>
|
||||
{order.status === 'pending' && 'Confirmar'}
|
||||
{order.status === 'confirmed' && 'Preparar'}
|
||||
{order.status === 'preparing' && 'Listo'}
|
||||
{order.status === 'ready' && 'Entregar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/pages/Products.tsx
Normal file
103
src/pages/Products.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
|
||||
|
||||
const mockProducts = [
|
||||
{ id: '1', name: 'Coca-Cola 600ml', category: 'bebidas', price: 18.00, stock: 24, barcode: '7501055300051' },
|
||||
{ id: '2', name: 'Sabritas Original', category: 'botanas', price: 15.00, stock: 12, barcode: '7501000111111' },
|
||||
{ id: '3', name: 'Leche Lala 1L', category: 'lacteos', price: 28.00, stock: 8, barcode: '7501020500001' },
|
||||
{ id: '4', name: 'Pan Bimbo Grande', category: 'panaderia', price: 45.00, stock: 5, barcode: '7501030400001' },
|
||||
{ id: '5', name: 'Fabuloso 1L', category: 'limpieza', price: 32.00, stock: 6, barcode: '7501040300001' },
|
||||
{ id: '6', name: 'Pepsi 600ml', category: 'bebidas', price: 17.00, stock: 20, barcode: '7501055300052' },
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todos' },
|
||||
{ id: 'bebidas', name: 'Bebidas' },
|
||||
{ id: 'botanas', name: 'Botanas' },
|
||||
{ id: 'lacteos', name: 'Lacteos' },
|
||||
{ id: 'panaderia', name: 'Panaderia' },
|
||||
{ id: 'limpieza', name: 'Limpieza' },
|
||||
];
|
||||
|
||||
export function Products() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('all');
|
||||
|
||||
const filteredProducts = mockProducts.filter((product) => {
|
||||
const matchesSearch = product.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = category === 'all' || product.category === category;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
|
||||
<p className="text-gray-500">{mockProducts.length} productos en catalogo</p>
|
||||
</div>
|
||||
<button className="btn-primary flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Agregar Producto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar productos..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="input sm:w-48"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filteredProducts.map((product) => (
|
||||
<div key={product.id} className="card hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Package className="h-8 w-8 text-gray-600" />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button className="p-1 hover:bg-gray-100 rounded">
|
||||
<Edit className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-red-100 rounded">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">{product.name}</h3>
|
||||
<p className="text-sm text-gray-500 capitalize">{product.category}</p>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xl font-bold text-primary-600">
|
||||
${product.price.toFixed(2)}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${
|
||||
product.stock > 10 ? 'text-green-600' : product.stock > 5 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{product.stock} en stock
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">{product.barcode}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
src/pages/Referrals.tsx
Normal file
276
src/pages/Referrals.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Gift, Users, Copy, Share2, Check, Clock, X } from 'lucide-react';
|
||||
import { referralsApi } from '../lib/api';
|
||||
|
||||
interface ReferralStats {
|
||||
code: string;
|
||||
totalInvited: number;
|
||||
totalConverted: number;
|
||||
totalPending: number;
|
||||
totalExpired: number;
|
||||
monthsEarned: number;
|
||||
monthsAvailable: number;
|
||||
}
|
||||
|
||||
interface Referral {
|
||||
id: string;
|
||||
referredTenantId: string;
|
||||
codeUsed: string;
|
||||
status: 'pending' | 'converted' | 'rewarded' | 'expired';
|
||||
createdAt: string;
|
||||
convertedAt?: string;
|
||||
}
|
||||
|
||||
export function Referrals() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: stats, isLoading } = useQuery<ReferralStats>({
|
||||
queryKey: ['referral-stats'],
|
||||
queryFn: async () => {
|
||||
const res = await referralsApi.getStats();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: referrals } = useQuery<Referral[]>({
|
||||
queryKey: ['referrals'],
|
||||
queryFn: async () => {
|
||||
const res = await referralsApi.getMyReferrals();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const generateCodeMutation = useMutation({
|
||||
mutationFn: () => referralsApi.generateCode(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['referral-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const copyCode = async () => {
|
||||
if (stats?.code) {
|
||||
await navigator.clipboard.writeText(stats.code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const shareWhatsApp = () => {
|
||||
if (stats?.code) {
|
||||
const text = `Usa mi codigo ${stats.code} para registrarte en MiChangarrito y obtener 50% de descuento en tu primer mes! https://michangarrito.com/r/${stats.code}`;
|
||||
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pendiente
|
||||
</span>
|
||||
);
|
||||
case 'converted':
|
||||
case 'rewarded':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<Check className="h-3 w-3" />
|
||||
Convertido
|
||||
</span>
|
||||
);
|
||||
case 'expired':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
<X className="h-3 w-3" />
|
||||
Expirado
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Programa de Referidos</h1>
|
||||
<p className="text-gray-500">Invita amigos y gana meses gratis</p>
|
||||
</div>
|
||||
|
||||
{/* Share Card */}
|
||||
<div className="card bg-gradient-to-r from-primary-500 to-primary-600 text-white">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Gift className="h-8 w-8" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Invita amigos y gana</h2>
|
||||
<p className="text-primary-100">Por cada amigo que se suscriba, ganas 1 mes gratis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-primary-100 mb-2">Tu codigo de referido:</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl font-bold tracking-wider">{stats?.code || 'Generando...'}</span>
|
||||
<button
|
||||
onClick={copyCode}
|
||||
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 transition-colors"
|
||||
title="Copiar codigo"
|
||||
>
|
||||
{copied ? <Check className="h-5 w-5" /> : <Copy className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={copyCode}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-white text-primary-600 rounded-lg font-medium hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
{copied ? 'Copiado!' : 'Copiar codigo'}
|
||||
</button>
|
||||
<button
|
||||
onClick={shareWhatsApp}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg font-medium hover:bg-green-600 transition-colors"
|
||||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
Compartir por WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats?.totalInvited || 0}</p>
|
||||
<p className="text-sm text-gray-500">Invitados</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<Check className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats?.totalConverted || 0}</p>
|
||||
<p className="text-sm text-gray-500">Convertidos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Gift className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats?.monthsEarned || 0}</p>
|
||||
<p className="text-sm text-gray-500">Meses ganados</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-100 rounded-lg">
|
||||
<Gift className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats?.monthsAvailable || 0}</p>
|
||||
<p className="text-sm text-gray-500">Disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold mb-4">Como funciona</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-xl font-bold text-primary-600">1</span>
|
||||
</div>
|
||||
<h4 className="font-medium mb-1">Comparte tu codigo</h4>
|
||||
<p className="text-sm text-gray-500">Envia tu codigo a amigos por WhatsApp</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-xl font-bold text-primary-600">2</span>
|
||||
</div>
|
||||
<h4 className="font-medium mb-1">Tu amigo se registra</h4>
|
||||
<p className="text-sm text-gray-500">Obtiene 50% de descuento en su primer mes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-xl font-bold text-primary-600">3</span>
|
||||
</div>
|
||||
<h4 className="font-medium mb-1">Tu ganas 1 mes gratis</h4>
|
||||
<p className="text-sm text-gray-500">Cuando tu amigo paga su primer mes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referrals List */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold mb-4">Tus referidos</h3>
|
||||
{referrals && referrals.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-gray-500 border-b">
|
||||
<th className="pb-3 font-medium">Fecha</th>
|
||||
<th className="pb-3 font-medium">Estado</th>
|
||||
<th className="pb-3 font-medium">Conversion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{referrals.map((referral) => (
|
||||
<tr key={referral.id}>
|
||||
<td className="py-3">
|
||||
{new Date(referral.createdAt).toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</td>
|
||||
<td className="py-3">{getStatusBadge(referral.status)}</td>
|
||||
<td className="py-3 text-sm text-gray-500">
|
||||
{referral.convertedAt
|
||||
? new Date(referral.convertedAt).toLocaleDateString('es-MX')
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Users className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>Aun no tienes referidos</p>
|
||||
<p className="text-sm">Comparte tu codigo para empezar a ganar</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
src/pages/Register.tsx
Normal file
241
src/pages/Register.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const BUSINESS_TYPES = [
|
||||
{ value: 'tiendita', label: 'Tiendita / Abarrotes' },
|
||||
{ value: 'fonda', label: 'Fonda / Comida' },
|
||||
{ value: 'taqueria', label: 'Taqueria' },
|
||||
{ value: 'tortilleria', label: 'Tortilleria' },
|
||||
{ value: 'verduleria', label: 'Verduleria' },
|
||||
{ value: 'otro', label: 'Otro' },
|
||||
];
|
||||
|
||||
export default function Register() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
ownerName: '',
|
||||
businessType: 'tiendita',
|
||||
phone: '',
|
||||
pin: '',
|
||||
confirmPin: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
if (name === 'phone' || name === 'pin' || name === 'confirmPin') {
|
||||
setFormData({ ...formData, [name]: value.replace(/\D/g, '') });
|
||||
} else {
|
||||
setFormData({ ...formData, [name]: value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (formData.pin !== formData.confirmPin) {
|
||||
setError('Los PINs no coinciden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.pin.length < 4) {
|
||||
setError('El PIN debe tener al menos 4 digitos');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await register({
|
||||
name: formData.name,
|
||||
ownerName: formData.ownerName,
|
||||
businessType: formData.businessType,
|
||||
phone: formData.phone,
|
||||
pin: formData.pin,
|
||||
email: formData.email || undefined,
|
||||
});
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al registrar');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h1 className="text-center text-3xl font-bold text-emerald-600">
|
||||
MiChangarrito
|
||||
</h1>
|
||||
<h2 className="mt-6 text-center text-2xl font-bold text-gray-900">
|
||||
Registra tu negocio
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Ya tienes cuenta?{' '}
|
||||
<Link to="/login" className="font-medium text-emerald-600 hover:text-emerald-500">
|
||||
Inicia sesion
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Nombre del negocio *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
maxLength={100}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="Ej: Abarrotes Don Pepe"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ownerName" className="block text-sm font-medium text-gray-700">
|
||||
Tu nombre *
|
||||
</label>
|
||||
<input
|
||||
id="ownerName"
|
||||
name="ownerName"
|
||||
type="text"
|
||||
required
|
||||
maxLength={100}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="Ej: Jose Perez"
|
||||
value={formData.ownerName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="businessType" className="block text-sm font-medium text-gray-700">
|
||||
Tipo de negocio *
|
||||
</label>
|
||||
<select
|
||||
id="businessType"
|
||||
name="businessType"
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
value={formData.businessType}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{BUSINESS_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||
Telefono (10 digitos) *
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
maxLength={10}
|
||||
pattern="[0-9]{10}"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="5512345678"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email (opcional)
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="tucorreo@ejemplo.com"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="pin" className="block text-sm font-medium text-gray-700">
|
||||
PIN (4-6 digitos) *
|
||||
</label>
|
||||
<input
|
||||
id="pin"
|
||||
name="pin"
|
||||
type="password"
|
||||
required
|
||||
maxLength={6}
|
||||
minLength={4}
|
||||
pattern="[0-9]{4,6}"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="****"
|
||||
value={formData.pin}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPin" className="block text-sm font-medium text-gray-700">
|
||||
Confirmar PIN *
|
||||
</label>
|
||||
<input
|
||||
id="confirmPin"
|
||||
name="confirmPin"
|
||||
type="password"
|
||||
required
|
||||
maxLength={6}
|
||||
minLength={4}
|
||||
pattern="[0-9]{4,6}"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
|
||||
placeholder="****"
|
||||
value={formData.confirmPin}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Registrando...' : 'Registrar mi negocio'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-center text-gray-500 mt-4">
|
||||
Al registrarte aceptas los terminos y condiciones del servicio.
|
||||
Prueba gratis por 14 dias.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/pages/Settings.tsx
Normal file
165
src/pages/Settings.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { Store, CreditCard, Bell, MessageSquare, Shield } from 'lucide-react';
|
||||
|
||||
export function Settings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Ajustes</h1>
|
||||
<p className="text-gray-500">Configura tu tienda</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Business Info */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Store className="h-6 w-6 text-primary-600" />
|
||||
<h2 className="text-lg font-bold">Informacion del Negocio</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre de la tienda
|
||||
</label>
|
||||
<input type="text" className="input" defaultValue="Mi Tiendita" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Telefono
|
||||
</label>
|
||||
<input type="tel" className="input" defaultValue="555-123-4567" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Direccion
|
||||
</label>
|
||||
<input type="text" className="input" defaultValue="Calle Principal #123" />
|
||||
</div>
|
||||
<button className="btn-primary">Guardar cambios</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiado Settings */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<CreditCard className="h-6 w-6 text-primary-600" />
|
||||
<h2 className="text-lg font-bold">Configuracion de Fiado</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Habilitar fiado</p>
|
||||
<p className="text-sm text-gray-500">Permite credito a clientes</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Limite de credito por defecto
|
||||
</label>
|
||||
<input type="number" className="input" defaultValue="500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Dias de gracia
|
||||
</label>
|
||||
<input type="number" className="input" defaultValue="15" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WhatsApp Settings */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<MessageSquare className="h-6 w-6 text-green-600" />
|
||||
<h2 className="text-lg font-bold">WhatsApp Business</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<p className="text-green-800 font-medium">Conectado</p>
|
||||
<p className="text-sm text-green-600">+52 555 123 4567</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Respuestas automaticas</p>
|
||||
<p className="text-sm text-gray-500">Usa IA para responder</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Notificar pedidos</p>
|
||||
<p className="text-sm text-gray-500">Avisa cuando hay pedidos nuevos</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Bell className="h-6 w-6 text-primary-600" />
|
||||
<h2 className="text-lg font-bold">Notificaciones</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Stock bajo</p>
|
||||
<p className="text-sm text-gray-500">Alerta cuando hay poco inventario</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Fiados vencidos</p>
|
||||
<p className="text-sm text-gray-500">Recordatorio de cobros pendientes</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Nuevos pedidos</p>
|
||||
<p className="text-sm text-gray-500">Sonido al recibir pedidos</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Shield className="h-6 w-6 text-primary-600" />
|
||||
<h2 className="text-lg font-bold">Plan de Suscripcion</h2>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-bold text-xl">Plan Basico</p>
|
||||
<p className="text-gray-500">$299/mes - 1,000 mensajes IA incluidos</p>
|
||||
<p className="text-sm text-gray-400">Renueva: 15 de febrero, 2024</p>
|
||||
</div>
|
||||
<button className="btn-primary">Mejorar plan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
tailwind.config.js
Normal file
40
tailwind.config.js
Normal file
@ -0,0 +1,40 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#fff7ed',
|
||||
100: '#ffedd5',
|
||||
200: '#fed7aa',
|
||||
300: '#fdba74',
|
||||
400: '#fb923c',
|
||||
500: '#f97316',
|
||||
600: '#ea580c',
|
||||
700: '#c2410c',
|
||||
800: '#9a3412',
|
||||
900: '#7c2d12',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
vite.config.ts
Normal file
17
vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3140,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3141',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user