Migración desde erp-mecanicas-diesel/frontend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2a897e53e4
commit
abff318db4
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:3011/api/v1
|
||||
|
||||
# Environment
|
||||
VITE_APP_NAME=Mecanicas Diesel
|
||||
VITE_APP_ENV=development
|
||||
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?
|
||||
153
README.md
153
README.md
@ -1,3 +1,152 @@
|
||||
# erp-mecanicas-diesel-frontend-v2
|
||||
# Frontend - ERP Mecanicas Diesel
|
||||
|
||||
Frontend de erp-mecanicas-diesel - Workspace V2
|
||||
## Stack Tecnologico
|
||||
|
||||
| Tecnologia | Version | Proposito |
|
||||
|------------|---------|-----------|
|
||||
| React | 18.x | Framework UI |
|
||||
| Vite | 6.x | Build tool |
|
||||
| TypeScript | 5.x | Lenguaje |
|
||||
| React Router | 6.x | Routing |
|
||||
| Zustand | 5.x | State management |
|
||||
| React Query | 5.x | Data fetching/caching |
|
||||
| Tailwind CSS | 3.x | Styling |
|
||||
| React Hook Form | 7.x | Formularios |
|
||||
| Zod | 3.x | Validacion |
|
||||
| Axios | 1.x | HTTP client |
|
||||
| Lucide React | - | Iconos |
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── common/ # Componentes reutilizables (Button, Input, etc)
|
||||
│ ├── layout/ # Layout principal (Sidebar, Header, MainLayout)
|
||||
│ └── features/ # Componentes especificos por modulo
|
||||
├── features/ # Logica por modulo/epic
|
||||
│ ├── auth/ # Autenticacion
|
||||
│ ├── service-orders/ # MMD-002: Ordenes de servicio
|
||||
│ ├── diagnostics/ # MMD-003: Diagnosticos
|
||||
│ ├── inventory/ # MMD-004: Inventario
|
||||
│ ├── vehicles/ # MMD-005: Vehiculos
|
||||
│ ├── quotes/ # MMD-006: Cotizaciones
|
||||
│ └── settings/ # MMD-001: Configuracion
|
||||
├── store/ # Zustand stores
|
||||
│ ├── authStore.ts # Estado de autenticacion
|
||||
│ └── tallerStore.ts # Estado del taller
|
||||
├── services/
|
||||
│ └── api/ # Clientes API
|
||||
│ ├── client.ts # Axios instance con interceptors
|
||||
│ ├── auth.ts # Endpoints de auth
|
||||
│ └── serviceOrders.ts # Endpoints de ordenes
|
||||
├── pages/ # Paginas/Vistas
|
||||
│ ├── Login.tsx
|
||||
│ └── Dashboard.tsx
|
||||
├── hooks/ # Custom React hooks
|
||||
├── types/ # TypeScript types
|
||||
│ └── index.ts # Tipos base
|
||||
├── utils/ # Utilidades
|
||||
├── App.tsx # Router principal
|
||||
├── main.tsx # Entry point
|
||||
└── index.css # Tailwind imports
|
||||
```
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
# Desarrollo
|
||||
npm run dev
|
||||
|
||||
# Build produccion
|
||||
npm run build
|
||||
|
||||
# Preview build
|
||||
npm run preview
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
Crear archivo `.env.local`:
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:3041/api/v1
|
||||
```
|
||||
|
||||
## Modulos por Implementar
|
||||
|
||||
### MMD-001: Fundamentos (Sprint 1-2)
|
||||
- [ ] Configuracion de taller (wizard)
|
||||
- [ ] Gestion de roles
|
||||
- [ ] Catalogo de servicios
|
||||
- [ ] Gestion de bahias
|
||||
|
||||
### MMD-002: Ordenes de Servicio (Sprint 2-5)
|
||||
- [ ] Lista de ordenes con filtros
|
||||
- [ ] Detalle de orden
|
||||
- [ ] Crear orden (wizard 4 pasos)
|
||||
- [ ] Tablero Kanban
|
||||
- [ ] Registro de trabajos
|
||||
|
||||
### MMD-003: Diagnosticos (Sprint 2-4)
|
||||
- [ ] Lista de diagnosticos
|
||||
- [ ] Scanner OBD (DTC codes)
|
||||
- [ ] Pruebas de banco
|
||||
- [ ] Galeria de fotos
|
||||
|
||||
### MMD-004: Inventario (Sprint 4-6)
|
||||
- [ ] Catalogo de refacciones
|
||||
- [ ] Kardex de movimientos
|
||||
- [ ] Alertas de stock
|
||||
- [ ] Recepcion de mercancia
|
||||
|
||||
### MMD-005: Vehiculos (Sprint 4-6)
|
||||
- [ ] Lista de vehiculos
|
||||
- [ ] Ficha tecnica
|
||||
- [ ] Especificaciones de motor
|
||||
- [ ] Historial de servicios
|
||||
|
||||
### MMD-006: Cotizaciones (Sprint 6)
|
||||
- [ ] Lista de cotizaciones
|
||||
- [ ] Crear cotizacion
|
||||
- [ ] Generar PDF
|
||||
- [ ] Envio por email/WhatsApp
|
||||
|
||||
## Convenciones
|
||||
|
||||
### Nombres de Archivos
|
||||
- Componentes: `PascalCase.tsx`
|
||||
- Hooks: `useCamelCase.ts`
|
||||
- Stores: `camelCaseStore.ts`
|
||||
- Types: `camelCase.types.ts`
|
||||
- Services: `camelCase.ts`
|
||||
|
||||
### Estructura de Feature
|
||||
```
|
||||
features/{feature}/
|
||||
├── components/ # Componentes UI
|
||||
├── hooks/ # Custom hooks
|
||||
├── types/ # TypeScript types
|
||||
└── index.ts # Exports publicos
|
||||
```
|
||||
|
||||
## Dependencias del Backend
|
||||
|
||||
Este frontend requiere el backend de mecanicas-diesel corriendo en el puerto 3041.
|
||||
|
||||
```bash
|
||||
# Desde la raiz del proyecto
|
||||
cd ../backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*ERP Mecanicas Diesel - Sistema NEXUS*
|
||||
*Creado: 2025-12-08*
|
||||
|
||||
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>
|
||||
4344
package-lock.json
generated
Normal file
4344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"axios": "^1.13.2",
|
||||
"lucide-react": "^0.556.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^24.10.2",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"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.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
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 |
132
src/App.tsx
Normal file
132
src/App.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { MainLayout } from './components/layout';
|
||||
import { ToastContainer } from './components/ui';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const Login = lazy(() => import('./pages/Login').then(m => ({ default: m.Login })));
|
||||
const Register = lazy(() => import('./pages/Register').then(m => ({ default: m.Register })));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })));
|
||||
const UsersPage = lazy(() => import('./pages/Users').then(m => ({ default: m.UsersPage })));
|
||||
const ServiceOrdersPage = lazy(() => import('./pages/ServiceOrders').then(m => ({ default: m.ServiceOrdersPage })));
|
||||
const ServiceOrdersKanbanPage = lazy(() => import('./pages/ServiceOrdersKanban').then(m => ({ default: m.ServiceOrdersKanbanPage })));
|
||||
const ServiceOrderNewPage = lazy(() => import('./pages/ServiceOrderNew').then(m => ({ default: m.ServiceOrderNewPage })));
|
||||
const ServiceOrderDetailPage = lazy(() => import('./pages/ServiceOrderDetail').then(m => ({ default: m.ServiceOrderDetailPage })));
|
||||
const VehiclesPage = lazy(() => import('./pages/Vehicles').then(m => ({ default: m.VehiclesPage })));
|
||||
const VehicleDetailPage = lazy(() => import('./pages/VehicleDetail').then(m => ({ default: m.VehicleDetailPage })));
|
||||
const InventoryPage = lazy(() => import('./pages/Inventory').then(m => ({ default: m.InventoryPage })));
|
||||
const InventoryDetailPage = lazy(() => import('./pages/InventoryDetail').then(m => ({ default: m.InventoryDetailPage })));
|
||||
const CustomersPage = lazy(() => import('./pages/Customers').then(m => ({ default: m.CustomersPage })));
|
||||
const CustomerDetailPage = lazy(() => import('./pages/CustomerDetail').then(m => ({ default: m.CustomerDetailPage })));
|
||||
const QuotesPage = lazy(() => import('./pages/Quotes').then(m => ({ default: m.QuotesPage })));
|
||||
const QuoteDetailPage = lazy(() => import('./pages/QuoteDetail').then(m => ({ default: m.QuoteDetailPage })));
|
||||
const SettingsPage = lazy(() => import('./pages/Settings').then(m => ({ default: m.SettingsPage })));
|
||||
const DiagnosticsPage = lazy(() => import('./pages/Diagnostics').then(m => ({ default: m.DiagnosticsPage })));
|
||||
const DiagnosticsNewPage = lazy(() => import('./pages/DiagnosticsNew').then(m => ({ default: m.DiagnosticsNewPage })));
|
||||
const DiagnosticDetailPage = lazy(() => import('./pages/DiagnosticDetail').then(m => ({ default: m.DiagnosticDetailPage })));
|
||||
|
||||
// Loading fallback component
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create React Query client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Public route wrapper (redirect if already authenticated)
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||
<Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="orders" element={<ServiceOrdersPage />} />
|
||||
<Route path="orders/kanban" element={<ServiceOrdersKanbanPage />} />
|
||||
<Route path="orders/new" element={<ServiceOrderNewPage />} />
|
||||
<Route path="orders/:id" element={<ServiceOrderDetailPage />} />
|
||||
<Route path="diagnostics" element={<DiagnosticsPage />} />
|
||||
<Route path="diagnostics/new" element={<DiagnosticsNewPage />} />
|
||||
<Route path="diagnostics/:id" element={<DiagnosticDetailPage />} />
|
||||
<Route path="inventory" element={<InventoryPage />} />
|
||||
<Route path="inventory/:id" element={<InventoryDetailPage />} />
|
||||
<Route path="vehicles" element={<VehiclesPage />} />
|
||||
<Route path="vehicles/:id" element={<VehicleDetailPage />} />
|
||||
<Route path="customers" element={<CustomersPage />} />
|
||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||
<Route path="quotes" element={<QuotesPage />} />
|
||||
<Route path="quotes/:id" element={<QuoteDetailPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900">404</h1>
|
||||
<p className="text-gray-500">Pagina no encontrada</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<ToastContainer />
|
||||
</BrowserRouter>
|
||||
</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 |
50
src/components/layout/Header.tsx
Normal file
50
src/components/layout/Header.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Bell, Search } from 'lucide-react';
|
||||
import { useTallerStore } from '../../store/tallerStore';
|
||||
|
||||
export function Header() {
|
||||
const { selectedBay, workBays, setSelectedBay } = useTallerStore();
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-4 border-b border-gray-200 bg-white px-6">
|
||||
{/* Search */}
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative w-96">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar ordenes, vehiculos, clientes..."
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bay selector */}
|
||||
{workBays.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Bahia:</span>
|
||||
<select
|
||||
value={selectedBay?.id || ''}
|
||||
onChange={(e) => {
|
||||
const bay = workBays.find((b) => b.id === e.target.value);
|
||||
setSelectedBay(bay || null);
|
||||
}}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
>
|
||||
<option value="">Sin bahia</option>
|
||||
{workBays.map((bay) => (
|
||||
<option key={bay.id} value={bay.id}>
|
||||
{bay.name} ({bay.status === 'available' ? 'Disponible' : 'Ocupada'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<button className="relative rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-red-500" />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
23
src/components/layout/MainLayout.tsx
Normal file
23
src/components/layout/MainLayout.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
|
||||
export function MainLayout() {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<Header />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/layout/Sidebar.tsx
Normal file
135
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Wrench,
|
||||
Stethoscope,
|
||||
Package,
|
||||
Truck,
|
||||
FileText,
|
||||
Settings,
|
||||
LogOut,
|
||||
Users,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTallerStore } from '../../store/tallerStore';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Ordenes de Servicio', href: '/orders', icon: Wrench },
|
||||
{ name: 'Clientes', href: '/customers', icon: Building2 },
|
||||
{ name: 'Vehiculos', href: '/vehicles', icon: Truck },
|
||||
{ name: 'Inventario', href: '/inventory', icon: Package },
|
||||
{ name: 'Cotizaciones', href: '/quotes', icon: FileText },
|
||||
{ name: 'Diagnosticos', href: '/diagnostics', icon: Stethoscope },
|
||||
];
|
||||
|
||||
const secondaryNavigation = [
|
||||
{ name: 'Usuarios', href: '/users', icon: Users },
|
||||
{ name: 'Configuracion', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { logout, user } = useAuthStore();
|
||||
const { currentTaller } = useTallerStore();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-64 flex-col bg-gray-900">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 shrink-0 items-center px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-diesel-500 flex items-center justify-center">
|
||||
<Wrench className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-white">Mecanicas</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taller info */}
|
||||
{currentTaller && (
|
||||
<div className="px-4 py-2">
|
||||
<div className="rounded-lg bg-gray-800 px-3 py-2">
|
||||
<p className="text-xs text-gray-400">Taller</p>
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{currentTaller.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-1 flex-col px-4 py-4">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`group flex gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-diesel-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Secondary navigation */}
|
||||
<ul role="list" className="mt-auto space-y-1">
|
||||
{secondaryNavigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`group flex gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="group flex w-full gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<LogOut className="h-5 w-5 shrink-0" />
|
||||
Cerrar Sesion
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* User info */}
|
||||
{user && (
|
||||
<div className="mt-4 border-t border-gray-800 pt-4">
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user.full_name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user.full_name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/components/layout/index.ts
Normal file
3
src/components/layout/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { MainLayout } from './MainLayout';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { Header } from './Header';
|
||||
152
src/components/ui/LoadingSpinner.tsx
Normal file
152
src/components/ui/LoadingSpinner.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type SpinnerVariant = 'default' | 'primary' | 'white';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: SpinnerSize;
|
||||
variant?: SpinnerVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZE_CLASSES: Record<SpinnerSize, string> = {
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
xl: 'h-12 w-12',
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<SpinnerVariant, string> = {
|
||||
default: 'text-gray-500',
|
||||
primary: 'text-diesel-600',
|
||||
white: 'text-white',
|
||||
};
|
||||
|
||||
export function LoadingSpinner({
|
||||
size = 'md',
|
||||
variant = 'primary',
|
||||
className = '',
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<Loader2
|
||||
className={`animate-spin ${SIZE_CLASSES[size]} ${VARIANT_CLASSES[variant]} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Full page loading state
|
||||
interface PageLoaderProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function PageLoader({ message = 'Cargando...' }: PageLoaderProps) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="mt-3 text-sm text-gray-500">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline loading state (for buttons, etc.)
|
||||
interface InlineLoaderProps {
|
||||
size?: SpinnerSize;
|
||||
variant?: SpinnerVariant;
|
||||
}
|
||||
|
||||
export function InlineLoader({ size = 'sm', variant = 'primary' }: InlineLoaderProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<LoadingSpinner size={size} variant={variant} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Overlay loading state
|
||||
interface LoadingOverlayProps {
|
||||
isLoading: boolean;
|
||||
message?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ isLoading, message, children }: LoadingOverlayProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{children}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80">
|
||||
<LoadingSpinner size="lg" />
|
||||
{message && <p className="mt-3 text-sm text-gray-500">{message}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader for content placeholders
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: 'text' | 'circular' | 'rectangular';
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
className = '',
|
||||
variant = 'text',
|
||||
width,
|
||||
height,
|
||||
}: SkeletonProps) {
|
||||
const baseClasses = 'animate-pulse bg-gray-200';
|
||||
|
||||
const variantClasses = {
|
||||
text: 'rounded',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded-lg',
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
|
||||
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-configured skeleton rows
|
||||
export function SkeletonText({ lines = 3 }: { lines?: number }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
height={16}
|
||||
width={i === lines - 1 ? '60%' : '100%'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton variant="circular" width={48} height={48} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton variant="text" height={20} width="40%" />
|
||||
<Skeleton variant="text" height={16} width="60%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<SkeletonText lines={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
src/components/ui/Modal.tsx
Normal file
275
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
full: 'max-w-4xl',
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
}: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEscape) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, closeOnEscape, onClose]);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const modal = modalRef.current;
|
||||
if (!modal) return;
|
||||
|
||||
const focusableElements = modal.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstFocusable = focusableElements[0];
|
||||
const lastFocusable = focusableElements[focusableElements.length - 1];
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstFocusable) {
|
||||
e.preventDefault();
|
||||
lastFocusable?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusable) {
|
||||
e.preventDefault();
|
||||
firstFocusable?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleTab);
|
||||
firstFocusable?.focus();
|
||||
|
||||
return () => document.removeEventListener('keydown', handleTab);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (closeOnBackdrop && e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`w-full ${SIZE_CLASSES[size]} animate-in fade-in zoom-in-95 rounded-xl bg-white shadow-xl`}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
{title && (
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Confirmation Modal variant
|
||||
export interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES = {
|
||||
danger: 'bg-red-600 hover:bg-red-700',
|
||||
warning: 'bg-yellow-600 hover:bg-yellow-700',
|
||||
info: 'bg-diesel-600 hover:bg-diesel-700',
|
||||
};
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirmar',
|
||||
cancelText = 'Cancelar',
|
||||
variant = 'info',
|
||||
isLoading = false,
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${VARIANT_CLASSES[variant]}`}
|
||||
>
|
||||
{isLoading ? 'Procesando...' : confirmText}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Form Modal variant (useful for quick forms)
|
||||
export interface FormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
isLoading?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
}
|
||||
|
||||
export function FormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
children,
|
||||
submitText = 'Guardar',
|
||||
cancelText = 'Cancelar',
|
||||
isLoading = false,
|
||||
size = 'md',
|
||||
}: FormModalProps) {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size={size}
|
||||
closeOnBackdrop={!isLoading}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="modal-form"
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Guardando...' : submitText}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="modal-form" onSubmit={handleSubmit}>
|
||||
{children}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
71
src/components/ui/StatusBadge.tsx
Normal file
71
src/components/ui/StatusBadge.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'default' | 'primary';
|
||||
export type BadgeSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
default: 'bg-gray-100 text-gray-800',
|
||||
primary: 'bg-diesel-100 text-diesel-800',
|
||||
};
|
||||
|
||||
const DOT_CLASSES: Record<BadgeVariant, string> = {
|
||||
success: 'bg-green-500',
|
||||
warning: 'bg-yellow-500',
|
||||
error: 'bg-red-500',
|
||||
info: 'bg-blue-500',
|
||||
default: 'bg-gray-500',
|
||||
primary: 'bg-diesel-500',
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<BadgeSize, string> = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-0.5 text-xs',
|
||||
lg: 'px-2.5 py-1 text-sm',
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
children,
|
||||
icon,
|
||||
dot = false,
|
||||
}: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full font-medium ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]}`}
|
||||
>
|
||||
{dot && <span className={`h-1.5 w-1.5 rounded-full ${DOT_CLASSES[variant]}`} />}
|
||||
{icon && <span className="flex-shrink-0">{icon}</span>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-configured badges for common statuses
|
||||
export function SuccessBadge({ children, ...props }: Omit<StatusBadgeProps, 'variant'>) {
|
||||
return <StatusBadge variant="success" {...props}>{children}</StatusBadge>;
|
||||
}
|
||||
|
||||
export function WarningBadge({ children, ...props }: Omit<StatusBadgeProps, 'variant'>) {
|
||||
return <StatusBadge variant="warning" {...props}>{children}</StatusBadge>;
|
||||
}
|
||||
|
||||
export function ErrorBadge({ children, ...props }: Omit<StatusBadgeProps, 'variant'>) {
|
||||
return <StatusBadge variant="error" {...props}>{children}</StatusBadge>;
|
||||
}
|
||||
|
||||
export function InfoBadge({ children, ...props }: Omit<StatusBadgeProps, 'variant'>) {
|
||||
return <StatusBadge variant="info" {...props}>{children}</StatusBadge>;
|
||||
}
|
||||
95
src/components/ui/Toast.tsx
Normal file
95
src/components/ui/Toast.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { useToastStore, type Toast as ToastType, type ToastType as ToastVariant } from '../../store/toastStore';
|
||||
|
||||
const TOAST_CONFIG: Record<ToastVariant, { icon: typeof CheckCircle2; bgColor: string; borderColor: string; iconColor: string }> = {
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
iconColor: 'text-yellow-600',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
};
|
||||
|
||||
interface ToastItemProps {
|
||||
toast: ToastType;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onRemove }: ToastItemProps) {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const config = TOAST_CONFIG[toast.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
const handleRemove = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => onRemove(toast.id), 200);
|
||||
};
|
||||
|
||||
// Auto exit animation before removal
|
||||
useEffect(() => {
|
||||
if (toast.duration && toast.duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsExiting(true);
|
||||
}, toast.duration - 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast.duration]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex w-80 items-start gap-3 rounded-lg border p-4 shadow-lg transition-all duration-200 ${
|
||||
config.bgColor
|
||||
} ${config.borderColor} ${
|
||||
isExiting ? 'translate-x-full opacity-0' : 'translate-x-0 opacity-100'
|
||||
}`}
|
||||
role="alert"
|
||||
>
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 ${config.iconColor}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">{toast.title}</p>
|
||||
{toast.message && (
|
||||
<p className="mt-1 text-sm text-gray-600">{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="flex-shrink-0 rounded p-1 text-gray-400 hover:bg-gray-200/50 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const { toasts, removeToast } = useToastStore();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/index.ts
Normal file
24
src/components/ui/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export { Modal, ConfirmModal, FormModal } from './Modal';
|
||||
export type { ModalProps, ConfirmModalProps, FormModalProps } from './Modal';
|
||||
|
||||
export { ToastContainer } from './Toast';
|
||||
|
||||
export {
|
||||
StatusBadge,
|
||||
SuccessBadge,
|
||||
WarningBadge,
|
||||
ErrorBadge,
|
||||
InfoBadge,
|
||||
} from './StatusBadge';
|
||||
export type { BadgeVariant, BadgeSize } from './StatusBadge';
|
||||
|
||||
export {
|
||||
LoadingSpinner,
|
||||
PageLoader,
|
||||
InlineLoader,
|
||||
LoadingOverlay,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
SkeletonCard,
|
||||
} from './LoadingSpinner';
|
||||
export type { SpinnerSize, SpinnerVariant } from './LoadingSpinner';
|
||||
38
src/index.css
Normal file
38
src/index.css
Normal file
@ -0,0 +1,38 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
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>,
|
||||
)
|
||||
688
src/pages/CustomerDetail.tsx
Normal file
688
src/pages/CustomerDetail.tsx
Normal file
@ -0,0 +1,688 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Truck,
|
||||
FileText,
|
||||
DollarSign,
|
||||
Percent,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Wrench,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { customersApi, type Customer } from '../services/api/customers';
|
||||
import { vehiclesApi } from '../services/api/vehicles';
|
||||
import { serviceOrdersApi, type ServiceOrder } from '../services/api/serviceOrders';
|
||||
import { ConfirmModal } from '../components/ui';
|
||||
import { toast } from '../store/toastStore';
|
||||
import type { Vehicle } from '../types';
|
||||
|
||||
interface EditCustomerForm {
|
||||
name: string;
|
||||
code: string;
|
||||
contact_name: string;
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
discount_labor_pct: number;
|
||||
discount_parts_pct: number;
|
||||
credit_days: number;
|
||||
credit_limit: number;
|
||||
notes: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const ORDER_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800' },
|
||||
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800' },
|
||||
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800' },
|
||||
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800' },
|
||||
in_repair: { label: 'En Reparacion', color: 'bg-orange-100 text-orange-800' },
|
||||
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800' },
|
||||
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800' },
|
||||
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800' },
|
||||
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500' },
|
||||
};
|
||||
|
||||
export function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Fetch customer data
|
||||
const { data: customerData, isLoading, error } = useQuery({
|
||||
queryKey: ['customer', id],
|
||||
queryFn: () => customersApi.getById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const customer: Customer | undefined = customerData?.data;
|
||||
|
||||
// Fetch vehicles for this customer
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: ['customer-vehicles', id],
|
||||
queryFn: () => vehiclesApi.list({ customerId: id }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const vehicles: Vehicle[] = vehiclesData?.data?.data || [];
|
||||
|
||||
// Fetch service orders for this customer
|
||||
const { data: ordersData } = useQuery({
|
||||
queryKey: ['customer-orders', id],
|
||||
queryFn: () => serviceOrdersApi.list({ customer_id: id, pageSize: 50 }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const orders: ServiceOrder[] = ordersData?.data?.data || [];
|
||||
|
||||
// Form setup
|
||||
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<EditCustomerForm>();
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: EditCustomerForm) => customersApi.update(id!, {
|
||||
name: data.name,
|
||||
code: data.code || undefined,
|
||||
contact_name: data.contact_name || undefined,
|
||||
contact_email: data.contact_email || undefined,
|
||||
contact_phone: data.contact_phone || undefined,
|
||||
discount_labor_pct: data.discount_labor_pct,
|
||||
discount_parts_pct: data.discount_parts_pct,
|
||||
credit_days: data.credit_days,
|
||||
credit_limit: data.credit_limit,
|
||||
notes: data.notes || undefined,
|
||||
is_active: data.is_active,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customer', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
setIsEditing(false);
|
||||
toast.success('Cliente actualizado', 'Los cambios se guardaron correctamente');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al guardar', 'No se pudieron guardar los cambios');
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => customersApi.delete(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
toast.success('Cliente eliminado');
|
||||
navigate('/customers');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al eliminar', 'No se pudo eliminar el cliente');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
if (customer) {
|
||||
reset({
|
||||
name: customer.name,
|
||||
code: customer.code || '',
|
||||
contact_name: customer.contact_name || '',
|
||||
contact_email: customer.contact_email || '',
|
||||
contact_phone: customer.contact_phone || '',
|
||||
discount_labor_pct: customer.discount_labor_pct,
|
||||
discount_parts_pct: customer.discount_parts_pct,
|
||||
credit_days: customer.credit_days,
|
||||
credit_limit: customer.credit_limit,
|
||||
notes: customer.notes || '',
|
||||
is_active: customer.is_active,
|
||||
});
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = (data: EditCustomerForm) => {
|
||||
updateMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !customer) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Cliente no encontrado</p>
|
||||
<Link to="/customers" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||
Volver a clientes
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const totalSpent = orders
|
||||
.filter(o => o.status === 'delivered')
|
||||
.reduce((sum, o) => sum + o.grand_total, 0);
|
||||
const pendingOrders = orders.filter(o => !['delivered', 'cancelled'].includes(o.status)).length;
|
||||
const completedOrders = orders.filter(o => o.status === 'delivered').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/customers"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{customer.name}</h1>
|
||||
{customer.is_active ? (
|
||||
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
Activo
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
Inactivo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{customer.code && (
|
||||
<p className="text-sm text-gray-500">Codigo: {customer.code}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Eliminar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={!isDirty || updateMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Guardar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Truck className="h-4 w-4" />
|
||||
Vehiculos
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900">{vehicles.length}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<FileText className="h-4 w-4" />
|
||||
Ordenes Activas
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-orange-600">{pendingOrders}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Completadas
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-green-600">{completedOrders}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Total Facturado
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-diesel-600">
|
||||
${totalSpent.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Customer Info */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Building2 className="h-5 w-5 text-diesel-600" />
|
||||
Informacion del Cliente
|
||||
</h2>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Nombre / Razon Social</label>
|
||||
<input
|
||||
{...register('name', { required: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Codigo</label>
|
||||
<input
|
||||
{...register('code')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
{...register('is_active')}
|
||||
className="h-4 w-4 rounded border-gray-300 text-diesel-600 focus:ring-diesel-500"
|
||||
/>
|
||||
<label htmlFor="is_active" className="text-sm font-medium text-gray-700">
|
||||
Cliente Activo
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Nombre Contacto</label>
|
||||
<input
|
||||
{...register('contact_name')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Telefono</label>
|
||||
<input
|
||||
{...register('contact_phone')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('contact_email')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
{customer.contact_name && (
|
||||
<div className="flex items-start gap-2">
|
||||
<User className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Contacto</p>
|
||||
<p className="text-sm text-gray-900">{customer.contact_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{customer.contact_phone && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Phone className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Telefono</p>
|
||||
<p className="text-sm text-gray-900">{customer.contact_phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{customer.contact_email && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Mail className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Email</p>
|
||||
<p className="text-sm text-gray-900">{customer.contact_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Cliente desde</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{new Date(customer.created_at).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{customer.notes && (
|
||||
<div className="col-span-full">
|
||||
<p className="text-xs font-medium text-gray-500">Notas</p>
|
||||
<p className="text-sm text-gray-600">{customer.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vehicles */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Truck className="h-5 w-5 text-diesel-600" />
|
||||
Vehiculos ({vehicles.length})
|
||||
</h2>
|
||||
<Link
|
||||
to={`/vehicles?customerId=${customer.id}`}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Agregar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{vehicles.length === 0 ? (
|
||||
<div className="flex h-24 flex-col items-center justify-center text-gray-400">
|
||||
<Truck className="mb-2 h-8 w-8" />
|
||||
<p className="text-sm">Sin vehiculos registrados</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{vehicles.slice(0, 5).map((vehicle) => (
|
||||
<Link
|
||||
key={vehicle.id}
|
||||
to={`/vehicles/${vehicle.id}`}
|
||||
className="-mx-2 flex items-center justify-between rounded-lg px-2 py-3 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-diesel-50">
|
||||
<Truck className="h-5 w-5 text-diesel-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{vehicle.make} {vehicle.model} {vehicle.year}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{vehicle.licensePlate}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
vehicle.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{vehicle.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{vehicles.length > 5 && (
|
||||
<div className="pt-3 text-center">
|
||||
<Link to={`/vehicles?customerId=${customer.id}`} className="text-sm text-diesel-600 hover:text-diesel-700">
|
||||
Ver todos ({vehicles.length})
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service History */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Wrench className="h-5 w-5 text-diesel-600" />
|
||||
Historial de Servicio
|
||||
</h2>
|
||||
<Link
|
||||
to={`/orders/new?customerId=${customer.id}`}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva Orden
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="flex h-24 flex-col items-center justify-center text-gray-400">
|
||||
<FileText className="mb-2 h-8 w-8" />
|
||||
<p className="text-sm">Sin historial de servicio</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{orders.slice(0, 10).map((order) => {
|
||||
const statusConfig = ORDER_STATUS_CONFIG[order.status] || ORDER_STATUS_CONFIG.received;
|
||||
return (
|
||||
<Link
|
||||
key={order.id}
|
||||
to={`/orders/${order.id}`}
|
||||
className="-mx-2 flex items-center justify-between rounded-lg px-2 py-3 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-diesel-50">
|
||||
<Wrench className="h-5 w-5 text-diesel-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{order.order_number}</p>
|
||||
<p className="text-xs text-gray-500">{order.vehicle_info}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<p className="mt-1 text-sm font-medium text-gray-900">
|
||||
${order.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{orders.length > 10 && (
|
||||
<div className="pt-3 text-center">
|
||||
<Link to={`/orders?customerId=${customer.id}`} className="text-sm text-diesel-600 hover:text-diesel-700">
|
||||
Ver todas ({orders.length})
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Commercial Terms */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<CreditCard className="h-5 w-5 text-diesel-600" />
|
||||
Condiciones Comerciales
|
||||
</h2>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Desc. Mano de Obra (%)</label>
|
||||
<div className="relative">
|
||||
<Percent className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('discount_labor_pct', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Desc. Refacciones (%)</label>
|
||||
<div className="relative">
|
||||
<Percent className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('discount_parts_pct', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Dias de Credito</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('credit_days', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Limite de Credito</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('credit_limit', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Percent className="h-4 w-4" />
|
||||
Desc. Mano de Obra
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{customer.discount_labor_pct}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Percent className="h-4 w-4" />
|
||||
Desc. Refacciones
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{customer.discount_parts_pct}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4" />
|
||||
Dias de Credito
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{customer.credit_days} dias</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Limite de Credito
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
${customer.credit_limit.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||
Resumen Financiero
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Total Facturado</span>
|
||||
<span className="text-sm font-bold text-diesel-600">
|
||||
${totalSpent.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Ticket Promedio</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
${completedOrders > 0 ? (totalSpent / completedOrders).toLocaleString('es-MX', { minimumFractionDigits: 2 }) : '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Ordenes Totales</span>
|
||||
<span className="text-sm font-medium text-gray-900">{orders.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Indicator */}
|
||||
<div className="mt-4 border-t border-gray-100 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{pendingOrders > 0 ? (
|
||||
<>
|
||||
<Clock className="h-5 w-5 text-orange-500" />
|
||||
<span className="text-sm font-medium text-orange-600">{pendingOrders} ordenes pendientes</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-600">Sin ordenes pendientes</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
title="Eliminar Cliente"
|
||||
message={`¿Estas seguro de eliminar a "${customer.name}"? Esta accion no se puede deshacer y eliminara toda la informacion asociada.`}
|
||||
confirmText="Eliminar"
|
||||
variant="danger"
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
473
src/pages/Customers.tsx
Normal file
473
src/pages/Customers.tsx
Normal file
@ -0,0 +1,473 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Building2,
|
||||
Plus,
|
||||
Search,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Phone,
|
||||
Mail,
|
||||
Truck,
|
||||
CreditCard,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { customersApi } from '../services/api/customers';
|
||||
import type { Customer, CustomerFilters } from '../services/api/customers';
|
||||
|
||||
const customerSchema = z.object({
|
||||
name: z.string().min(1, 'Nombre requerido'),
|
||||
code: z.string().optional(),
|
||||
contact_name: z.string().optional(),
|
||||
contact_email: z.string().email('Email invalido').optional().or(z.literal('')),
|
||||
contact_phone: z.string().optional(),
|
||||
discount_labor_pct: z.number().min(0).max(100).optional(),
|
||||
discount_parts_pct: z.number().min(0).max(100).optional(),
|
||||
credit_days: z.number().min(0).optional(),
|
||||
credit_limit: z.number().min(0).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
type CustomerForm = z.infer<typeof customerSchema>;
|
||||
|
||||
export function CustomersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filters, setFilters] = useState<CustomerFilters>({ page: 1, pageSize: 20 });
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CustomerForm>({
|
||||
resolver: zodResolver(customerSchema),
|
||||
defaultValues: {
|
||||
discount_labor_pct: 0,
|
||||
discount_parts_pct: 0,
|
||||
credit_days: 0,
|
||||
credit_limit: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch customers
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['customers', filters],
|
||||
queryFn: () => customersApi.list(filters),
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CustomerForm) => customersApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
handleCloseModal();
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<CustomerForm> }) =>
|
||||
customersApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
handleCloseModal();
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => customersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const customers = data?.data?.data || [];
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingCustomer(null);
|
||||
reset({
|
||||
discount_labor_pct: 0,
|
||||
discount_parts_pct: 0,
|
||||
credit_days: 0,
|
||||
credit_limit: 0,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (customer: Customer) => {
|
||||
setEditingCustomer(customer);
|
||||
reset({
|
||||
name: customer.name,
|
||||
code: customer.code,
|
||||
contact_name: customer.contact_name,
|
||||
contact_email: customer.contact_email,
|
||||
contact_phone: customer.contact_phone,
|
||||
discount_labor_pct: customer.discount_labor_pct,
|
||||
discount_parts_pct: customer.discount_parts_pct,
|
||||
credit_days: customer.credit_days,
|
||||
credit_limit: customer.credit_limit,
|
||||
notes: customer.notes,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingCustomer(null);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = (data: CustomerForm) => {
|
||||
// Clean empty email
|
||||
const cleanData = {
|
||||
...data,
|
||||
contact_email: data.contact_email || undefined,
|
||||
};
|
||||
|
||||
if (editingCustomer) {
|
||||
updateMutation.mutate({ id: editingCustomer.id, data: cleanData });
|
||||
} else {
|
||||
createMutation.mutate(cleanData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (customer: Customer) => {
|
||||
if (confirm(`Eliminar cliente ${customer.name}?`)) {
|
||||
deleteMutation.mutate(customer.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
|
||||
<p className="text-sm text-gray-500">Gestiona los clientes y flotas</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nuevo Cliente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre, codigo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="col-span-full flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar clientes</p>
|
||||
</div>
|
||||
) : customers.length === 0 ? (
|
||||
<div className="col-span-full flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<Building2 className="mb-2 h-8 w-8" />
|
||||
<p>No hay clientes registrados</p>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Registrar primer cliente
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
customers.map((customer: Customer) => (
|
||||
<div
|
||||
key={customer.id}
|
||||
className="rounded-xl border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<Link to={`/customers/${customer.id}`} className="flex items-center gap-3 hover:opacity-80">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-diesel-100">
|
||||
<Building2 className="h-6 w-6 text-diesel-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{customer.name}</h3>
|
||||
{customer.code && (
|
||||
<p className="text-sm text-gray-500">{customer.code}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(customer)}
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(customer)}
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{customer.contact_name && (
|
||||
<p className="text-sm text-gray-600">{customer.contact_name}</p>
|
||||
)}
|
||||
{customer.contact_phone && (
|
||||
<p className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
{customer.contact_phone}
|
||||
</p>
|
||||
)}
|
||||
{customer.contact_email && (
|
||||
<p className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{customer.contact_email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between border-t border-gray-100 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<Truck className="h-4 w-4" />
|
||||
<span>{customer.vehicle_count}</span>
|
||||
</div>
|
||||
{customer.credit_limit > 0 && (
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
<span>${customer.credit_limit.toLocaleString('es-MX')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/customers/${customer.id}`}
|
||||
className="flex items-center gap-1 text-sm font-medium text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Ver detalle
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data && customers.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Pagina {data.data.page} de {data.data.totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||
disabled={(filters.page || 1) <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingCustomer ? 'Editar Cliente' : 'Nuevo Cliente'}
|
||||
</h2>
|
||||
<button onClick={handleCloseModal} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Nombre de Empresa *
|
||||
</label>
|
||||
<input
|
||||
{...register('name')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Transportes del Norte SA"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Codigo</label>
|
||||
<input
|
||||
{...register('code')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="TDN-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Nombre Contacto
|
||||
</label>
|
||||
<input
|
||||
{...register('contact_name')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Juan Perez"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Telefono</label>
|
||||
<input
|
||||
{...register('contact_phone')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="81 1234 5678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('contact_email')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="contacto@empresa.com"
|
||||
/>
|
||||
{errors.contact_email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.contact_email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="mb-3 text-sm font-medium text-gray-700">Condiciones Comerciales</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Descuento Mano de Obra (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('discount_labor_pct', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Descuento Refacciones (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('discount_parts_pct', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Dias de Credito
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('credit_days', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Limite de Credito
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('credit_limit', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{(createMutation.isPending || updateMutation.isPending) && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{editingCustomer ? 'Guardar Cambios' : 'Crear Cliente'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
307
src/pages/Dashboard.tsx
Normal file
307
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,307 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Wrench,
|
||||
Truck,
|
||||
Package,
|
||||
FileText,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||
import { partsApi } from '../services/api/parts';
|
||||
import { customersApi } from '../services/api/customers';
|
||||
import type { ServiceOrder, DashboardStats } from '../services/api/serviceOrders';
|
||||
import type { ServiceOrderStatus, Part } from '../types';
|
||||
|
||||
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string }> = {
|
||||
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800' },
|
||||
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800' },
|
||||
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800' },
|
||||
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800' },
|
||||
in_repair: { label: 'En Reparacion', color: 'bg-orange-100 text-orange-800' },
|
||||
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800' },
|
||||
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800' },
|
||||
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800' },
|
||||
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500' },
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
// Fetch dashboard stats
|
||||
const { data: statsData, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: () => serviceOrdersApi.getStats(),
|
||||
});
|
||||
|
||||
// Fetch recent orders
|
||||
const { data: ordersData, isLoading: ordersLoading } = useQuery({
|
||||
queryKey: ['recent-orders'],
|
||||
queryFn: () => serviceOrdersApi.list({ page: 1, pageSize: 5 }),
|
||||
});
|
||||
|
||||
// Fetch low stock parts
|
||||
const { data: lowStockData } = useQuery({
|
||||
queryKey: ['low-stock-parts'],
|
||||
queryFn: () => partsApi.getLowStock(),
|
||||
});
|
||||
|
||||
// Fetch customer count
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers-count'],
|
||||
queryFn: () => customersApi.list({ pageSize: 1 }),
|
||||
});
|
||||
|
||||
const stats: DashboardStats = statsData?.data || {
|
||||
totalOrders: 0,
|
||||
pendingOrders: 0,
|
||||
inProgressOrders: 0,
|
||||
completedToday: 0,
|
||||
totalRevenue: 0,
|
||||
averageTicket: 0,
|
||||
};
|
||||
|
||||
const recentOrders = ordersData?.data?.data || [];
|
||||
const lowStockParts: Part[] = lowStockData?.data || [];
|
||||
const totalCustomers = customersData?.data?.total || 0;
|
||||
|
||||
const statCards = [
|
||||
{ name: 'Ordenes Activas', value: stats.pendingOrders + stats.inProgressOrders, icon: Wrench, color: 'bg-blue-500' },
|
||||
{ name: 'En Proceso', value: stats.inProgressOrders, icon: Clock, color: 'bg-orange-500' },
|
||||
{ name: 'Completadas Hoy', value: stats.completedToday, icon: CheckCircle, color: 'bg-green-500' },
|
||||
{ name: 'Total Clientes', value: totalCustomers, icon: Users, color: 'bg-purple-500' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500">Resumen de operaciones del taller</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{statCards.map((stat) => (
|
||||
<div
|
||||
key={stat.name}
|
||||
className="flex items-center gap-4 rounded-lg bg-white p-4 shadow-sm"
|
||||
>
|
||||
<div className={`rounded-lg ${stat.color} p-3`}>
|
||||
<stat.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
{statsLoading ? (
|
||||
<div className="h-8 w-12 animate-pulse rounded bg-gray-200" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">{stat.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Revenue stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
|
||||
<div className="rounded-lg bg-emerald-500 p-3">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Ingresos Totales</p>
|
||||
{statsLoading ? (
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-gray-200" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
${stats.totalRevenue.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
|
||||
<div className="rounded-lg bg-indigo-500 p-3">
|
||||
<Truck className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Ticket Promedio</p>
|
||||
{statsLoading ? (
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-gray-200" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
${stats.averageTicket.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content grid */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Recent orders */}
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Ordenes Recientes</h2>
|
||||
<Link to="/orders" className="text-sm text-diesel-600 hover:text-diesel-700">
|
||||
Ver todas
|
||||
</Link>
|
||||
</div>
|
||||
{ordersLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-lg bg-gray-100" />
|
||||
))}
|
||||
</div>
|
||||
) : recentOrders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<Wrench className="mb-2 h-8 w-8" />
|
||||
<p>No hay ordenes recientes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentOrders.map((order: ServiceOrder) => {
|
||||
const statusConfig = STATUS_CONFIG[order.status as ServiceOrderStatus] || STATUS_CONFIG.received;
|
||||
return (
|
||||
<Link
|
||||
to={`/orders/${order.id}`}
|
||||
key={order.id}
|
||||
className="flex items-center justify-between rounded-lg border border-gray-100 p-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<Truck className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{order.order_number}</p>
|
||||
<p className="text-sm text-gray-500">{order.vehicle_info}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 mt-1">{order.customer_name}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Acciones Rapidas
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link
|
||||
to="/orders/new"
|
||||
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors"
|
||||
>
|
||||
<Wrench className="h-8 w-8 text-diesel-600" />
|
||||
<span className="text-sm font-medium text-gray-700">Nueva Orden</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/diagnostics/new"
|
||||
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors"
|
||||
>
|
||||
<TrendingUp className="h-8 w-8 text-diesel-600" />
|
||||
<span className="text-sm font-medium text-gray-700">Nuevo Diagnostico</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/vehicles"
|
||||
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors"
|
||||
>
|
||||
<Truck className="h-8 w-8 text-diesel-600" />
|
||||
<span className="text-sm font-medium text-gray-700">Ver Vehiculos</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/inventory"
|
||||
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors"
|
||||
>
|
||||
<Package className="h-8 w-8 text-diesel-600" />
|
||||
<span className="text-sm font-medium text-gray-700">Ver Inventario</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Low Stock Alert */}
|
||||
{lowStockParts.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
<span className="font-medium text-yellow-800">Stock Bajo ({lowStockParts.length})</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{lowStockParts.slice(0, 3).map((part) => (
|
||||
<Link
|
||||
key={part.id}
|
||||
to={`/inventory/${part.id}`}
|
||||
className="flex items-center justify-between text-sm hover:bg-yellow-100 rounded px-2 py-1 -mx-2"
|
||||
>
|
||||
<span className="text-yellow-800">{part.name}</span>
|
||||
<span className="font-medium text-yellow-900">
|
||||
{part.currentStock} / {part.minStock} {part.unit}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{lowStockParts.length > 3 && (
|
||||
<Link to="/inventory?lowStock=true" className="text-sm text-yellow-700 hover:text-yellow-800">
|
||||
Ver todos ({lowStockParts.length})
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Today's schedule */}
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm lg:col-span-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Resumen del Dia
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="h-5 w-5 text-yellow-500" />
|
||||
<span className="font-medium text-gray-900">Pendientes</span>
|
||||
</div>
|
||||
{statsLoading ? (
|
||||
<div className="h-9 w-12 animate-pulse rounded bg-gray-200" />
|
||||
) : (
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.pendingOrders}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">ordenes por iniciar</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wrench className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-medium text-gray-900">En Proceso</span>
|
||||
</div>
|
||||
{statsLoading ? (
|
||||
<div className="h-9 w-12 animate-pulse rounded bg-gray-200" />
|
||||
) : (
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.inProgressOrders}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">ordenes en reparacion</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<span className="font-medium text-gray-900">Completadas Hoy</span>
|
||||
</div>
|
||||
{statsLoading ? (
|
||||
<div className="h-9 w-12 animate-pulse rounded bg-gray-200" />
|
||||
) : (
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.completedToday}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">ordenes entregadas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
402
src/pages/DiagnosticDetail.tsx
Normal file
402
src/pages/DiagnosticDetail.tsx
Normal file
@ -0,0 +1,402 @@
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Stethoscope,
|
||||
Truck,
|
||||
Calendar,
|
||||
User,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Cog,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Printer,
|
||||
Trash2,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { diagnosticsApi } from '../services/api/diagnostics';
|
||||
import type { Diagnostic, DiagnosticResult, DiagnosticItem } from '../services/api/diagnostics';
|
||||
import type { DiagnosticType } from '../types';
|
||||
|
||||
const DIAGNOSTIC_TYPES: Record<DiagnosticType, { label: string; icon: typeof Cpu }> = {
|
||||
obd_scanner: { label: 'Scanner OBD', icon: Cpu },
|
||||
injector_bench: { label: 'Banco Inyectores', icon: Gauge },
|
||||
pump_bench: { label: 'Banco Bomba', icon: Cog },
|
||||
measurements: { label: 'Mediciones', icon: Stethoscope },
|
||||
};
|
||||
|
||||
const RESULT_CONFIG: Record<DiagnosticResult, { label: string; color: string; bgColor: string; icon: typeof CheckCircle2 }> = {
|
||||
pass: { label: 'Aprobado', color: 'text-green-700', bgColor: 'bg-green-100', icon: CheckCircle2 },
|
||||
fail: { label: 'Fallo', color: 'text-red-700', bgColor: 'bg-red-100', icon: XCircle },
|
||||
needs_attention: { label: 'Requiere Atencion', color: 'text-yellow-700', bgColor: 'bg-yellow-100', icon: AlertTriangle },
|
||||
};
|
||||
|
||||
export function DiagnosticDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch diagnostic
|
||||
const { data: diagnosticData, isLoading, error } = useQuery({
|
||||
queryKey: ['diagnostic', id],
|
||||
queryFn: () => diagnosticsApi.getById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const diagnostic: Diagnostic | undefined = diagnosticData?.data;
|
||||
|
||||
// Fetch diagnostic items
|
||||
const { data: itemsData } = useQuery({
|
||||
queryKey: ['diagnostic-items', id],
|
||||
queryFn: () => diagnosticsApi.getItems(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const items: DiagnosticItem[] = itemsData?.data || [];
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => diagnosticsApi.delete(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['diagnostics'] });
|
||||
navigate('/diagnostics');
|
||||
},
|
||||
});
|
||||
|
||||
// Update result mutation
|
||||
const updateResultMutation = useMutation({
|
||||
mutationFn: ({ result, summary }: { result: DiagnosticResult; summary?: string }) =>
|
||||
diagnosticsApi.setResult(id!, result, summary),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['diagnostic', id] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('¿Seguro que desea eliminar este diagnostico?')) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetResult = (result: DiagnosticResult) => {
|
||||
updateResultMutation.mutate({ result });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !diagnostic) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar el diagnostico</p>
|
||||
<Link to="/diagnostics" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||
Volver a diagnosticos
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const typeConfig = DIAGNOSTIC_TYPES[diagnostic.diagnostic_type];
|
||||
const TypeIcon = typeConfig?.icon || Stethoscope;
|
||||
const resultConfig = diagnostic.result ? RESULT_CONFIG[diagnostic.result] : null;
|
||||
const ResultIcon = resultConfig?.icon;
|
||||
|
||||
// Calculate item statistics
|
||||
const passCount = items.filter((i: DiagnosticItem) => i.result === 'pass').length;
|
||||
const failCount = items.filter((i: DiagnosticItem) => i.result === 'fail').length;
|
||||
const attentionCount = items.filter((i: DiagnosticItem) => i.result === 'needs_attention').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/diagnostics"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Diagnostico</h1>
|
||||
<p className="text-sm text-gray-500">{typeConfig?.label || diagnostic.diagnostic_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Imprimir
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Vehicle Info */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Truck className="h-5 w-5 text-diesel-600" />
|
||||
Vehiculo
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gray-100">
|
||||
<Truck className="h-7 w-7 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">{diagnostic.vehicle_info}</p>
|
||||
<p className="text-gray-500">{diagnostic.customer_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagnostic.order_number && (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-lg bg-gray-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">Orden de Servicio:</span>
|
||||
<Link
|
||||
to={`/orders/${diagnostic.order_id}`}
|
||||
className="font-medium text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
{diagnostic.order_number}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diagnostic Items */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<TypeIcon className="h-5 w-5 text-diesel-600" />
|
||||
Pruebas Realizadas
|
||||
</h2>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle2 className="h-4 w-4" /> {passCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-red-600">
|
||||
<XCircle className="h-4 w-4" /> {failCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-yellow-600">
|
||||
<AlertTriangle className="h-4 w-4" /> {attentionCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-lg bg-gray-50 p-8 text-center text-gray-500">
|
||||
<Stethoscope className="mx-auto mb-2 h-8 w-8" />
|
||||
<p>No hay pruebas registradas</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Componente
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Prueba
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Valor
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Rango
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Resultado
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{items.map((item: DiagnosticItem) => {
|
||||
const itemResultConfig = RESULT_CONFIG[item.result];
|
||||
const ItemIcon = itemResultConfig.icon;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td className="whitespace-nowrap px-4 py-3 font-medium text-gray-900">
|
||||
{item.component}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-gray-700">
|
||||
{item.test_name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-gray-700">
|
||||
{item.value} {item.unit}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{item.min_value !== null && item.max_value !== null
|
||||
? `${item.min_value} - ${item.max_value} ${item.unit || ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${itemResultConfig.bgColor} ${itemResultConfig.color}`}
|
||||
>
|
||||
<ItemIcon className="h-3 w-3" />
|
||||
{itemResultConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.some((i: DiagnosticItem) => i.notes) && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-700">Notas</h3>
|
||||
{items
|
||||
.filter((i: DiagnosticItem) => i.notes)
|
||||
.map((item: DiagnosticItem) => (
|
||||
<div key={item.id} className="rounded-lg bg-gray-50 p-3 text-sm">
|
||||
<span className="font-medium text-gray-700">{item.component}:</span>{' '}
|
||||
<span className="text-gray-600">{item.notes}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Result */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">Resultado General</h2>
|
||||
|
||||
{diagnostic.result ? (
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg p-4 ${resultConfig?.bgColor}`}
|
||||
>
|
||||
{ResultIcon && <ResultIcon className={`h-6 w-6 ${resultConfig?.color}`} />}
|
||||
<span className={`text-lg font-semibold ${resultConfig?.color}`}>
|
||||
{resultConfig?.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 p-4 text-center text-gray-500">
|
||||
Pendiente de evaluacion
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">Establecer Resultado:</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.entries(RESULT_CONFIG).map(([key, config]) => {
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleSetResult(key as DiagnosticResult)}
|
||||
disabled={updateResultMutation.isPending}
|
||||
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-xs transition-colors ${
|
||||
diagnostic.result === key
|
||||
? `${config.bgColor} ${config.color} border-current`
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{config.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">Detalles</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<TypeIcon className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Tipo</p>
|
||||
<p className="font-medium text-gray-900">{typeConfig?.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagnostic.equipment && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Cpu className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Equipo</p>
|
||||
<p className="font-medium text-gray-900">{diagnostic.equipment}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Calendar className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Fecha</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{new Date(diagnostic.performed_at).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagnostic.technician_name && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<User className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Tecnico</p>
|
||||
<p className="font-medium text-gray-900">{diagnostic.technician_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{diagnostic.summary && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">Observaciones</h2>
|
||||
<p className="text-gray-700">{diagnostic.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
src/pages/Diagnostics.tsx
Normal file
279
src/pages/Diagnostics.tsx
Normal file
@ -0,0 +1,279 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Stethoscope,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Truck,
|
||||
Calendar,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Cog,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { diagnosticsApi } from '../services/api/diagnostics';
|
||||
import type { Diagnostic, DiagnosticResult, DiagnosticFilters } from '../services/api/diagnostics';
|
||||
import type { DiagnosticType } from '../types';
|
||||
|
||||
const DIAGNOSTIC_TYPES: { value: DiagnosticType; label: string; icon: typeof Cpu }[] = [
|
||||
{ value: 'obd_scanner', label: 'Scanner OBD', icon: Cpu },
|
||||
{ value: 'injector_bench', label: 'Banco Inyectores', icon: Gauge },
|
||||
{ value: 'pump_bench', label: 'Banco Bomba', icon: Cog },
|
||||
{ value: 'measurements', label: 'Mediciones', icon: Stethoscope },
|
||||
];
|
||||
|
||||
const RESULT_CONFIG: Record<DiagnosticResult, { label: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||
pass: { label: 'Aprobado', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
|
||||
fail: { label: 'Fallo', color: 'bg-red-100 text-red-700', icon: XCircle },
|
||||
needs_attention: { label: 'Requiere Atencion', color: 'bg-yellow-100 text-yellow-700', icon: AlertTriangle },
|
||||
};
|
||||
|
||||
export function DiagnosticsPage() {
|
||||
const [filters, setFilters] = useState<DiagnosticFilters>({ page: 1, pageSize: 20 });
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Fetch diagnostics
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['diagnostics', filters],
|
||||
queryFn: () => diagnosticsApi.list(filters),
|
||||
});
|
||||
|
||||
const diagnostics = data?.data?.data || [];
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
const handleTypeFilter = (type: DiagnosticType | undefined) => {
|
||||
setFilters({ ...filters, diagnostic_type: type, page: 1 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Diagnosticos</h1>
|
||||
<p className="text-sm text-gray-500">Pruebas y analisis de vehiculos</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/diagnostics/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nuevo Diagnostico
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Type Filters */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{DIAGNOSTIC_TYPES.map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<button
|
||||
key={type.value}
|
||||
onClick={() => handleTypeFilter(filters.diagnostic_type === type.value ? undefined : type.value)}
|
||||
className={`flex items-center gap-3 rounded-lg border p-4 transition-colors ${
|
||||
filters.diagnostic_type === type.value
|
||||
? 'border-diesel-500 bg-diesel-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${filters.diagnostic_type === type.value ? 'text-diesel-600' : 'text-gray-400'}`} />
|
||||
<span className="text-sm font-medium text-gray-700">{type.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por vehiculo, cliente..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<select
|
||||
value={filters.result || ''}
|
||||
onChange={(e) => setFilters({ ...filters, result: e.target.value as DiagnosticResult || undefined, page: 1 })}
|
||||
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los resultados</option>
|
||||
{Object.entries(RESULT_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar diagnosticos</p>
|
||||
</div>
|
||||
) : diagnostics.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<Stethoscope className="mb-2 h-8 w-8" />
|
||||
<p>No hay diagnosticos registrados</p>
|
||||
<Link
|
||||
to="/diagnostics/new"
|
||||
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Realizar primer diagnostico
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Vehiculo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Resultado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Orden
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Fecha
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{diagnostics.map((diagnostic: Diagnostic) => {
|
||||
const typeConfig = DIAGNOSTIC_TYPES.find(t => t.value === diagnostic.diagnostic_type);
|
||||
const TypeIcon = typeConfig?.icon || Stethoscope;
|
||||
const resultConfig = diagnostic.result ? RESULT_CONFIG[diagnostic.result] : null;
|
||||
const ResultIcon = resultConfig?.icon;
|
||||
|
||||
return (
|
||||
<tr key={diagnostic.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Truck className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{diagnostic.vehicle_info}</p>
|
||||
<p className="text-sm text-gray-500">{diagnostic.customer_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-700">{typeConfig?.label || diagnostic.diagnostic_type}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
{resultConfig ? (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${resultConfig.color}`}>
|
||||
{ResultIcon && <ResultIcon className="h-3 w-3" />}
|
||||
{resultConfig.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">Pendiente</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
{diagnostic.order_number ? (
|
||||
<Link
|
||||
to={`/orders/${diagnostic.order_id}`}
|
||||
className="text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
{diagnostic.order_number}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{new Date(diagnostic.performed_at).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||
<Link
|
||||
to={`/diagnostics/${diagnostic.id}`}
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data && diagnostics.length > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
Pagina {data.data.page} de {data.data.totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||
disabled={(filters.page || 1) <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
526
src/pages/DiagnosticsNew.tsx
Normal file
526
src/pages/DiagnosticsNew.tsx
Normal file
@ -0,0 +1,526 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Stethoscope,
|
||||
Truck,
|
||||
Search,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Cog,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { diagnosticsApi } from '../services/api/diagnostics';
|
||||
import { vehiclesApi } from '../services/api/vehicles';
|
||||
import type { DiagnosticResult } from '../services/api/diagnostics';
|
||||
import type { DiagnosticType, Vehicle } from '../types';
|
||||
|
||||
const diagnosticSchema = z.object({
|
||||
vehicle_id: z.string().min(1, 'Seleccione un vehiculo'),
|
||||
order_id: z.string().optional(),
|
||||
diagnostic_type: z.enum(['obd_scanner', 'injector_bench', 'pump_bench', 'measurements']),
|
||||
equipment: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
type DiagnosticFormData = z.infer<typeof diagnosticSchema>;
|
||||
|
||||
interface DiagnosticItemForm {
|
||||
component: string;
|
||||
test_name: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
min_value: string;
|
||||
max_value: string;
|
||||
result: DiagnosticResult;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const DIAGNOSTIC_TYPES: { value: DiagnosticType; label: string; description: string; icon: typeof Cpu }[] = [
|
||||
{ value: 'obd_scanner', label: 'Scanner OBD', description: 'Lectura de codigos de falla', icon: Cpu },
|
||||
{ value: 'injector_bench', label: 'Banco Inyectores', description: 'Prueba de inyectores diesel', icon: Gauge },
|
||||
{ value: 'pump_bench', label: 'Banco Bomba', description: 'Prueba de bomba de inyeccion', icon: Cog },
|
||||
{ value: 'measurements', label: 'Mediciones', description: 'Mediciones electricas y mecanicas', icon: Stethoscope },
|
||||
];
|
||||
|
||||
const RESULT_OPTIONS: { value: DiagnosticResult; label: string; color: string; icon: typeof CheckCircle2 }[] = [
|
||||
{ value: 'pass', label: 'Aprobado', color: 'bg-green-100 text-green-700 border-green-300', icon: CheckCircle2 },
|
||||
{ value: 'fail', label: 'Fallo', color: 'bg-red-100 text-red-700 border-red-300', icon: XCircle },
|
||||
{ value: 'needs_attention', label: 'Atencion', color: 'bg-yellow-100 text-yellow-700 border-yellow-300', icon: AlertTriangle },
|
||||
];
|
||||
|
||||
const EMPTY_ITEM: DiagnosticItemForm = {
|
||||
component: '',
|
||||
test_name: '',
|
||||
value: '',
|
||||
unit: '',
|
||||
min_value: '',
|
||||
max_value: '',
|
||||
result: 'pass',
|
||||
notes: '',
|
||||
};
|
||||
|
||||
export function DiagnosticsNewPage() {
|
||||
const navigate = useNavigate();
|
||||
const [vehicleSearch, setVehicleSearch] = useState('');
|
||||
const [showVehicleSearch, setShowVehicleSearch] = useState(false);
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<{ id: string; info: string } | null>(null);
|
||||
const [items, setItems] = useState<DiagnosticItemForm[]>([{ ...EMPTY_ITEM }]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<DiagnosticFormData>({
|
||||
resolver: zodResolver(diagnosticSchema),
|
||||
defaultValues: {
|
||||
diagnostic_type: 'obd_scanner',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedType = watch('diagnostic_type');
|
||||
|
||||
// Search vehicles
|
||||
const { data: vehiclesData, isLoading: searchingVehicles } = useQuery({
|
||||
queryKey: ['vehicles-search', vehicleSearch],
|
||||
queryFn: () => vehiclesApi.list({ search: vehicleSearch, pageSize: 10 }),
|
||||
enabled: vehicleSearch.length >= 2,
|
||||
});
|
||||
|
||||
const vehicles = vehiclesData?.data?.data || [];
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: DiagnosticFormData) => {
|
||||
// First create the diagnostic
|
||||
const response = await diagnosticsApi.create({
|
||||
vehicle_id: data.vehicle_id,
|
||||
order_id: data.order_id,
|
||||
diagnostic_type: data.diagnostic_type,
|
||||
equipment: data.equipment,
|
||||
summary: data.summary,
|
||||
});
|
||||
|
||||
const diagnosticId = response.data?.id;
|
||||
|
||||
// Then add items if we have them
|
||||
if (diagnosticId && items.some(item => item.component && item.test_name)) {
|
||||
for (const item of items) {
|
||||
if (item.component && item.test_name) {
|
||||
await diagnosticsApi.addItem(diagnosticId, {
|
||||
component: item.component,
|
||||
test_name: item.test_name,
|
||||
value: item.value,
|
||||
unit: item.unit || null,
|
||||
min_value: item.min_value ? parseFloat(item.min_value) : null,
|
||||
max_value: item.max_value ? parseFloat(item.max_value) : null,
|
||||
result: item.result,
|
||||
notes: item.notes || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
const id = response.data?.id;
|
||||
if (id) {
|
||||
navigate(`/diagnostics/${id}`);
|
||||
} else {
|
||||
navigate('/diagnostics');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: DiagnosticFormData) => {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const selectVehicle = (vehicle: Vehicle) => {
|
||||
setSelectedVehicle({
|
||||
id: vehicle.id,
|
||||
info: `${vehicle.licensePlate} - ${vehicle.make} ${vehicle.model} ${vehicle.year}`,
|
||||
});
|
||||
setValue('vehicle_id', vehicle.id);
|
||||
setShowVehicleSearch(false);
|
||||
setVehicleSearch('');
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setItems([...items, { ...EMPTY_ITEM }]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
if (items.length > 1) {
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: keyof DiagnosticItemForm, value: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/diagnostics"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Nuevo Diagnostico</h1>
|
||||
<p className="text-sm text-gray-500">Registre pruebas y analisis del vehiculo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Vehicle Selection */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Truck className="h-5 w-5 text-diesel-600" />
|
||||
Vehiculo
|
||||
</h2>
|
||||
|
||||
{selectedVehicle ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-diesel-200 bg-diesel-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-diesel-100">
|
||||
<Truck className="h-5 w-5 text-diesel-600" />
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">{selectedVehicle.info}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedVehicle(null);
|
||||
setValue('vehicle_id', '');
|
||||
}}
|
||||
className="text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Cambiar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={vehicleSearch}
|
||||
onChange={(e) => {
|
||||
setVehicleSearch(e.target.value);
|
||||
setShowVehicleSearch(true);
|
||||
}}
|
||||
onFocus={() => setShowVehicleSearch(true)}
|
||||
placeholder="Buscar por placas, marca o modelo..."
|
||||
className="w-full rounded-lg border border-gray-300 py-3 pl-10 pr-4 focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showVehicleSearch && vehicleSearch.length >= 2 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
{searchingVehicles ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
) : vehicles.length > 0 ? (
|
||||
<div className="max-h-60 overflow-y-auto p-2">
|
||||
{vehicles.map((vehicle) => (
|
||||
<button
|
||||
key={vehicle.id}
|
||||
type="button"
|
||||
onClick={() => selectVehicle(vehicle)}
|
||||
className="flex w-full items-center gap-3 rounded-lg p-3 text-left hover:bg-gray-50"
|
||||
>
|
||||
<Truck className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{vehicle.licensePlate} - {vehicle.make} {vehicle.model}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{vehicle.year} | {vehicle.vehicleType}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
No se encontraron vehiculos
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.vehicle_id && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.vehicle_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diagnostic Type */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Stethoscope className="h-5 w-5 text-diesel-600" />
|
||||
Tipo de Diagnostico
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{DIAGNOSTIC_TYPES.map((type) => {
|
||||
const Icon = type.icon;
|
||||
const isSelected = selectedType === type.value;
|
||||
return (
|
||||
<label
|
||||
key={type.value}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors ${
|
||||
isSelected
|
||||
? 'border-diesel-500 bg-diesel-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
{...register('diagnostic_type')}
|
||||
value={type.value}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Icon className={`h-5 w-5 ${isSelected ? 'text-diesel-600' : 'text-gray-400'}`} />
|
||||
<div>
|
||||
<p className={`font-medium ${isSelected ? 'text-diesel-700' : 'text-gray-700'}`}>
|
||||
{type.label}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{type.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Equipo Utilizado (opcional)
|
||||
</label>
|
||||
<input
|
||||
{...register('equipment')}
|
||||
placeholder="Ej: Scanner LAUNCH X431, Banco Asnu GDI..."
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostic Items */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Pruebas Realizadas</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="flex items-center gap-1 text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Agregar Prueba
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Prueba {index + 1}</span>
|
||||
{items.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(index)}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">Componente</label>
|
||||
<input
|
||||
value={item.component}
|
||||
onChange={(e) => updateItem(index, 'component', e.target.value)}
|
||||
placeholder="Ej: Inyector 1"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">Prueba</label>
|
||||
<input
|
||||
value={item.test_name}
|
||||
onChange={(e) => updateItem(index, 'test_name', e.target.value)}
|
||||
placeholder="Ej: Caudal Retorno"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">Valor</label>
|
||||
<input
|
||||
value={item.value}
|
||||
onChange={(e) => updateItem(index, 'value', e.target.value)}
|
||||
placeholder="Ej: 85"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">Unidad</label>
|
||||
<input
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
||||
placeholder="Ej: ml/min"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">Valor Min</label>
|
||||
<input
|
||||
type="number"
|
||||
value={item.min_value}
|
||||
onChange={(e) => updateItem(index, 'min_value', e.target.value)}
|
||||
placeholder="Ej: 70"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">Valor Max</label>
|
||||
<input
|
||||
type="number"
|
||||
value={item.max_value}
|
||||
onChange={(e) => updateItem(index, 'max_value', e.target.value)}
|
||||
placeholder="Ej: 100"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">Resultado</label>
|
||||
<div className="flex gap-2">
|
||||
{RESULT_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateItem(index, 'result', option.value)}
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
item.result === option.value
|
||||
? option.color
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">Notas</label>
|
||||
<input
|
||||
value={item.notes}
|
||||
onChange={(e) => updateItem(index, 'notes', e.target.value)}
|
||||
placeholder="Observaciones adicionales..."
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">Resumen</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Observaciones Generales
|
||||
</label>
|
||||
<textarea
|
||||
{...register('summary')}
|
||||
rows={4}
|
||||
placeholder="Describa hallazgos, recomendaciones..."
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Orden de Servicio (opcional)
|
||||
</label>
|
||||
<input
|
||||
{...register('order_id')}
|
||||
placeholder="ID de orden relacionada"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-diesel-600 px-4 py-3 font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
Guardar Diagnostico
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to="/diagnostics"
|
||||
className="block w-full rounded-lg border border-gray-300 px-4 py-3 text-center font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{createMutation.error && (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||
Error al crear el diagnostico. Intente nuevamente.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
564
src/pages/Inventory.tsx
Normal file
564
src/pages/Inventory.tsx
Normal file
@ -0,0 +1,564 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
DollarSign,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { partsApi } from '../services/api/parts';
|
||||
import type { Part, PartFilters } from '../types';
|
||||
|
||||
const partSchema = z.object({
|
||||
sku: z.string().min(1, 'SKU requerido'),
|
||||
name: z.string().min(1, 'Nombre requerido'),
|
||||
description: z.string().optional(),
|
||||
brand: z.string().optional(),
|
||||
manufacturer: z.string().optional(),
|
||||
cost: z.number().optional(),
|
||||
price: z.number().min(0, 'Precio requerido'),
|
||||
currentStock: z.number().min(0, 'Stock requerido'),
|
||||
minStock: z.number().min(0, 'Stock minimo requerido'),
|
||||
maxStock: z.number().optional(),
|
||||
reorderPoint: z.number().optional(),
|
||||
unit: z.string().min(1, 'Unidad requerida'),
|
||||
barcode: z.string().optional(),
|
||||
});
|
||||
|
||||
type PartForm = z.infer<typeof partSchema>;
|
||||
|
||||
export function InventoryPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filters, setFilters] = useState<PartFilters>({ page: 1, pageSize: 20 });
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingPart, setEditingPart] = useState<Part | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<PartForm>({
|
||||
resolver: zodResolver(partSchema),
|
||||
defaultValues: {
|
||||
unit: 'PZA',
|
||||
currentStock: 0,
|
||||
minStock: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch parts
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['parts', filters],
|
||||
queryFn: () => partsApi.list(filters),
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: PartForm) => partsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||
handleCloseModal();
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<PartForm> }) =>
|
||||
partsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||
handleCloseModal();
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => partsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const parts = data?.data?.data || [];
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingPart(null);
|
||||
reset({
|
||||
unit: 'PZA',
|
||||
currentStock: 0,
|
||||
minStock: 0,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (part: Part) => {
|
||||
setEditingPart(part);
|
||||
reset({
|
||||
sku: part.sku,
|
||||
name: part.name,
|
||||
description: part.description,
|
||||
brand: part.brand,
|
||||
manufacturer: part.manufacturer,
|
||||
cost: part.cost,
|
||||
price: part.price,
|
||||
currentStock: part.currentStock,
|
||||
minStock: part.minStock,
|
||||
maxStock: part.maxStock,
|
||||
reorderPoint: part.reorderPoint,
|
||||
unit: part.unit,
|
||||
barcode: part.barcode,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingPart(null);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = (data: PartForm) => {
|
||||
if (editingPart) {
|
||||
updateMutation.mutate({ id: editingPart.id, data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (part: Part) => {
|
||||
if (confirm(`Eliminar refaccion ${part.name}?`)) {
|
||||
deleteMutation.mutate(part.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate stats
|
||||
const lowStockParts = parts.filter((p: Part) => p.currentStock <= p.minStock);
|
||||
const totalValue = parts.reduce((sum: number, p: Part) => sum + (p.currentStock * p.price), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
|
||||
<p className="text-sm text-gray-500">Gestiona las refacciones y materiales</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva Refaccion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-blue-100 p-2">
|
||||
<Package className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total Productos</p>
|
||||
<p className="text-xl font-bold text-gray-900">{data?.data?.total || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-yellow-100 p-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Stock Bajo</p>
|
||||
<p className="text-xl font-bold text-gray-900">{lowStockParts.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-100 p-2">
|
||||
<DollarSign className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Valor en Inventario</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
${totalValue.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por SKU, nombre, marca..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, lowStock: !filters.lowStock, page: 1 })}
|
||||
className={`rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
filters.lowStock
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Stock Bajo
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar inventario</p>
|
||||
</div>
|
||||
) : parts.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<Package className="mb-2 h-8 w-8" />
|
||||
<p>No hay refacciones registradas</p>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Agregar primera refaccion
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Producto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
SKU
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Marca
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Stock
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Precio
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{parts.map((part: Part) => {
|
||||
const isLowStock = part.currentStock <= part.minStock;
|
||||
|
||||
return (
|
||||
<tr key={part.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
|
||||
isLowStock ? 'bg-yellow-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
<Package className={`h-5 w-5 ${isLowStock ? 'text-yellow-600' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{part.name}</p>
|
||||
{part.description && (
|
||||
<p className="text-sm text-gray-500 truncate max-w-xs">{part.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<span className="flex items-center gap-1 text-sm text-gray-600">
|
||||
<Tag className="h-3 w-3" />
|
||||
{part.sku}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{part.brand || '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{isLowStock && (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className={`font-medium ${isLowStock ? 'text-yellow-600' : 'text-gray-900'}`}>
|
||||
{part.currentStock} {part.unit}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">Min: {part.minStock}</p>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||
<p className="font-medium text-gray-900">
|
||||
${part.price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
{part.cost && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Costo: ${part.cost.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(part)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(part)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data && parts.length > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
Pagina {data.data.page} de {data.data.totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||
disabled={(filters.page || 1) <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingPart ? 'Editar Refaccion' : 'Nueva Refaccion'}
|
||||
</h2>
|
||||
<button onClick={handleCloseModal} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">SKU *</label>
|
||||
<input
|
||||
{...register('sku')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="REF-001"
|
||||
/>
|
||||
{errors.sku && <p className="mt-1 text-sm text-red-600">{errors.sku.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Codigo Barras</label>
|
||||
<input
|
||||
{...register('barcode')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Nombre *</label>
|
||||
<input
|
||||
{...register('name')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Filtro de aceite"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Descripcion</label>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca</label>
|
||||
<input
|
||||
{...register('brand')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Fleetguard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Fabricante</label>
|
||||
<input
|
||||
{...register('manufacturer')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Costo</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('cost', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Precio *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('price', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.price && <p className="mt-1 text-sm text-red-600">{errors.price.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Unidad *</label>
|
||||
<select
|
||||
{...register('unit')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
<option value="PZA">PZA</option>
|
||||
<option value="LT">LT</option>
|
||||
<option value="KG">KG</option>
|
||||
<option value="MT">MT</option>
|
||||
<option value="JGO">JGO</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Actual</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('currentStock', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Minimo</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('minStock', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Maximo</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('maxStock', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{(createMutation.isPending || updateMutation.isPending) && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{editingPart ? 'Guardar Cambios' : 'Crear Refaccion'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
650
src/pages/InventoryDetail.tsx
Normal file
650
src/pages/InventoryDetail.tsx
Normal file
@ -0,0 +1,650 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Package,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
DollarSign,
|
||||
Hash,
|
||||
Boxes,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Barcode,
|
||||
MapPin,
|
||||
Tag,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import { partsApi } from '../services/api/parts';
|
||||
import { ConfirmModal } from '../components/ui';
|
||||
import { toast } from '../store/toastStore';
|
||||
import type { Part } from '../types';
|
||||
|
||||
interface EditPartForm {
|
||||
sku: string;
|
||||
name: string;
|
||||
description: string;
|
||||
brand: string;
|
||||
manufacturer: string;
|
||||
cost: number;
|
||||
price: number;
|
||||
currentStock: number;
|
||||
minStock: number;
|
||||
maxStock: number;
|
||||
reorderPoint: number;
|
||||
unit: string;
|
||||
barcode: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function InventoryDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Fetch part data
|
||||
const { data: partData, isLoading, error } = useQuery({
|
||||
queryKey: ['part', id],
|
||||
queryFn: () => partsApi.getById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const part: Part | undefined = partData?.data;
|
||||
|
||||
// Form setup
|
||||
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<EditPartForm>();
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: EditPartForm) => partsApi.update(id!, {
|
||||
sku: data.sku,
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
brand: data.brand || undefined,
|
||||
manufacturer: data.manufacturer || undefined,
|
||||
cost: data.cost || undefined,
|
||||
price: data.price,
|
||||
currentStock: data.currentStock,
|
||||
minStock: data.minStock,
|
||||
maxStock: data.maxStock || undefined,
|
||||
reorderPoint: data.reorderPoint || undefined,
|
||||
unit: data.unit,
|
||||
barcode: data.barcode || undefined,
|
||||
isActive: data.isActive,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['part', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||
setIsEditing(false);
|
||||
toast.success('Refaccion actualizada', 'Los cambios se guardaron correctamente');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al guardar', 'No se pudieron guardar los cambios');
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => partsApi.delete(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||
toast.success('Refaccion eliminada');
|
||||
navigate('/inventory');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al eliminar', 'No se pudo eliminar la refaccion');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
if (part) {
|
||||
reset({
|
||||
sku: part.sku,
|
||||
name: part.name,
|
||||
description: part.description || '',
|
||||
brand: part.brand || '',
|
||||
manufacturer: part.manufacturer || '',
|
||||
cost: part.cost || 0,
|
||||
price: part.price,
|
||||
currentStock: part.currentStock,
|
||||
minStock: part.minStock,
|
||||
maxStock: part.maxStock || 0,
|
||||
reorderPoint: part.reorderPoint || 0,
|
||||
unit: part.unit,
|
||||
barcode: part.barcode || '',
|
||||
isActive: part.isActive,
|
||||
});
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = (data: EditPartForm) => {
|
||||
updateMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !part) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Refaccion no encontrada</p>
|
||||
<Link to="/inventory" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||
Volver a inventario
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isLowStock = part.currentStock <= part.minStock;
|
||||
const availableStock = part.currentStock - part.reservedStock;
|
||||
const stockPercentage = part.maxStock ? Math.round((part.currentStock / part.maxStock) * 100) : 0;
|
||||
const profit = part.price - (part.cost || 0);
|
||||
const profitMargin = part.cost ? Math.round((profit / part.cost) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/inventory"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{part.name}</h1>
|
||||
{part.isActive ? (
|
||||
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
Activo
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
Inactivo
|
||||
</span>
|
||||
)}
|
||||
{isLowStock && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Stock Bajo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">SKU: {part.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Eliminar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={!isDirty || updateMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Guardar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Part Info */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Package className="h-5 w-5 text-diesel-600" />
|
||||
Informacion de la Refaccion
|
||||
</h2>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">SKU</label>
|
||||
<input
|
||||
{...register('sku', { required: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Codigo de Barras</label>
|
||||
<input
|
||||
{...register('barcode')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input
|
||||
{...register('name', { required: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Descripcion</label>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca</label>
|
||||
<input
|
||||
{...register('brand')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Fabricante</label>
|
||||
<input
|
||||
{...register('manufacturer')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Unidad</label>
|
||||
<input
|
||||
{...register('unit', { required: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="pza, lt, kg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
{...register('isActive')}
|
||||
className="h-4 w-4 rounded border-gray-300 text-diesel-600 focus:ring-diesel-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||
Activo
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">SKU</p>
|
||||
<p className="text-sm font-mono font-medium text-gray-900">{part.sku}</p>
|
||||
</div>
|
||||
{part.barcode && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Codigo de Barras</p>
|
||||
<p className="text-sm font-mono text-gray-900">{part.barcode}</p>
|
||||
</div>
|
||||
)}
|
||||
{part.brand && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Marca</p>
|
||||
<p className="text-sm text-gray-900">{part.brand}</p>
|
||||
</div>
|
||||
)}
|
||||
{part.manufacturer && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Fabricante</p>
|
||||
<p className="text-sm text-gray-900">{part.manufacturer}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Unidad</p>
|
||||
<p className="text-sm text-gray-900">{part.unit}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Registrado</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{new Date(part.createdAt).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</div>
|
||||
{part.description && (
|
||||
<div className="col-span-full">
|
||||
<p className="text-xs font-medium text-gray-500">Descripcion</p>
|
||||
<p className="text-sm text-gray-600">{part.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{part.compatibleEngines && part.compatibleEngines.length > 0 && (
|
||||
<div className="col-span-full">
|
||||
<p className="text-xs font-medium text-gray-500">Motores Compatibles</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{part.compatibleEngines.map((engine, idx) => (
|
||||
<span key={idx} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
|
||||
{engine}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||
Precios
|
||||
</h2>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Costo</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('cost', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Precio de Venta</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('price', { required: true, valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Costo</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
${(part.cost || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Precio Venta</p>
|
||||
<p className="text-lg font-semibold text-diesel-600">
|
||||
${part.price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Utilidad</p>
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
${profit.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Margen</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{profitMargin}%</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stock Management */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Boxes className="h-5 w-5 text-diesel-600" />
|
||||
Control de Inventario
|
||||
</h2>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Actual</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('currentStock', { required: true, valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Minimo</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('minStock', { required: true, valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Maximo</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('maxStock', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Punto de Reorden</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('reorderPoint', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Stock Bar */}
|
||||
{part.maxStock && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Nivel de Stock</span>
|
||||
<span className="font-medium text-gray-900">{stockPercentage}%</span>
|
||||
</div>
|
||||
<div className="h-3 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all ${
|
||||
stockPercentage <= 25 ? 'bg-red-500' :
|
||||
stockPercentage <= 50 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(stockPercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-lg bg-gray-50 p-3 text-center">
|
||||
<p className="text-xs font-medium text-gray-500">Actual</p>
|
||||
<p className={`text-2xl font-bold ${isLowStock ? 'text-red-600' : 'text-gray-900'}`}>
|
||||
{part.currentStock}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{part.unit}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-center">
|
||||
<p className="text-xs font-medium text-blue-600">Reservado</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{part.reservedStock}</p>
|
||||
<p className="text-xs text-blue-500">{part.unit}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-green-50 p-3 text-center">
|
||||
<p className="text-xs font-medium text-green-600">Disponible</p>
|
||||
<p className="text-2xl font-bold text-green-600">{availableStock}</p>
|
||||
<p className="text-xs text-green-500">{part.unit}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-3 text-center">
|
||||
<p className="text-xs font-medium text-gray-500">Minimo</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{part.minStock}</p>
|
||||
<p className="text-xs text-gray-500">{part.unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{part.reorderPoint && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Punto de reorden: {part.reorderPoint} {part.unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Settings className="h-5 w-5 text-diesel-600" />
|
||||
Acciones Rapidas
|
||||
</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button className="flex w-full items-center gap-3 rounded-lg border border-gray-200 p-3 text-left text-sm hover:bg-gray-50">
|
||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Entrada de Stock</p>
|
||||
<p className="text-xs text-gray-500">Registrar compra o devolucion</p>
|
||||
</div>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-3 rounded-lg border border-gray-200 p-3 text-left text-sm hover:bg-gray-50">
|
||||
<Truck className="h-5 w-5 text-orange-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Salida de Stock</p>
|
||||
<p className="text-xs text-gray-500">Registrar uso o ajuste</p>
|
||||
</div>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-3 rounded-lg border border-gray-200 p-3 text-left text-sm hover:bg-gray-50">
|
||||
<Barcode className="h-5 w-5 text-diesel-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Imprimir Etiqueta</p>
|
||||
<p className="text-xs text-gray-500">Generar codigo de barras</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Hash className="h-5 w-5 text-diesel-600" />
|
||||
Resumen
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Tag className="h-4 w-4" />
|
||||
Valor en Stock
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
${(part.currentStock * part.price).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Costo en Stock
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
${(part.currentStock * (part.cost || 0)).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
{part.maxStock && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Boxes className="h-4 w-4" />
|
||||
Stock Maximo
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{part.maxStock} {part.unit}</span>
|
||||
</div>
|
||||
)}
|
||||
{part.locationId && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Ubicacion
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{part.locationId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="mt-4 border-t border-gray-100 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLowStock ? (
|
||||
<>
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-600">Requiere reabastecimiento</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-600">Stock saludable</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
title="Eliminar Refaccion"
|
||||
message={`¿Estas seguro de eliminar "${part.name}" (${part.sku})? Esta accion no se puede deshacer.`}
|
||||
confirmText="Eliminar"
|
||||
variant="danger"
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/pages/Login.tsx
Normal file
171
src/pages/Login.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Wrench, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { authApi } from '../services/api/auth';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Email invalido'),
|
||||
password: z.string().min(8, 'Minimo 8 caracteres'),
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await authApi.login(data);
|
||||
|
||||
if (response.success) {
|
||||
login(response.data.user, response.data.token, response.data.refreshToken);
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.data?.error) {
|
||||
setError(err.response.data.error.message || 'Error de autenticacion');
|
||||
} else {
|
||||
setError('Error de conexion. Verifica que el servidor este activo.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-diesel-500">
|
||||
<Wrench className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mecanicas Diesel</h1>
|
||||
<p className="text-sm text-gray-500">Sistema de gestion de taller</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="rounded-xl bg-white p-8 shadow-lg">
|
||||
<h2 className="mb-6 text-xl font-semibold text-gray-900">
|
||||
Iniciar Sesion
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Correo electronico
|
||||
</label>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
id="email"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
placeholder="usuario@taller.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Contrasena
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
className="block w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Forgot password */}
|
||||
<div className="flex items-center justify-end">
|
||||
<a
|
||||
href="#"
|
||||
className="text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Olvidaste tu contrasena?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-lg bg-diesel-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-diesel-700 focus:outline-none focus:ring-2 focus:ring-diesel-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Ingresando...' : 'Ingresar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Register link */}
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
No tienes cuenta?{' '}
|
||||
<Link to="/register" className="font-medium text-diesel-600 hover:text-diesel-700">
|
||||
Registra tu taller
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-4 text-center text-xs text-gray-500">
|
||||
ERP Mecanicas Diesel - Sistema NEXUS
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
486
src/pages/QuoteDetail.tsx
Normal file
486
src/pages/QuoteDetail.tsx
Normal file
@ -0,0 +1,486 @@
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Truck,
|
||||
User,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Printer,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
ArrowRightCircle,
|
||||
Wrench,
|
||||
Package,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { quotesApi } from '../services/api/quotes';
|
||||
import type { Quote, QuoteItem } from '../services/api/quotes';
|
||||
import type { QuoteStatus } from '../types';
|
||||
|
||||
const STATUS_CONFIG: Record<QuoteStatus, { label: string; color: string; bgColor: string; icon: typeof Clock }> = {
|
||||
draft: { label: 'Borrador', color: 'text-gray-700', bgColor: 'bg-gray-100', icon: FileText },
|
||||
sent: { label: 'Enviada', color: 'text-blue-700', bgColor: 'bg-blue-100', icon: Mail },
|
||||
viewed: { label: 'Vista', color: 'text-purple-700', bgColor: 'bg-purple-100', icon: Eye },
|
||||
approved: { label: 'Aprobada', color: 'text-green-700', bgColor: 'bg-green-100', icon: CheckCircle2 },
|
||||
rejected: { label: 'Rechazada', color: 'text-red-700', bgColor: 'bg-red-100', icon: XCircle },
|
||||
expired: { label: 'Expirada', color: 'text-yellow-700', bgColor: 'bg-yellow-100', icon: Clock },
|
||||
converted: { label: 'Convertida', color: 'text-emerald-700', bgColor: 'bg-emerald-100', icon: ArrowRightCircle },
|
||||
};
|
||||
|
||||
export function QuoteDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch quote
|
||||
const { data: quoteData, isLoading, error } = useQuery({
|
||||
queryKey: ['quote', id],
|
||||
queryFn: () => quotesApi.getById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const quote: Quote | undefined = quoteData?.data;
|
||||
|
||||
// Fetch quote items
|
||||
const { data: itemsData } = useQuery({
|
||||
queryKey: ['quote-items', id],
|
||||
queryFn: () => quotesApi.getItems(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const items: QuoteItem[] = itemsData?.data || [];
|
||||
|
||||
// Send quote mutation
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: () => quotesApi.send(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['quote', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Change status mutation
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: QuoteStatus) => quotesApi.changeStatus(id!, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['quote', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Convert to order mutation
|
||||
const convertMutation = useMutation({
|
||||
mutationFn: () => quotesApi.convertToOrder(id!),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['quote', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service-orders'] });
|
||||
// Navigate to the new order
|
||||
const orderId = response.data?.orderId;
|
||||
if (orderId) {
|
||||
navigate(`/orders/${orderId}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !quote) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar la cotizacion</p>
|
||||
<Link to="/quotes" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||
Volver a cotizaciones
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = STATUS_CONFIG[quote.status as QuoteStatus] || STATUS_CONFIG.draft;
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
// Calculate totals from items
|
||||
const laborItems = items.filter(i => i.item_type === 'service');
|
||||
const partItems = items.filter(i => i.item_type === 'part');
|
||||
const subtotal = items.reduce((sum, item) => sum + item.subtotal, 0);
|
||||
|
||||
// Check if quote is expired
|
||||
const isExpired = quote.valid_until && new Date(quote.valid_until) < new Date();
|
||||
const canSend = quote.status === 'draft';
|
||||
const canApprove = (quote.status === 'sent' || quote.status === 'viewed') && !isExpired;
|
||||
const canConvert = quote.status === 'approved';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/quotes"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{quote.quote_number}</h1>
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-medium ${statusConfig.bgColor} ${statusConfig.color}`}>
|
||||
<StatusIcon className="h-4 w-4" />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Creada el {new Date(quote.created_at).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Imprimir
|
||||
</button>
|
||||
{canSend && (
|
||||
<button
|
||||
onClick={() => sendMutation.mutate()}
|
||||
disabled={sendMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg border border-blue-300 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
{sendMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4" />
|
||||
)}
|
||||
Enviar
|
||||
</button>
|
||||
)}
|
||||
{canApprove && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => statusMutation.mutate('approved')}
|
||||
disabled={statusMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Aprobar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => statusMutation.mutate('rejected')}
|
||||
disabled={statusMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Rechazar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canConvert && (
|
||||
<button
|
||||
onClick={() => convertMutation.mutate()}
|
||||
disabled={convertMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
{convertMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRightCircle className="h-4 w-4" />
|
||||
)}
|
||||
Convertir a Orden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content - Quote Preview */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 print:border-0 print:p-0">
|
||||
{/* Quote Header */}
|
||||
<div className="mb-8 border-b border-gray-200 pb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">COTIZACION</h2>
|
||||
<p className="text-lg font-semibold text-diesel-600">{quote.quote_number}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">Fecha de Emision</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{new Date(quote.created_at).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-500">Valida Hasta</p>
|
||||
<p className={`font-medium ${isExpired ? 'text-red-600' : 'text-gray-900'}`}>
|
||||
{quote.valid_until
|
||||
? new Date(quote.valid_until).toLocaleDateString('es-MX')
|
||||
: 'Sin fecha limite'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer & Vehicle Info */}
|
||||
<div className="mb-8 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold uppercase text-gray-500">Cliente</h3>
|
||||
<p className="font-medium text-gray-900">{quote.customer_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold uppercase text-gray-500">Vehiculo</h3>
|
||||
<p className="font-medium text-gray-900">{quote.vehicle_info}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Table */}
|
||||
<div className="mb-8">
|
||||
{/* Labor Items */}
|
||||
{laborItems.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase text-gray-500">
|
||||
<Wrench className="h-4 w-4" />
|
||||
Mano de Obra
|
||||
</h3>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="pb-2 text-left text-sm font-medium text-gray-500">Descripcion</th>
|
||||
<th className="pb-2 text-right text-sm font-medium text-gray-500">Cant</th>
|
||||
<th className="pb-2 text-right text-sm font-medium text-gray-500">Precio</th>
|
||||
<th className="pb-2 text-right text-sm font-medium text-gray-500">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{laborItems.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-100">
|
||||
<td className="py-3">
|
||||
<p className="font-medium text-gray-900">{item.description}</p>
|
||||
</td>
|
||||
<td className="py-3 text-right text-gray-700">{item.quantity}</td>
|
||||
<td className="py-3 text-right text-gray-700">
|
||||
${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-3 text-right font-medium text-gray-900">
|
||||
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Part Items */}
|
||||
{partItems.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase text-gray-500">
|
||||
<Package className="h-4 w-4" />
|
||||
Refacciones
|
||||
</h3>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="pb-2 text-left text-sm font-medium text-gray-500">Descripcion</th>
|
||||
<th className="pb-2 text-right text-sm font-medium text-gray-500">Cant</th>
|
||||
<th className="pb-2 text-right text-sm font-medium text-gray-500">Precio</th>
|
||||
<th className="pb-2 text-right text-sm font-medium text-gray-500">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{partItems.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-100">
|
||||
<td className="py-3">
|
||||
<p className="font-medium text-gray-900">{item.description}</p>
|
||||
</td>
|
||||
<td className="py-3 text-right text-gray-700">{item.quantity}</td>
|
||||
<td className="py-3 text-right text-gray-700">
|
||||
${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-3 text-right font-medium text-gray-900">
|
||||
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="flex justify-end">
|
||||
<div className="w-64 space-y-2">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Mano de Obra</span>
|
||||
<span>${quote.labor_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Refacciones</span>
|
||||
<span>${quote.parts_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
{quote.discount_amount > 0 && (
|
||||
<div className="flex justify-between text-green-600">
|
||||
<span>Descuento</span>
|
||||
<span>-${quote.discount_amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>IVA</span>
|
||||
<span>${quote.tax.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-gray-200 pt-2 text-xl font-bold text-gray-900">
|
||||
<span>Total</span>
|
||||
<span>${quote.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{quote.notes && (
|
||||
<div className="mt-8 rounded-lg bg-gray-50 p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-700">Notas</h3>
|
||||
<p className="text-sm text-gray-600">{quote.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terms */}
|
||||
<div className="mt-8 border-t border-gray-200 pt-6 text-xs text-gray-500">
|
||||
<p>* Precios expresados en Pesos Mexicanos (MXN)</p>
|
||||
<p>* Los precios estan sujetos a disponibilidad de refacciones</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Customer Info */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<User className="h-5 w-5 text-diesel-600" />
|
||||
Cliente
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium text-gray-900">{quote.customer_name}</p>
|
||||
{quote.customer_id && (
|
||||
<Link
|
||||
to={`/customers?id=${quote.customer_id}`}
|
||||
className="text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Ver perfil del cliente
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Info */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Truck className="h-5 w-5 text-diesel-600" />
|
||||
Vehiculo
|
||||
</h2>
|
||||
<p className="font-medium text-gray-900">{quote.vehicle_info}</p>
|
||||
</div>
|
||||
|
||||
{/* Timeline / Actions */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Calendar className="h-5 w-5 text-diesel-600" />
|
||||
Fechas
|
||||
</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Creada</span>
|
||||
<span className="text-gray-900">
|
||||
{new Date(quote.created_at).toLocaleDateString('es-MX')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Valida hasta</span>
|
||||
<span className={isExpired ? 'text-red-600' : 'text-gray-900'}>
|
||||
{quote.valid_until
|
||||
? new Date(quote.valid_until).toLocaleDateString('es-MX')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
{quote.updated_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Actualizada</span>
|
||||
<span className="text-gray-900">
|
||||
{new Date(quote.updated_at).toLocaleDateString('es-MX')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||
Resumen
|
||||
</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Servicios</span>
|
||||
<span className="text-gray-900">{laborItems.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Refacciones</span>
|
||||
<span className="text-gray-900">{partItems.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-gray-200 pt-2 font-medium">
|
||||
<span className="text-gray-700">Total</span>
|
||||
<span className="text-diesel-600">
|
||||
${quote.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">Acciones Rapidas</h2>
|
||||
<div className="space-y-2">
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Enviar por WhatsApp
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<Mail className="h-4 w-4" />
|
||||
Enviar por Email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
src/pages/Quotes.tsx
Normal file
291
src/pages/Quotes.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Search,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Send,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import { quotesApi } from '../services/api/quotes';
|
||||
import type { Quote, QuoteFilters } from '../services/api/quotes';
|
||||
import type { QuoteStatus } from '../types';
|
||||
|
||||
const STATUS_CONFIG: Record<QuoteStatus, { label: string; color: string; icon: typeof Clock }> = {
|
||||
draft: { label: 'Borrador', color: 'bg-gray-100 text-gray-700', icon: FileText },
|
||||
sent: { label: 'Enviada', color: 'bg-blue-100 text-blue-700', icon: Send },
|
||||
viewed: { label: 'Vista', color: 'bg-purple-100 text-purple-700', icon: Eye },
|
||||
approved: { label: 'Aprobada', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
|
||||
rejected: { label: 'Rechazada', color: 'bg-red-100 text-red-700', icon: XCircle },
|
||||
expired: { label: 'Expirada', color: 'bg-gray-100 text-gray-500', icon: Clock },
|
||||
converted: { label: 'Convertida', color: 'bg-emerald-100 text-emerald-700', icon: ArrowRight },
|
||||
};
|
||||
|
||||
export function QuotesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filters, setFilters] = useState<QuoteFilters>({ page: 1, pageSize: 20 });
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Fetch quotes
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['quotes', filters],
|
||||
queryFn: () => quotesApi.list(filters),
|
||||
});
|
||||
|
||||
// Convert to order mutation
|
||||
const convertMutation = useMutation({
|
||||
mutationFn: (quoteId: string) => quotesApi.convertToOrder(quoteId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Send mutation
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (quoteId: string) => quotesApi.send(quoteId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||
},
|
||||
});
|
||||
|
||||
const quotes = data?.data?.data || [];
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
const handleStatusFilter = (status: QuoteStatus | undefined) => {
|
||||
setFilters({ ...filters, status, page: 1 });
|
||||
};
|
||||
|
||||
const isExpired = (validUntil: string) => {
|
||||
return new Date(validUntil) < new Date();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Cotizaciones</h1>
|
||||
<p className="text-sm text-gray-500">Gestiona las cotizaciones para clientes</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/quotes/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva Cotizacion
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Status summary */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
|
||||
{(Object.keys(STATUS_CONFIG) as QuoteStatus[]).map((status) => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusFilter(filters.status === status ? undefined : status)}
|
||||
className={`rounded-lg border p-3 text-left transition-colors ${
|
||||
filters.status === status
|
||||
? 'border-diesel-500 bg-diesel-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${filters.status === status ? 'text-diesel-600' : 'text-gray-400'}`} />
|
||||
<span className="text-xs font-medium text-gray-600">{config.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por numero, cliente..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar cotizaciones</p>
|
||||
</div>
|
||||
) : quotes.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<FileText className="mb-2 h-8 w-8" />
|
||||
<p>No hay cotizaciones</p>
|
||||
<Link
|
||||
to="/quotes/new"
|
||||
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Crear primera cotizacion
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Cotizacion
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Cliente / Vehiculo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Vigencia
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{quotes.map((quote: Quote) => {
|
||||
const statusConfig = STATUS_CONFIG[quote.status as QuoteStatus] || STATUS_CONFIG.draft;
|
||||
const expired = isExpired(quote.valid_until);
|
||||
|
||||
return (
|
||||
<tr key={quote.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<Link
|
||||
to={`/quotes/${quote.id}`}
|
||||
className="font-medium text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
{quote.quote_number}
|
||||
</Link>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(quote.created_at).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{quote.customer_name}</p>
|
||||
<p className="text-sm text-gray-500">{quote.vehicle_info}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<div className={`flex items-center gap-1 ${expired && quote.status !== 'converted' ? 'text-red-500' : 'text-gray-500'}`}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
{new Date(quote.valid_until).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})}
|
||||
{expired && quote.status !== 'converted' && (
|
||||
<span className="ml-1 text-xs">(vencida)</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right font-medium text-gray-900">
|
||||
${quote.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{quote.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => sendMutation.mutate(quote.id)}
|
||||
disabled={sendMutation.isPending}
|
||||
className="rounded p-1.5 text-blue-600 hover:bg-blue-50"
|
||||
title="Enviar"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{quote.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => convertMutation.mutate(quote.id)}
|
||||
disabled={convertMutation.isPending}
|
||||
className="rounded p-1.5 text-green-600 hover:bg-green-50"
|
||||
title="Convertir a Orden"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/quotes/${quote.id}`}
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data && quotes.length > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
Pagina {data.data.page} de {data.data.totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||
disabled={(filters.page || 1) <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
src/pages/Register.tsx
Normal file
253
src/pages/Register.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Wrench, Eye, EyeOff, Building2 } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { authApi } from '../services/api/auth';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const registerSchema = z.object({
|
||||
taller_name: z.string().min(3, 'Nombre del taller requerido (min 3 caracteres)'),
|
||||
full_name: z.string().min(2, 'Nombre completo requerido'),
|
||||
email: z.string().email('Email invalido'),
|
||||
password: z.string().min(8, 'Minimo 8 caracteres'),
|
||||
confirmPassword: z.string().min(8, 'Confirma tu contrasena'),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Las contrasenas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type RegisterForm = z.infer<typeof registerSchema>;
|
||||
|
||||
export function Register() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterForm>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterForm) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await authApi.register({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
full_name: data.full_name,
|
||||
taller_name: data.taller_name,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
login(response.data.user, response.data.token, response.data.refreshToken);
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.data?.error) {
|
||||
setError(err.response.data.error.message || 'Error en el registro');
|
||||
} else {
|
||||
setError('Error de conexion. Verifica que el servidor este activo.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-diesel-500">
|
||||
<Wrench className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mecanicas Diesel</h1>
|
||||
<p className="text-sm text-gray-500">Registra tu taller</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="rounded-xl bg-white p-8 shadow-lg">
|
||||
<h2 className="mb-6 text-xl font-semibold text-gray-900">
|
||||
Crear Cuenta
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Taller Name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="taller_name"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Nombre del Taller
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('taller_name')}
|
||||
type="text"
|
||||
id="taller_name"
|
||||
className="block w-full rounded-lg border border-gray-300 py-2.5 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
placeholder="Taller Mecanico XYZ"
|
||||
/>
|
||||
</div>
|
||||
{errors.taller_name && (
|
||||
<p className="mt-1 text-xs text-red-600">{errors.taller_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="full_name"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Tu nombre completo
|
||||
</label>
|
||||
<input
|
||||
{...register('full_name')}
|
||||
type="text"
|
||||
id="full_name"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
placeholder="Juan Perez"
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<p className="mt-1 text-xs text-red-600">{errors.full_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Correo electronico
|
||||
</label>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
id="email"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
placeholder="usuario@taller.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Contrasena
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
className="block w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
placeholder="Minimo 8 caracteres"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Confirmar contrasena
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
{...register('confirmPassword')}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
id="confirmPassword"
|
||||
className="block w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
placeholder="Repite tu contrasena"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-lg bg-diesel-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-diesel-700 focus:outline-none focus:ring-2 focus:ring-diesel-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Registrando...' : 'Registrar Taller'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Login link */}
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Ya tienes cuenta?{' '}
|
||||
<Link to="/login" className="font-medium text-diesel-600 hover:text-diesel-700">
|
||||
Inicia sesion
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-4 text-center text-xs text-gray-500">
|
||||
ERP Mecanicas Diesel - Sistema NEXUS
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
406
src/pages/ServiceOrderDetail.tsx
Normal file
406
src/pages/ServiceOrderDetail.tsx
Normal file
@ -0,0 +1,406 @@
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Truck,
|
||||
User,
|
||||
Calendar,
|
||||
Clock,
|
||||
Wrench,
|
||||
Package,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
Pause,
|
||||
FileText,
|
||||
Edit2,
|
||||
Loader2,
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||
import type { ServiceOrder, ServiceOrderItem } from '../services/api/serviceOrders';
|
||||
import type { ServiceOrderStatus } from '../types';
|
||||
|
||||
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string; bgColor: string }> = {
|
||||
received: { label: 'Recibido', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||
diagnosing: { label: 'Diagnosticando', color: 'text-purple-700', bgColor: 'bg-purple-100' },
|
||||
quoted: { label: 'Cotizado', color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
|
||||
approved: { label: 'Aprobado', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||
in_repair: { label: 'En Reparacion', color: 'text-orange-700', bgColor: 'bg-orange-100' },
|
||||
waiting_parts: { label: 'Esperando Partes', color: 'text-red-700', bgColor: 'bg-red-100' },
|
||||
ready: { label: 'Listo', color: 'text-emerald-700', bgColor: 'bg-emerald-100' },
|
||||
delivered: { label: 'Entregado', color: 'text-gray-700', bgColor: 'bg-gray-100' },
|
||||
cancelled: { label: 'Cancelado', color: 'text-gray-500', bgColor: 'bg-gray-100' },
|
||||
};
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Baja', color: 'text-gray-600' },
|
||||
medium: { label: 'Media', color: 'text-blue-600' },
|
||||
high: { label: 'Alta', color: 'text-orange-600' },
|
||||
urgent: { label: 'Urgente', color: 'text-red-600' },
|
||||
};
|
||||
|
||||
// Status transitions
|
||||
const STATUS_ACTIONS: Record<ServiceOrderStatus, { next: ServiceOrderStatus; label: string; icon: typeof Play }[]> = {
|
||||
received: [
|
||||
{ next: 'diagnosing', label: 'Iniciar Diagnostico', icon: Play },
|
||||
{ next: 'in_repair', label: 'Iniciar Reparacion', icon: Wrench },
|
||||
],
|
||||
diagnosing: [
|
||||
{ next: 'quoted', label: 'Crear Cotizacion', icon: FileText },
|
||||
],
|
||||
quoted: [
|
||||
{ next: 'approved', label: 'Aprobar', icon: CheckCircle2 },
|
||||
],
|
||||
approved: [
|
||||
{ next: 'in_repair', label: 'Iniciar Reparacion', icon: Wrench },
|
||||
],
|
||||
in_repair: [
|
||||
{ next: 'waiting_parts', label: 'Esperando Partes', icon: Pause },
|
||||
{ next: 'ready', label: 'Marcar Listo', icon: CheckCircle2 },
|
||||
],
|
||||
waiting_parts: [
|
||||
{ next: 'in_repair', label: 'Continuar Reparacion', icon: Play },
|
||||
],
|
||||
ready: [
|
||||
{ next: 'delivered', label: 'Entregar', icon: Truck },
|
||||
],
|
||||
delivered: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export function ServiceOrderDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch order details
|
||||
const { data: orderData, isLoading, error } = useQuery({
|
||||
queryKey: ['service-order', id],
|
||||
queryFn: () => serviceOrdersApi.getById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Fetch order items
|
||||
const { data: itemsData } = useQuery({
|
||||
queryKey: ['service-order-items', id],
|
||||
queryFn: () => serviceOrdersApi.getItems(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Status change mutation
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ status, notes }: { status: ServiceOrderStatus; notes?: string }) =>
|
||||
serviceOrdersApi.changeStatus(id!, status, notes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['service-order', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service-orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
const order: ServiceOrder | undefined = orderData?.data;
|
||||
const items: ServiceOrderItem[] = itemsData?.data || [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !order) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Orden no encontrada</p>
|
||||
<Link to="/orders" className="mt-2 text-sm text-diesel-600 hover:text-diesel-700">
|
||||
Volver a ordenes
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = STATUS_CONFIG[order.status as ServiceOrderStatus] || STATUS_CONFIG.received;
|
||||
const priorityConfig = PRIORITY_CONFIG[order.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG.medium;
|
||||
const actions = STATUS_ACTIONS[order.status as ServiceOrderStatus] || [];
|
||||
|
||||
const laborItems = items.filter(i => i.item_type === 'service');
|
||||
const partItems = items.filter(i => i.item_type === 'part');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/orders')}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{order.order_number}</h1>
|
||||
<span className={`rounded-full px-3 py-1 text-sm font-medium ${statusConfig.bgColor} ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Creada el {new Date(order.received_at).toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Actions */}
|
||||
{actions.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<button
|
||||
key={action.next}
|
||||
onClick={() => statusMutation.mutate({ status: action.next })}
|
||||
disabled={statusMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{statusMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4" />
|
||||
)}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Customer & Vehicle */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Customer */}
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||
<User className="h-4 w-4" />
|
||||
Cliente
|
||||
</h3>
|
||||
<p className="text-lg font-semibold text-gray-900">{order.customer_name}</p>
|
||||
</div>
|
||||
|
||||
{/* Vehicle */}
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||
<Truck className="h-4 w-4" />
|
||||
Vehiculo
|
||||
</h3>
|
||||
<p className="text-lg font-semibold text-gray-900">{order.vehicle_info}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Symptoms */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<AlertCircle className="h-5 w-5 text-diesel-600" />
|
||||
Sintomas Reportados
|
||||
</h3>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">
|
||||
{order.symptoms || 'Sin sintomas registrados'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Package className="h-5 w-5 text-diesel-600" />
|
||||
Trabajos y Refacciones
|
||||
</h3>
|
||||
<button className="flex items-center gap-1 text-sm text-diesel-600 hover:text-diesel-700">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">No hay items agregados</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Labor */}
|
||||
{laborItems.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||
<Wrench className="h-4 w-4" />
|
||||
Mano de Obra
|
||||
</h4>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{laborItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{item.description}</p>
|
||||
{item.actual_hours && (
|
||||
<p className="text-sm text-gray-500">{item.actual_hours} hrs</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-medium text-gray-900">
|
||||
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parts */}
|
||||
{partItems.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||
<Package className="h-4 w-4" />
|
||||
Refacciones
|
||||
</h4>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{partItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{item.description}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{item.quantity} x ${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-gray-900">
|
||||
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{order.notes && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-3 text-lg font-semibold text-gray-900">Notas Internas</h3>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{order.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Info Card */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Informacion</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Prioridad</span>
|
||||
<span className={`font-medium ${priorityConfig.color}`}>{priorityConfig.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Mecanico</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{order.mechanic_name || 'Sin asignar'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Bahia</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{order.bay_name || 'Sin asignar'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{order.promised_at && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-gray-500">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Prometido
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{new Date(order.promised_at).toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||
Totales
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">Mano de Obra</span>
|
||||
<span className="text-gray-900">
|
||||
${order.labor_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">Refacciones</span>
|
||||
<span className="text-gray-900">
|
||||
${order.parts_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">IVA</span>
|
||||
<span className="text-gray-900">
|
||||
${order.tax.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-gray-900">Total</span>
|
||||
<span className="text-xl font-bold text-diesel-600">
|
||||
${order.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline placeholder */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Clock className="h-5 w-5 text-diesel-600" />
|
||||
Historial
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-diesel-500" />
|
||||
<span>Orden creada</span>
|
||||
<span className="ml-auto">
|
||||
{new Date(order.created_at).toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
384
src/pages/ServiceOrderNew.tsx
Normal file
384
src/pages/ServiceOrderNew.tsx
Normal file
@ -0,0 +1,384 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Truck,
|
||||
User,
|
||||
Search,
|
||||
Calendar,
|
||||
Gauge,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||
import { customersApi } from '../services/api/customers';
|
||||
import { vehiclesApi } from '../services/api/vehicles';
|
||||
import type { Customer } from '../services/api/customers';
|
||||
import type { Vehicle, ServiceOrderPriority } from '../types';
|
||||
|
||||
const PRIORITY_OPTIONS: { value: ServiceOrderPriority; label: string; color: string }[] = [
|
||||
{ value: 'low', label: 'Baja', color: 'bg-gray-100 text-gray-700' },
|
||||
{ value: 'medium', label: 'Media', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'high', label: 'Alta', color: 'bg-orange-100 text-orange-700' },
|
||||
{ value: 'urgent', label: 'Urgente', color: 'bg-red-100 text-red-700' },
|
||||
];
|
||||
|
||||
const serviceOrderSchema = z.object({
|
||||
customerId: z.string().min(1, 'Selecciona un cliente'),
|
||||
vehicleId: z.string().min(1, 'Selecciona un vehiculo'),
|
||||
customerSymptoms: z.string().min(10, 'Describe los sintomas (min. 10 caracteres)'),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']),
|
||||
promisedAt: z.string().optional(),
|
||||
odometerIn: z.number().optional(),
|
||||
internalNotes: z.string().optional(),
|
||||
});
|
||||
|
||||
type ServiceOrderForm = z.infer<typeof serviceOrderSchema>;
|
||||
|
||||
export function ServiceOrderNewPage() {
|
||||
const navigate = useNavigate();
|
||||
const [customerSearch, setCustomerSearch] = useState('');
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<ServiceOrderForm>({
|
||||
resolver: zodResolver(serviceOrderSchema),
|
||||
defaultValues: {
|
||||
priority: 'medium',
|
||||
},
|
||||
});
|
||||
|
||||
const customerId = watch('customerId');
|
||||
|
||||
// Fetch customers for search
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers', customerSearch],
|
||||
queryFn: () => customersApi.list({ search: customerSearch, pageSize: 10 }),
|
||||
enabled: customerSearch.length >= 2,
|
||||
});
|
||||
|
||||
// Fetch vehicles for selected customer
|
||||
const { data: vehiclesData, isLoading: vehiclesLoading } = useQuery({
|
||||
queryKey: ['vehicles', customerId],
|
||||
queryFn: () => vehiclesApi.list({ customerId, pageSize: 100 }),
|
||||
enabled: !!customerId,
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: ServiceOrderForm) => serviceOrdersApi.create({
|
||||
customer_id: data.customerId,
|
||||
vehicle_id: data.vehicleId,
|
||||
symptoms: data.customerSymptoms,
|
||||
priority: data.priority,
|
||||
promised_at: data.promisedAt,
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
if (response.data?.id) {
|
||||
navigate(`/orders/${response.data.id}`);
|
||||
} else {
|
||||
navigate('/orders');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const customers = customersData?.data?.data || [];
|
||||
const vehicles = vehiclesData?.data?.data || [];
|
||||
|
||||
const handleCustomerSelect = (customer: Customer) => {
|
||||
setSelectedCustomer(customer);
|
||||
setValue('customerId', customer.id);
|
||||
setShowCustomerDropdown(false);
|
||||
setCustomerSearch(customer.name);
|
||||
// Reset vehicle when customer changes
|
||||
setSelectedVehicle(null);
|
||||
setValue('vehicleId', '');
|
||||
};
|
||||
|
||||
const handleVehicleSelect = (vehicle: Vehicle) => {
|
||||
setSelectedVehicle(vehicle);
|
||||
setValue('vehicleId', vehicle.id);
|
||||
if (vehicle.currentOdometer) {
|
||||
setValue('odometerIn', vehicle.currentOdometer);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: ServiceOrderForm) => {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/orders')}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Nueva Orden de Servicio</h1>
|
||||
<p className="text-sm text-gray-500">Registra un nuevo vehiculo para servicio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Form */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Customer Selection */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<User className="h-5 w-5 text-diesel-600" />
|
||||
Cliente
|
||||
</h2>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar cliente por nombre..."
|
||||
value={customerSearch}
|
||||
onChange={(e) => {
|
||||
setCustomerSearch(e.target.value);
|
||||
setShowCustomerDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCustomerDropdown(true)}
|
||||
className="w-full rounded-lg border border-gray-300 py-2.5 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Customers Dropdown */}
|
||||
{showCustomerDropdown && customers.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
{customers.map((customer: Customer) => (
|
||||
<button
|
||||
key={customer.id}
|
||||
type="button"
|
||||
onClick={() => handleCustomerSelect(customer)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<User className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{customer.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{customer.contact_name} - {customer.vehicle_count} vehiculos
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errors.customerId && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.customerId.message}</p>
|
||||
)}
|
||||
|
||||
{/* Selected Customer Info */}
|
||||
{selectedCustomer && (
|
||||
<div className="mt-4 rounded-lg bg-diesel-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{selectedCustomer.name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedCustomer.contact_name} - {selectedCustomer.contact_phone}
|
||||
</p>
|
||||
</div>
|
||||
<Check className="h-5 w-5 text-diesel-600" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vehicle Selection */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Truck className="h-5 w-5 text-diesel-600" />
|
||||
Vehiculo
|
||||
</h2>
|
||||
|
||||
{!customerId ? (
|
||||
<p className="text-sm text-gray-500">Selecciona primero un cliente</p>
|
||||
) : vehiclesLoading ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Cargando vehiculos...
|
||||
</div>
|
||||
) : vehicles.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">El cliente no tiene vehiculos registrados</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{vehicles.map((vehicle: Vehicle) => (
|
||||
<button
|
||||
key={vehicle.id}
|
||||
type="button"
|
||||
onClick={() => handleVehicleSelect(vehicle)}
|
||||
className={`flex items-center gap-3 rounded-lg border p-4 text-left transition-colors ${
|
||||
selectedVehicle?.id === vehicle.id
|
||||
? 'border-diesel-500 bg-diesel-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Truck className="h-6 w-6 text-gray-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">
|
||||
{vehicle.make} {vehicle.model}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{vehicle.year} - {vehicle.licensePlate}
|
||||
</p>
|
||||
{vehicle.economicNumber && (
|
||||
<p className="text-xs text-gray-400">Eco: {vehicle.economicNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedVehicle?.id === vehicle.id && (
|
||||
<Check className="h-5 w-5 text-diesel-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.vehicleId && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.vehicleId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Symptoms */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<AlertCircle className="h-5 w-5 text-diesel-600" />
|
||||
Sintomas Reportados
|
||||
</h2>
|
||||
|
||||
<textarea
|
||||
{...register('customerSymptoms')}
|
||||
rows={4}
|
||||
placeholder="Describe los problemas o sintomas que reporta el cliente..."
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
|
||||
{errors.customerSymptoms && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.customerSymptoms.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Internal Notes */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">Notas Internas</h2>
|
||||
|
||||
<textarea
|
||||
{...register('internalNotes')}
|
||||
rows={3}
|
||||
placeholder="Notas adicionales para el equipo de trabajo (opcional)..."
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Priority */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">Prioridad</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors ${
|
||||
watch('priority') === option.value
|
||||
? 'border-diesel-500 bg-diesel-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
{...register('priority')}
|
||||
value={option.value}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${option.color}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">Informacion Adicional</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Fecha Prometida
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
{...register('promisedAt')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Odometro Entrada (km)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('odometerIn', { valueAsNumber: true })}
|
||||
placeholder="Ej: 125000"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-diesel-600 px-4 py-3 font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Creando...
|
||||
</>
|
||||
) : (
|
||||
'Crear Orden de Servicio'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{createMutation.isError && (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||
Error al crear la orden. Intenta de nuevo.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
src/pages/ServiceOrders.tsx
Normal file
290
src/pages/ServiceOrders.tsx
Normal file
@ -0,0 +1,290 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Wrench,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Truck,
|
||||
Calendar,
|
||||
LayoutGrid,
|
||||
} from 'lucide-react';
|
||||
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||
import type { ServiceOrder, ServiceOrderFilters } from '../services/api/serviceOrders';
|
||||
import type { ServiceOrderStatus } from '../types';
|
||||
|
||||
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string; icon: typeof Clock }> = {
|
||||
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800', icon: Clock },
|
||||
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800', icon: AlertCircle },
|
||||
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800', icon: CheckCircle2 },
|
||||
in_repair: { label: 'En Reparación', color: 'bg-orange-100 text-orange-800', icon: Wrench },
|
||||
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800', icon: Clock },
|
||||
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800', icon: CheckCircle2 },
|
||||
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800', icon: Truck },
|
||||
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500', icon: AlertCircle },
|
||||
};
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Baja', color: 'text-gray-500' },
|
||||
medium: { label: 'Media', color: 'text-blue-500' },
|
||||
high: { label: 'Alta', color: 'text-orange-500' },
|
||||
urgent: { label: 'Urgente', color: 'text-red-500' },
|
||||
};
|
||||
|
||||
export function ServiceOrdersPage() {
|
||||
const [filters, setFilters] = useState<ServiceOrderFilters>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['service-orders', filters],
|
||||
queryFn: () => serviceOrdersApi.list(filters),
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
const handleStatusFilter = (status: ServiceOrderStatus | undefined) => {
|
||||
setFilters({ ...filters, status, page: 1 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Órdenes de Servicio</h1>
|
||||
<p className="text-sm text-gray-500">Gestiona las órdenes de trabajo del taller</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/orders/kanban"
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Vista Kanban
|
||||
</Link>
|
||||
<Link
|
||||
to="/orders/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva Orden
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status summary cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{(['received', 'in_repair', 'waiting_parts', 'ready', 'delivered'] as ServiceOrderStatus[]).map(
|
||||
(status) => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusFilter(filters.status === status ? undefined : status)}
|
||||
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||
filters.status === status
|
||||
? 'border-diesel-500 bg-diesel-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${filters.status === status ? 'text-diesel-600' : 'text-gray-400'}`} />
|
||||
<span className="text-xs font-medium text-gray-500">{config.label}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900">-</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por número de orden, cliente o vehículo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleStatusFilter((e.target.value as ServiceOrderStatus) || undefined)}
|
||||
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>
|
||||
{config.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.priority || ''}
|
||||
onChange={(e) =>
|
||||
setFilters({ ...filters, priority: e.target.value || undefined, page: 1 })
|
||||
}
|
||||
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todas las prioridades</option>
|
||||
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>
|
||||
{config.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-diesel-600 border-t-transparent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar órdenes</p>
|
||||
<p className="text-sm">Verifica que el servidor esté activo</p>
|
||||
</div>
|
||||
) : !data?.data?.data?.length ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<Wrench className="mb-2 h-8 w-8" />
|
||||
<p>No hay órdenes de servicio</p>
|
||||
<Link
|
||||
to="/orders/new"
|
||||
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Crear primera orden
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Orden
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Cliente / Vehículo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Prioridad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Fecha
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{data.data.data.map((order: ServiceOrder) => {
|
||||
const statusConfig = STATUS_CONFIG[order.status as ServiceOrderStatus] || STATUS_CONFIG.received;
|
||||
const priorityConfig = PRIORITY_CONFIG[order.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG.medium;
|
||||
|
||||
return (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<Link
|
||||
to={`/orders/${order.id}`}
|
||||
className="font-medium text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
{order.order_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{order.customer_name}</div>
|
||||
<div className="text-sm text-gray-500">{order.vehicle_info}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<span className={`text-sm font-medium ${priorityConfig.color}`}>
|
||||
{priorityConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{new Date(order.received_at).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-medium text-gray-900">
|
||||
${order.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data && data.data.data.length > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
Página {data.data.page} de {data.data.totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||
disabled={filters.page === 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
src/pages/ServiceOrdersKanban.tsx
Normal file
320
src/pages/ServiceOrdersKanban.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Truck,
|
||||
Clock,
|
||||
User,
|
||||
List,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||
import type { ServiceOrder } from '../services/api/serviceOrders';
|
||||
import type { ServiceOrderStatus } from '../types';
|
||||
|
||||
// Kanban columns configuration
|
||||
const KANBAN_COLUMNS: { status: ServiceOrderStatus; label: string; color: string; bgColor: string }[] = [
|
||||
{ status: 'received', label: 'Recibidos', color: 'border-blue-500', bgColor: 'bg-blue-50' },
|
||||
{ status: 'diagnosing', label: 'Diagnosticando', color: 'border-purple-500', bgColor: 'bg-purple-50' },
|
||||
{ status: 'quoted', label: 'Cotizados', color: 'border-yellow-500', bgColor: 'bg-yellow-50' },
|
||||
{ status: 'approved', label: 'Aprobados', color: 'border-green-500', bgColor: 'bg-green-50' },
|
||||
{ status: 'in_repair', label: 'En Reparacion', color: 'border-orange-500', bgColor: 'bg-orange-50' },
|
||||
{ status: 'waiting_parts', label: 'Esperando Partes', color: 'border-red-500', bgColor: 'bg-red-50' },
|
||||
{ status: 'ready', label: 'Listos', color: 'border-emerald-500', bgColor: 'bg-emerald-50' },
|
||||
];
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Baja', color: 'text-gray-500', dot: 'bg-gray-400' },
|
||||
medium: { label: 'Media', color: 'text-blue-500', dot: 'bg-blue-400' },
|
||||
high: { label: 'Alta', color: 'text-orange-500', dot: 'bg-orange-400' },
|
||||
urgent: { label: 'Urgente', color: 'text-red-500', dot: 'bg-red-400' },
|
||||
};
|
||||
|
||||
interface KanbanCardProps {
|
||||
order: ServiceOrder;
|
||||
onMoveToNext: (orderId: string, currentStatus: ServiceOrderStatus) => void;
|
||||
isMoving: boolean;
|
||||
}
|
||||
|
||||
function KanbanCard({ order, onMoveToNext, isMoving }: KanbanCardProps) {
|
||||
const priorityConfig = PRIORITY_CONFIG[order.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG.medium;
|
||||
const isUrgent = order.priority === 'urgent' || order.priority === 'high';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group rounded-lg border bg-white p-3 shadow-sm transition-all hover:shadow-md ${
|
||||
isUrgent ? 'border-l-4 border-l-red-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<Link
|
||||
to={`/orders/${order.id}`}
|
||||
className="font-medium text-gray-900 hover:text-diesel-600"
|
||||
>
|
||||
{order.order_number}
|
||||
</Link>
|
||||
<span className={`flex items-center gap-1 text-xs ${priorityConfig.color}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${priorityConfig.dot}`} />
|
||||
{priorityConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600">
|
||||
<Truck className="h-4 w-4" />
|
||||
<span className="truncate">{order.vehicle_info}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-gray-500">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="truncate">{order.customer_name}</span>
|
||||
</div>
|
||||
|
||||
{order.promised_at && (
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-gray-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
Prometido: {new Date(order.promised_at).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.symptoms && (
|
||||
<p className="mb-2 text-xs text-gray-500 line-clamp-2">{order.symptoms}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(order.received_at).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onMoveToNext(order.id, order.status as ServiceOrderStatus)}
|
||||
disabled={isMoving}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-diesel-600 opacity-0 transition-opacity hover:bg-diesel-50 group-hover:opacity-100 disabled:opacity-50"
|
||||
>
|
||||
{isMoving ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Avanzar
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KanbanColumnProps {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
orders: ServiceOrder[];
|
||||
onMoveToNext: (orderId: string, currentStatus: ServiceOrderStatus) => void;
|
||||
movingOrderId: string | null;
|
||||
}
|
||||
|
||||
function KanbanColumn({ label, color, bgColor, orders, onMoveToNext, movingOrderId }: KanbanColumnProps) {
|
||||
const urgentCount = orders.filter(o => o.priority === 'urgent' || o.priority === 'high').length;
|
||||
|
||||
return (
|
||||
<div className="flex w-72 flex-shrink-0 flex-col rounded-lg border border-gray-200 bg-gray-50">
|
||||
<div className={`flex items-center justify-between border-b-2 ${color} p-3 ${bgColor}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{label}</h3>
|
||||
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-600">
|
||||
{orders.length}
|
||||
</span>
|
||||
</div>
|
||||
{urgentCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-red-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{urgentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-y-auto p-2" style={{ maxHeight: 'calc(100vh - 280px)' }}>
|
||||
{orders.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-gray-400">
|
||||
Sin ordenes
|
||||
</div>
|
||||
) : (
|
||||
orders.map((order) => (
|
||||
<KanbanCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onMoveToNext={onMoveToNext}
|
||||
isMoving={movingOrderId === order.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Status transitions for quick move
|
||||
const STATUS_NEXT: Record<ServiceOrderStatus, ServiceOrderStatus | null> = {
|
||||
received: 'diagnosing',
|
||||
diagnosing: 'quoted',
|
||||
quoted: 'approved',
|
||||
approved: 'in_repair',
|
||||
in_repair: 'ready',
|
||||
waiting_parts: 'in_repair',
|
||||
ready: 'delivered',
|
||||
delivered: null,
|
||||
cancelled: null,
|
||||
};
|
||||
|
||||
export function ServiceOrdersKanbanPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [movingOrderId, setMovingOrderId] = useState<string | null>(null);
|
||||
|
||||
// Fetch all orders for kanban (exclude delivered and cancelled)
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['service-orders-kanban'],
|
||||
queryFn: () => serviceOrdersApi.list({ pageSize: 200 }), // Get all active orders
|
||||
});
|
||||
|
||||
const allOrders: ServiceOrder[] = data?.data?.data || [];
|
||||
|
||||
// Filter out delivered and cancelled
|
||||
const activeOrders = allOrders.filter(
|
||||
o => o.status !== 'delivered' && o.status !== 'cancelled'
|
||||
);
|
||||
|
||||
// Group orders by status
|
||||
const ordersByStatus = KANBAN_COLUMNS.reduce((acc, col) => {
|
||||
acc[col.status] = activeOrders
|
||||
.filter(o => o.status === col.status)
|
||||
.sort((a, b) => {
|
||||
// Sort by priority (urgent first) then by date
|
||||
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 2;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 2;
|
||||
if (aPriority !== bPriority) return aPriority - bPriority;
|
||||
return new Date(a.received_at).getTime() - new Date(b.received_at).getTime();
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<ServiceOrderStatus, ServiceOrder[]>);
|
||||
|
||||
// Status change mutation
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ orderId, status }: { orderId: string; status: ServiceOrderStatus }) =>
|
||||
serviceOrdersApi.changeStatus(orderId, status),
|
||||
onMutate: ({ orderId }) => {
|
||||
setMovingOrderId(orderId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['service-orders-kanban'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service-orders'] });
|
||||
},
|
||||
onSettled: () => {
|
||||
setMovingOrderId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleMoveToNext = (orderId: string, currentStatus: ServiceOrderStatus) => {
|
||||
const nextStatus = STATUS_NEXT[currentStatus];
|
||||
if (nextStatus) {
|
||||
statusMutation.mutate({ orderId, status: nextStatus });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar ordenes</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tablero de Ordenes</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{activeOrders.length} ordenes activas
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/orders"
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
Vista Lista
|
||||
</Link>
|
||||
<Link
|
||||
to="/orders/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva Orden
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-4 gap-4 lg:grid-cols-7">
|
||||
{KANBAN_COLUMNS.map((col) => {
|
||||
const count = ordersByStatus[col.status]?.length || 0;
|
||||
const urgentCount = ordersByStatus[col.status]?.filter(
|
||||
o => o.priority === 'urgent' || o.priority === 'high'
|
||||
).length || 0;
|
||||
return (
|
||||
<div
|
||||
key={col.status}
|
||||
className={`rounded-lg border-l-4 ${col.color} bg-white p-3 shadow-sm`}
|
||||
>
|
||||
<p className="text-xs font-medium text-gray-500">{col.label}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold text-gray-900">{count}</p>
|
||||
{urgentCount > 0 && (
|
||||
<span className="text-xs text-red-500">({urgentCount} urgentes)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{KANBAN_COLUMNS.map((col) => (
|
||||
<KanbanColumn
|
||||
key={col.status}
|
||||
label={col.label}
|
||||
color={col.color}
|
||||
bgColor={col.bgColor}
|
||||
orders={ordersByStatus[col.status] || []}
|
||||
onMoveToNext={handleMoveToNext}
|
||||
movingOrderId={movingOrderId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
src/pages/Settings.tsx
Normal file
375
src/pages/Settings.tsx
Normal file
@ -0,0 +1,375 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Building2,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Percent,
|
||||
Save,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { settingsApi } from '../services/api/settings';
|
||||
import type { TenantSettings, UpdateTenantSettingsRequest } from '../services/api/settings';
|
||||
|
||||
interface WorkshopSettingsForm {
|
||||
name: string;
|
||||
legal_name: string;
|
||||
rfc: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
default_tax_rate: number;
|
||||
labor_rate: number;
|
||||
working_hours_start: string;
|
||||
working_hours_end: string;
|
||||
quote_validity_days: number;
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current settings
|
||||
const { data: settingsData, isLoading, error } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => settingsApi.getSettings(),
|
||||
});
|
||||
|
||||
const settings: TenantSettings | undefined = settingsData?.data;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = useForm<WorkshopSettingsForm>();
|
||||
|
||||
// Populate form when settings load
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
reset({
|
||||
name: settings.name || '',
|
||||
legal_name: settings.legal_name || '',
|
||||
rfc: settings.rfc || '',
|
||||
address: settings.address || '',
|
||||
city: settings.city || '',
|
||||
state: settings.state || '',
|
||||
zip_code: settings.zip_code || '',
|
||||
phone: settings.phone || '',
|
||||
email: settings.email || '',
|
||||
website: settings.website || '',
|
||||
default_tax_rate: settings.default_tax_rate || 16,
|
||||
labor_rate: settings.labor_rate || 450,
|
||||
working_hours_start: settings.working_hours_start || '08:00',
|
||||
working_hours_end: settings.working_hours_end || '18:00',
|
||||
quote_validity_days: settings.quote_validity_days || 15,
|
||||
});
|
||||
}
|
||||
}, [settings, reset]);
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateTenantSettingsRequest) => settingsApi.updateSettings(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: WorkshopSettingsForm) => {
|
||||
updateMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar configuracion</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Configuracion</h1>
|
||||
<p className="text-sm text-gray-500">Ajustes del taller y preferencias del sistema</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Settings */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Workshop Info */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Building2 className="h-5 w-5 text-diesel-600" />
|
||||
Informacion del Taller
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Nombre Comercial
|
||||
</label>
|
||||
<input
|
||||
{...register('name')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Razon Social
|
||||
</label>
|
||||
<input
|
||||
{...register('legal_name')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Taller Mecanico SA de CV"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">RFC</label>
|
||||
<input
|
||||
{...register('rfc')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="TME123456XXX"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Telefono
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
{...register('phone')}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="81 1234 5678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="contacto@taller.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Sitio Web
|
||||
</label>
|
||||
<input
|
||||
{...register('website')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="www.taller.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<MapPin className="h-5 w-5 text-diesel-600" />
|
||||
Direccion
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Calle y Numero
|
||||
</label>
|
||||
<input
|
||||
{...register('address')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Av. Industria #123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Ciudad</label>
|
||||
<input
|
||||
{...register('city')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Monterrey"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Estado</label>
|
||||
<input
|
||||
{...register('state')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Nuevo Leon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Codigo Postal
|
||||
</label>
|
||||
<input
|
||||
{...register('zip_code')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="64000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Settings */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||
Configuracion Comercial
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Tasa de IVA (%)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Percent className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('default_tax_rate', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Tarifa Mano de Obra ($/hr)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('labor_rate', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Vigencia Cotizaciones (dias)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('quote_validity_days', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Working Hours */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Clock className="h-5 w-5 text-diesel-600" />
|
||||
Horario de Trabajo
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Hora de Apertura
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
{...register('working_hours_start')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Hora de Cierre
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
{...register('working_hours_end')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || !isDirty}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-diesel-600 px-4 py-3 font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
</>
|
||||
) : updateMutation.isSuccess ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Guardado
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Guardar Cambios
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{updateMutation.error && (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||
Error al guardar. Intente nuevamente.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-lg bg-gray-50 p-4 text-sm text-gray-600">
|
||||
<p className="flex items-center gap-2">
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
Los cambios se aplicaran inmediatamente a todo el sistema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
579
src/pages/Users.tsx
Normal file
579
src/pages/Users.tsx
Normal file
@ -0,0 +1,579 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Key,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { usersApi } from '../services/api';
|
||||
import type { UserDetail, UserRole, UserFilters } from '../types';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const ROLES: { value: UserRole; label: string; color: string }[] = [
|
||||
{ value: 'admin', label: 'Administrador', color: 'bg-purple-100 text-purple-800' },
|
||||
{ value: 'jefe_taller', label: 'Jefe de Taller', color: 'bg-blue-100 text-blue-800' },
|
||||
{ value: 'mecanico', label: 'Mecánico', color: 'bg-green-100 text-green-800' },
|
||||
{ value: 'recepcion', label: 'Recepción', color: 'bg-yellow-100 text-yellow-800' },
|
||||
{ value: 'almacen', label: 'Almacén', color: 'bg-gray-100 text-gray-800' },
|
||||
];
|
||||
|
||||
function getRoleInfo(role: string) {
|
||||
return ROLES.find((r) => r.value === role) || { label: role, color: 'bg-gray-100 text-gray-800' };
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const [filters, setFilters] = useState<UserFilters>({ page: 1, limit: 20 });
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState<UserDetail | null>(null);
|
||||
|
||||
// Fetch users
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['users', filters],
|
||||
queryFn: () => usersApi.list(filters),
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => usersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
const handleFilterRole = (role: UserRole | undefined) => {
|
||||
setFilters({ ...filters, role, page: 1 });
|
||||
};
|
||||
|
||||
const handleDelete = async (user: UserDetail) => {
|
||||
if (confirm(`¿Estás seguro de eliminar a ${user.full_name}?`)) {
|
||||
deleteMutation.mutate(user.id);
|
||||
}
|
||||
};
|
||||
|
||||
const canManageUsers = currentUser?.role === 'admin' || currentUser?.role === 'jefe_taller';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
|
||||
<p className="text-sm text-gray-500">Gestiona los usuarios del sistema</p>
|
||||
</div>
|
||||
{canManageUsers && (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nuevo Usuario
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre o email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<select
|
||||
value={filters.role || ''}
|
||||
onChange={(e) => handleFilterRole(e.target.value as UserRole || undefined)}
|
||||
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los roles</option>
|
||||
{ROLES.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.isActive === undefined ? '' : String(filters.isActive)}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
isActive: e.target.value === '' ? undefined : e.target.value === 'true',
|
||||
page: 1,
|
||||
})
|
||||
}
|
||||
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="true">Activos</option>
|
||||
<option value="false">Inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-diesel-600 border-t-transparent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-64 items-center justify-center text-red-600">
|
||||
Error al cargar usuarios
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Usuario
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Rol
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Último acceso
|
||||
</th>
|
||||
{canManageUsers && (
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Acciones
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{data?.data?.data?.map((user) => {
|
||||
const roleInfo = getRoleInfo(user.role);
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-diesel-100 text-diesel-600">
|
||||
{user.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.full_name}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-medium">
|
||||
{user.full_name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.full_name}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${roleInfo.color}`}>
|
||||
{roleInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
{user.isActive ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
|
||||
<Check className="h-3 w-3" />
|
||||
Activo
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
|
||||
<X className="h-3 w-3" />
|
||||
Inactivo
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{user.lastLoginAt
|
||||
? new Date(user.lastLoginAt).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: 'Nunca'}
|
||||
</td>
|
||||
{canManageUsers && (
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setEditingUser(user)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResetPasswordUser(user)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
title="Cambiar contraseña"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</button>
|
||||
{user.id !== currentUser?.id && (
|
||||
<button
|
||||
onClick={() => handleDelete(user)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
Mostrando {((data.data.page - 1) * data.data.pageSize) + 1} - {Math.min(data.data.page * data.data.pageSize, data.data.total)} de {data.data.total}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||
disabled={filters.page === 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
<UserFormModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{editingUser && (
|
||||
<UserFormModal
|
||||
user={editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSuccess={() => {
|
||||
setEditingUser(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{resetPasswordUser && (
|
||||
<ResetPasswordModal
|
||||
user={resetPasswordUser}
|
||||
onClose={() => setResetPasswordUser(null)}
|
||||
onSuccess={() => {
|
||||
setResetPasswordUser(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// User Form Modal Component
|
||||
function UserFormModal({
|
||||
user,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
user?: UserDetail;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
email: user?.email || '',
|
||||
password: '',
|
||||
fullName: user?.full_name || '',
|
||||
role: (user?.role as UserRole) || 'mecanico',
|
||||
isActive: user?.isActive ?? true,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (user) {
|
||||
await usersApi.update(user.id, {
|
||||
fullName: formData.fullName,
|
||||
role: formData.role,
|
||||
isActive: formData.isActive,
|
||||
});
|
||||
} else {
|
||||
await usersApi.create({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
fullName: formData.fullName,
|
||||
role: formData.role,
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.data?.error?.message) {
|
||||
setError(err.response.data.error.message);
|
||||
} else {
|
||||
setError('Error al guardar usuario');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
{user ? 'Editar Usuario' : 'Nuevo Usuario'}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!user && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!user && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nombre completo</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.fullName}
|
||||
onChange={(e) => setFormData({ ...formData, fullName: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Rol</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserRole })}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
{ROLES.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="rounded border-gray-300 text-diesel-600 focus:ring-diesel-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">
|
||||
Usuario activo
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Guardando...' : user ? 'Guardar cambios' : 'Crear usuario'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reset Password Modal
|
||||
function ResetPasswordModal({
|
||||
user,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
user: UserDetail;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError('Las contraseñas no coinciden');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await usersApi.resetPassword(user.id, { newPassword: password });
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.data?.error?.message) {
|
||||
setError(err.response.data.error.message);
|
||||
} else {
|
||||
setError('Error al cambiar contraseña');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
Cambiar contraseña de {user.full_name}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nueva contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Confirmar contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Guardando...' : 'Cambiar contraseña'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
591
src/pages/VehicleDetail.tsx
Normal file
591
src/pages/VehicleDetail.tsx
Normal file
@ -0,0 +1,591 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Truck,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Gauge,
|
||||
User,
|
||||
FileText,
|
||||
Wrench,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Settings,
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { vehiclesApi } from '../services/api/vehicles';
|
||||
import { customersApi } from '../services/api/customers';
|
||||
import { serviceOrdersApi, type ServiceOrder } from '../services/api/serviceOrders';
|
||||
import { ConfirmModal } from '../components/ui';
|
||||
import type { Vehicle, VehicleType, VehicleStatus } from '../types';
|
||||
|
||||
const VEHICLE_TYPE_LABELS: Record<VehicleType, string> = {
|
||||
truck: 'Camion',
|
||||
trailer: 'Trailer',
|
||||
bus: 'Autobus',
|
||||
pickup: 'Pickup',
|
||||
other: 'Otro',
|
||||
};
|
||||
|
||||
const VEHICLE_STATUS_CONFIG: Record<VehicleStatus, { label: string; color: string }> = {
|
||||
active: { label: 'Activo', color: 'bg-green-100 text-green-800' },
|
||||
inactive: { label: 'Inactivo', color: 'bg-gray-100 text-gray-800' },
|
||||
sold: { label: 'Vendido', color: 'bg-red-100 text-red-800' },
|
||||
};
|
||||
|
||||
const ORDER_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800' },
|
||||
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800' },
|
||||
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800' },
|
||||
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800' },
|
||||
in_repair: { label: 'En Reparacion', color: 'bg-orange-100 text-orange-800' },
|
||||
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800' },
|
||||
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800' },
|
||||
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800' },
|
||||
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500' },
|
||||
};
|
||||
|
||||
interface EditVehicleForm {
|
||||
licensePlate: string;
|
||||
economicNumber: string;
|
||||
make: string;
|
||||
model: string;
|
||||
year: number;
|
||||
color: string;
|
||||
vehicleType: VehicleType;
|
||||
vin: string;
|
||||
currentOdometer: number;
|
||||
notes: string;
|
||||
status: VehicleStatus;
|
||||
}
|
||||
|
||||
export function VehicleDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Fetch vehicle data
|
||||
const { data: vehicleData, isLoading, error } = useQuery({
|
||||
queryKey: ['vehicle', id],
|
||||
queryFn: () => vehiclesApi.getById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const vehicle: Vehicle | undefined = vehicleData?.data;
|
||||
|
||||
// Fetch customer data
|
||||
const { data: customerData } = useQuery({
|
||||
queryKey: ['customer', vehicle?.customerId],
|
||||
queryFn: () => customersApi.getById(vehicle!.customerId),
|
||||
enabled: !!vehicle?.customerId,
|
||||
});
|
||||
|
||||
const customer = customerData?.data;
|
||||
|
||||
// Fetch service history
|
||||
const { data: historyData } = useQuery({
|
||||
queryKey: ['vehicle-history', id],
|
||||
queryFn: () => serviceOrdersApi.getHistory(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const serviceHistory: ServiceOrder[] = historyData?.data || [];
|
||||
|
||||
// Form setup
|
||||
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<EditVehicleForm>();
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: EditVehicleForm) => vehiclesApi.update(id!, {
|
||||
licensePlate: data.licensePlate,
|
||||
economicNumber: data.economicNumber || undefined,
|
||||
make: data.make,
|
||||
model: data.model,
|
||||
year: data.year,
|
||||
color: data.color || undefined,
|
||||
vehicleType: data.vehicleType,
|
||||
vin: data.vin || undefined,
|
||||
currentOdometer: data.currentOdometer || undefined,
|
||||
notes: data.notes || undefined,
|
||||
status: data.status,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicle', id] });
|
||||
setIsEditing(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => vehiclesApi.delete(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
navigate('/vehicles');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
if (vehicle) {
|
||||
reset({
|
||||
licensePlate: vehicle.licensePlate,
|
||||
economicNumber: vehicle.economicNumber || '',
|
||||
make: vehicle.make,
|
||||
model: vehicle.model,
|
||||
year: vehicle.year,
|
||||
color: vehicle.color || '',
|
||||
vehicleType: vehicle.vehicleType,
|
||||
vin: vehicle.vin || '',
|
||||
currentOdometer: vehicle.currentOdometer || 0,
|
||||
notes: vehicle.notes || '',
|
||||
status: vehicle.status,
|
||||
});
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = (data: EditVehicleForm) => {
|
||||
updateMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !vehicle) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Vehiculo no encontrado</p>
|
||||
<Link to="/vehicles" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||
Volver a vehiculos
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = VEHICLE_STATUS_CONFIG[vehicle.status];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/vehicles"
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{vehicle.make} {vehicle.model} {vehicle.year}
|
||||
</h1>
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{vehicle.licensePlate}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Eliminar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={!isDirty || updateMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Guardar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Vehicle Info */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Main Info Card */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Truck className="h-5 w-5 text-diesel-600" />
|
||||
Informacion del Vehiculo
|
||||
</h2>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Placas</label>
|
||||
<input
|
||||
{...register('licensePlate', { required: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">No. Economico</label>
|
||||
<input
|
||||
{...register('economicNumber')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca</label>
|
||||
<input
|
||||
{...register('make', { required: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Modelo</label>
|
||||
<input
|
||||
{...register('model', { required: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Ano</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('year', { required: true, valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Color</label>
|
||||
<input
|
||||
{...register('color')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Tipo</label>
|
||||
<select
|
||||
{...register('vehicleType')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
{Object.entries(VEHICLE_TYPE_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Estado</label>
|
||||
<select
|
||||
{...register('status')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
{Object.entries(VEHICLE_STATUS_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">VIN</label>
|
||||
<input
|
||||
{...register('vin')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Odometro Actual</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('currentOdometer', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Placas</p>
|
||||
<p className="text-sm font-medium text-gray-900">{vehicle.licensePlate}</p>
|
||||
</div>
|
||||
{vehicle.economicNumber && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">No. Economico</p>
|
||||
<p className="text-sm font-medium text-gray-900">{vehicle.economicNumber}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Tipo</p>
|
||||
<p className="text-sm font-medium text-gray-900">{VEHICLE_TYPE_LABELS[vehicle.vehicleType]}</p>
|
||||
</div>
|
||||
{vehicle.color && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Color</p>
|
||||
<p className="text-sm font-medium text-gray-900">{vehicle.color}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.vin && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs font-medium text-gray-500">VIN</p>
|
||||
<p className="text-sm font-mono text-gray-900">{vehicle.vin}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.currentOdometer && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Odometro</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{vehicle.currentOdometer.toLocaleString()} km
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Registrado</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{new Date(vehicle.createdAt).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</div>
|
||||
{vehicle.notes && (
|
||||
<div className="col-span-full">
|
||||
<p className="text-xs font-medium text-gray-500">Notas</p>
|
||||
<p className="text-sm text-gray-600">{vehicle.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service History */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Wrench className="h-5 w-5 text-diesel-600" />
|
||||
Historial de Servicio
|
||||
</h2>
|
||||
<Link
|
||||
to={`/orders/new?vehicleId=${vehicle.id}`}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
Nueva Orden
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{serviceHistory.length === 0 ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center text-gray-400">
|
||||
<FileText className="mb-2 h-8 w-8" />
|
||||
<p className="text-sm">Sin historial de servicio</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{serviceHistory.map((order) => {
|
||||
const statusConfig = ORDER_STATUS_CONFIG[order.status] || ORDER_STATUS_CONFIG.received;
|
||||
return (
|
||||
<Link
|
||||
key={order.id}
|
||||
to={`/orders/${order.id}`}
|
||||
className="flex items-center justify-between py-3 hover:bg-gray-50 -mx-2 px-2 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-diesel-50">
|
||||
<Wrench className="h-5 w-5 text-diesel-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{order.order_number}</p>
|
||||
<p className="text-xs text-gray-500 line-clamp-1">{order.symptoms}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-right">
|
||||
<div>
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{new Date(order.received_at).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
${order.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Owner Card */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<User className="h-5 w-5 text-diesel-600" />
|
||||
Propietario
|
||||
</h2>
|
||||
|
||||
{customer ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Cliente</p>
|
||||
<Link
|
||||
to={`/customers?id=${customer.id}`}
|
||||
className="text-sm font-medium text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
{customer.name}
|
||||
</Link>
|
||||
</div>
|
||||
{customer.contact_name && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Contacto</p>
|
||||
<p className="text-sm text-gray-900">{customer.contact_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{customer.contact_phone && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Telefono</p>
|
||||
<p className="text-sm text-gray-900">{customer.contact_phone}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Cargando...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||
<Settings className="h-5 w-5 text-diesel-600" />
|
||||
Resumen
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Hash className="h-4 w-4" />
|
||||
Total Ordenes
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{serviceHistory.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Completadas
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{serviceHistory.filter(o => o.status === 'delivered').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4" />
|
||||
En Proceso
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{serviceHistory.filter(o => !['delivered', 'cancelled'].includes(o.status)).length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Odometro
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{vehicle.currentOdometer ? `${vehicle.currentOdometer.toLocaleString()} km` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
{vehicle.odometerUpdatedAt && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Ultima Lectura
|
||||
</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{new Date(vehicle.odometerUpdatedAt).toLocaleDateString('es-MX')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total Spent */}
|
||||
<div className="mt-4 border-t border-gray-100 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Total Invertido</span>
|
||||
<span className="text-lg font-bold text-diesel-600">
|
||||
${serviceHistory.reduce((sum, o) => sum + (o.status === 'delivered' ? o.grand_total : 0), 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
title="Eliminar Vehiculo"
|
||||
message={`¿Estas seguro de eliminar el vehiculo ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate})? Esta accion no se puede deshacer.`}
|
||||
confirmText="Eliminar"
|
||||
variant="danger"
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
564
src/pages/Vehicles.tsx
Normal file
564
src/pages/Vehicles.tsx
Normal file
@ -0,0 +1,564 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Truck,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
import { vehiclesApi } from '../services/api/vehicles';
|
||||
import { customersApi } from '../services/api/customers';
|
||||
import type { Vehicle, VehicleType, VehicleStatus, VehicleFilters } from '../types';
|
||||
import type { Customer } from '../services/api/customers';
|
||||
|
||||
const VEHICLE_TYPES: { value: VehicleType; label: string }[] = [
|
||||
{ value: 'truck', label: 'Camion' },
|
||||
{ value: 'trailer', label: 'Trailer' },
|
||||
{ value: 'bus', label: 'Autobus' },
|
||||
{ value: 'pickup', label: 'Pickup' },
|
||||
{ value: 'other', label: 'Otro' },
|
||||
];
|
||||
|
||||
const VEHICLE_STATUS: { value: VehicleStatus; label: string; color: string }[] = [
|
||||
{ value: 'active', label: 'Activo', color: 'bg-green-100 text-green-800' },
|
||||
{ value: 'inactive', label: 'Inactivo', color: 'bg-gray-100 text-gray-800' },
|
||||
{ value: 'sold', label: 'Vendido', color: 'bg-red-100 text-red-800' },
|
||||
];
|
||||
|
||||
const vehicleSchema = z.object({
|
||||
customerId: z.string().min(1, 'Cliente requerido'),
|
||||
licensePlate: z.string().min(1, 'Placas requeridas'),
|
||||
vin: z.string().optional(),
|
||||
economicNumber: z.string().optional(),
|
||||
make: z.string().min(1, 'Marca requerida'),
|
||||
model: z.string().min(1, 'Modelo requerido'),
|
||||
year: z.number().min(1900).max(new Date().getFullYear() + 1),
|
||||
color: z.string().optional(),
|
||||
vehicleType: z.enum(['truck', 'trailer', 'bus', 'pickup', 'other']),
|
||||
currentOdometer: z.number().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
type VehicleForm = z.infer<typeof vehicleSchema>;
|
||||
|
||||
export function VehiclesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filters, setFilters] = useState<VehicleFilters>({ page: 1, pageSize: 20 });
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
|
||||
const [customerSearch, setCustomerSearch] = useState('');
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<VehicleForm>({
|
||||
resolver: zodResolver(vehicleSchema),
|
||||
defaultValues: {
|
||||
vehicleType: 'truck',
|
||||
year: new Date().getFullYear(),
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch vehicles
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['vehicles', filters],
|
||||
queryFn: () => vehiclesApi.list(filters),
|
||||
});
|
||||
|
||||
// Fetch customers for search
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers', customerSearch],
|
||||
queryFn: () => customersApi.list({ search: customerSearch, pageSize: 10 }),
|
||||
enabled: customerSearch.length >= 2,
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: VehicleForm) => vehiclesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
handleCloseModal();
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<VehicleForm> }) =>
|
||||
vehiclesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
handleCloseModal();
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => vehiclesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
},
|
||||
});
|
||||
|
||||
const vehicles = data?.data?.data || [];
|
||||
const customers = customersData?.data?.data || [];
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingVehicle(null);
|
||||
setSelectedCustomer(null);
|
||||
setCustomerSearch('');
|
||||
reset({
|
||||
vehicleType: 'truck',
|
||||
year: new Date().getFullYear(),
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (vehicle: Vehicle) => {
|
||||
setEditingVehicle(vehicle);
|
||||
reset({
|
||||
customerId: vehicle.customerId,
|
||||
licensePlate: vehicle.licensePlate,
|
||||
vin: vehicle.vin,
|
||||
economicNumber: vehicle.economicNumber,
|
||||
make: vehicle.make,
|
||||
model: vehicle.model,
|
||||
year: vehicle.year,
|
||||
color: vehicle.color,
|
||||
vehicleType: vehicle.vehicleType,
|
||||
currentOdometer: vehicle.currentOdometer,
|
||||
notes: vehicle.notes,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingVehicle(null);
|
||||
setSelectedCustomer(null);
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleCustomerSelect = (customer: Customer) => {
|
||||
setSelectedCustomer(customer);
|
||||
setValue('customerId', customer.id);
|
||||
setShowCustomerDropdown(false);
|
||||
setCustomerSearch(customer.name);
|
||||
};
|
||||
|
||||
const onSubmit = (data: VehicleForm) => {
|
||||
if (editingVehicle) {
|
||||
updateMutation.mutate({ id: editingVehicle.id, data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (vehicle: Vehicle) => {
|
||||
if (confirm(`Eliminar vehiculo ${vehicle.make} ${vehicle.model}?`)) {
|
||||
deleteMutation.mutate(vehicle.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Vehiculos</h1>
|
||||
<p className="text-sm text-gray-500">Gestiona los vehiculos registrados</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nuevo Vehiculo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por placa, marca, modelo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<select
|
||||
value={filters.vehicleType || ''}
|
||||
onChange={(e) => setFilters({ ...filters, vehicleType: e.target.value as VehicleType || undefined, page: 1 })}
|
||||
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<AlertCircle className="mb-2 h-8 w-8" />
|
||||
<p>Error al cargar vehiculos</p>
|
||||
</div>
|
||||
) : vehicles.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||
<Truck className="mb-2 h-8 w-8" />
|
||||
<p>No hay vehiculos registrados</p>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||
>
|
||||
Registrar primer vehiculo
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Vehiculo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Placas
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Odometro
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{vehicles.map((vehicle: Vehicle) => {
|
||||
const typeLabel = VEHICLE_TYPES.find(t => t.value === vehicle.vehicleType)?.label || vehicle.vehicleType;
|
||||
const statusConfig = VEHICLE_STATUS.find(s => s.value === vehicle.status) || VEHICLE_STATUS[0];
|
||||
|
||||
return (
|
||||
<tr key={vehicle.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Truck className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{vehicle.make} {vehicle.model}</p>
|
||||
<p className="text-sm text-gray-500">{vehicle.year}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<p className="font-medium text-gray-900">{vehicle.licensePlate}</p>
|
||||
{vehicle.economicNumber && (
|
||||
<p className="text-sm text-gray-500">Eco: {vehicle.economicNumber}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{typeLabel}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{vehicle.currentOdometer ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Gauge className="h-4 w-4" />
|
||||
{vehicle.currentOdometer.toLocaleString()} km
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusConfig.color}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(vehicle)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(vehicle)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.data && vehicles.length > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
Pagina {data.data.page} de {data.data.totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||
disabled={(filters.page || 1) <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingVehicle ? 'Editar Vehiculo' : 'Nuevo Vehiculo'}
|
||||
</h2>
|
||||
<button onClick={handleCloseModal} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Customer Search */}
|
||||
{!editingVehicle && (
|
||||
<div className="relative">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Cliente *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar cliente..."
|
||||
value={customerSearch}
|
||||
onChange={(e) => {
|
||||
setCustomerSearch(e.target.value);
|
||||
setShowCustomerDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCustomerDropdown(true)}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCustomerDropdown && customers.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg max-h-48 overflow-y-auto">
|
||||
{customers.map((customer: Customer) => (
|
||||
<button
|
||||
key={customer.id}
|
||||
type="button"
|
||||
onClick={() => handleCustomerSelect(customer)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-gray-50"
|
||||
>
|
||||
<span className="font-medium">{customer.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCustomer && (
|
||||
<div className="mt-2 rounded-lg bg-diesel-50 px-3 py-2 text-sm text-diesel-700">
|
||||
Cliente: {selectedCustomer.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.customerId && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.customerId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca *</label>
|
||||
<input
|
||||
{...register('make')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Kenworth"
|
||||
/>
|
||||
{errors.make && <p className="mt-1 text-sm text-red-600">{errors.make.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Modelo *</label>
|
||||
<input
|
||||
{...register('model')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="T800"
|
||||
/>
|
||||
{errors.model && <p className="mt-1 text-sm text-red-600">{errors.model.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Ano *</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('year', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
{errors.year && <p className="mt-1 text-sm text-red-600">{errors.year.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Tipo</label>
|
||||
<select
|
||||
{...register('vehicleType')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Placas *</label>
|
||||
<input
|
||||
{...register('licensePlate')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="ABC-123-XY"
|
||||
/>
|
||||
{errors.licensePlate && <p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">No. Economico</label>
|
||||
<input
|
||||
{...register('economicNumber')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="ECO-001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">VIN</label>
|
||||
<input
|
||||
{...register('vin')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="1XKAD49X04J038445"
|
||||
maxLength={17}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Color</label>
|
||||
<input
|
||||
{...register('color')}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="Blanco"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Odometro (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('currentOdometer', { valueAsNumber: true })}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
placeholder="125000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||
>
|
||||
{(createMutation.isPending || updateMutation.isPending) && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{editingVehicle ? 'Guardar Cambios' : 'Crear Vehiculo'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/services/api/auth.ts
Normal file
112
src/services/api/auth.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { api } from './client';
|
||||
import type { User, ApiResponse } from '../../types';
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Backend user response (camelCase)
|
||||
interface BackendUser {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
avatarUrl?: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
emailVerified: boolean;
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Backend login response
|
||||
interface BackendLoginResponse {
|
||||
user: BackendUser;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
taller_name?: string;
|
||||
}
|
||||
|
||||
// Map backend user to frontend user
|
||||
function mapUser(backendUser: BackendUser): User {
|
||||
return {
|
||||
id: backendUser.id,
|
||||
email: backendUser.email,
|
||||
full_name: backendUser.fullName,
|
||||
avatar_url: backendUser.avatarUrl,
|
||||
role: backendUser.role,
|
||||
permissions: getRolePermissions(backendUser.role),
|
||||
};
|
||||
}
|
||||
|
||||
// Get permissions based on role
|
||||
function getRolePermissions(role: string): string[] {
|
||||
const permissionMap: Record<string, string[]> = {
|
||||
admin: ['*'],
|
||||
jefe_taller: ['service_orders.*', 'vehicles.*', 'users.read', 'users.create', 'quotes.*', 'diagnostics.*'],
|
||||
mecanico: ['service_orders.read', 'service_orders.update', 'vehicles.read', 'diagnostics.*'],
|
||||
recepcion: ['service_orders.create', 'service_orders.read', 'vehicles.*', 'quotes.*'],
|
||||
almacen: ['parts.*', 'inventory.*'],
|
||||
};
|
||||
return permissionMap[role] || [];
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: async (data: LoginRequest): Promise<ApiResponse<LoginResponse>> => {
|
||||
const response = await api.post<ApiResponse<BackendLoginResponse>>('/auth/login', data);
|
||||
return {
|
||||
success: response.success,
|
||||
data: {
|
||||
user: mapUser(response.data.user),
|
||||
token: response.data.token,
|
||||
refreshToken: response.data.refreshToken,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
register: async (data: RegisterRequest): Promise<ApiResponse<LoginResponse>> => {
|
||||
const response = await api.post<ApiResponse<BackendLoginResponse>>('/auth/register', data);
|
||||
return {
|
||||
success: response.success,
|
||||
data: {
|
||||
user: mapUser(response.data.user),
|
||||
token: response.data.token,
|
||||
refreshToken: response.data.refreshToken,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
logout: (refreshToken: string) =>
|
||||
api.post<ApiResponse<null>>('/auth/logout', { refreshToken }),
|
||||
|
||||
refreshToken: (refreshToken: string) =>
|
||||
api.post<ApiResponse<{ token: string }>>('/auth/refresh', { refreshToken }),
|
||||
|
||||
getProfile: async (): Promise<ApiResponse<User>> => {
|
||||
const response = await api.get<ApiResponse<BackendUser>>('/auth/profile');
|
||||
return {
|
||||
success: response.success,
|
||||
data: mapUser(response.data),
|
||||
};
|
||||
},
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api.post<ApiResponse<null>>('/auth/change-password', {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
};
|
||||
85
src/services/api/client.ts
Normal file
85
src/services/api/client.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
// API Base URL
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3011/api/v1';
|
||||
|
||||
// Create axios instance
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor - handle errors
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle 401 - Unauthorized
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = useAuthStore.getState().refreshToken;
|
||||
if (refreshToken) {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
const { token } = response.data.data;
|
||||
useAuthStore.getState().setToken(token);
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, logout user
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
|
||||
// Generic API methods
|
||||
export const api = {
|
||||
get: <T>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config).then((res) => res.data),
|
||||
|
||||
post: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
put: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.put<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
patch: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
delete: <T>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||
};
|
||||
71
src/services/api/customers.ts
Normal file
71
src/services/api/customers.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, PaginatedResult, BaseFilters } from '../../types';
|
||||
|
||||
// Customer types (using Fleet as customer for commercial customers)
|
||||
export interface Customer {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
contact_name?: string;
|
||||
contact_email?: string;
|
||||
contact_phone?: string;
|
||||
discount_labor_pct: number;
|
||||
discount_parts_pct: number;
|
||||
credit_days: number;
|
||||
credit_limit: number;
|
||||
vehicle_count: number;
|
||||
notes?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CustomerFilters extends BaseFilters {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateCustomerRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
contact_name?: string;
|
||||
contact_email?: string;
|
||||
contact_phone?: string;
|
||||
discount_labor_pct?: number;
|
||||
discount_parts_pct?: number;
|
||||
credit_days?: number;
|
||||
credit_limit?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCustomerRequest extends Partial<CreateCustomerRequest> {
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const customersApi = {
|
||||
list: (filters?: CustomerFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.pageSize) params.append('limit', String(filters.pageSize));
|
||||
|
||||
return api.get<ApiResponse<PaginatedResult<Customer>>>(`/fleets?${params.toString()}`);
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
api.get<ApiResponse<Customer>>(`/fleets/${id}`),
|
||||
|
||||
create: (data: CreateCustomerRequest) =>
|
||||
api.post<ApiResponse<Customer>>('/fleets', data),
|
||||
|
||||
update: (id: string, data: UpdateCustomerRequest) =>
|
||||
api.patch<ApiResponse<Customer>>(`/fleets/${id}`, data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`/fleets/${id}`),
|
||||
|
||||
// Get vehicles for a customer
|
||||
getVehicles: (customerId: string) =>
|
||||
api.get<ApiResponse<{ data: unknown[] }>>(`/vehicles?customerId=${customerId}`),
|
||||
};
|
||||
100
src/services/api/diagnostics.ts
Normal file
100
src/services/api/diagnostics.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, PaginatedResult, BaseFilters, DiagnosticType } from '../../types';
|
||||
|
||||
// Diagnostic Result type
|
||||
export type DiagnosticResult = 'pass' | 'fail' | 'needs_attention';
|
||||
|
||||
// Types
|
||||
export interface Diagnostic {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
order_id: string | null;
|
||||
order_number: string | null;
|
||||
vehicle_id: string;
|
||||
vehicle_info: string;
|
||||
customer_name: string;
|
||||
diagnostic_type: DiagnosticType;
|
||||
equipment: string | null;
|
||||
performed_at: string;
|
||||
performed_by: string | null;
|
||||
technician_name: string | null;
|
||||
result: DiagnosticResult | null;
|
||||
summary: string | null;
|
||||
raw_data: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface DiagnosticItem {
|
||||
id: string;
|
||||
diagnostic_id: string;
|
||||
component: string;
|
||||
test_name: string;
|
||||
value: string;
|
||||
unit: string | null;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
result: DiagnosticResult;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface CreateDiagnosticRequest {
|
||||
vehicle_id: string;
|
||||
order_id?: string;
|
||||
diagnostic_type: DiagnosticType;
|
||||
equipment?: string;
|
||||
performed_by?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticFilters extends BaseFilters {
|
||||
diagnostic_type?: DiagnosticType;
|
||||
vehicle_id?: string;
|
||||
order_id?: string;
|
||||
result?: DiagnosticResult;
|
||||
}
|
||||
|
||||
// API
|
||||
export const diagnosticsApi = {
|
||||
// List diagnostics with filters
|
||||
list: (filters?: DiagnosticFilters) =>
|
||||
api.get<ApiResponse<PaginatedResult<Diagnostic>>>('/diagnostics', {
|
||||
params: filters,
|
||||
}),
|
||||
|
||||
// Get single diagnostic
|
||||
getById: (id: string) =>
|
||||
api.get<ApiResponse<Diagnostic>>(`/diagnostics/${id}`),
|
||||
|
||||
// Create new diagnostic
|
||||
create: (data: CreateDiagnosticRequest) =>
|
||||
api.post<ApiResponse<Diagnostic>>('/diagnostics', data),
|
||||
|
||||
// Update diagnostic
|
||||
update: (id: string, data: Partial<Diagnostic>) =>
|
||||
api.patch<ApiResponse<Diagnostic>>(`/diagnostics/${id}`, data),
|
||||
|
||||
// Delete diagnostic
|
||||
delete: (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`/diagnostics/${id}`),
|
||||
|
||||
// Get diagnostic items
|
||||
getItems: (diagnosticId: string) =>
|
||||
api.get<ApiResponse<DiagnosticItem[]>>(`/diagnostics/${diagnosticId}/items`),
|
||||
|
||||
// Add item to diagnostic
|
||||
addItem: (diagnosticId: string, item: Partial<DiagnosticItem>) =>
|
||||
api.post<ApiResponse<DiagnosticItem>>(`/diagnostics/${diagnosticId}/items`, item),
|
||||
|
||||
// Update result
|
||||
setResult: (id: string, result: DiagnosticResult, summary?: string) =>
|
||||
api.patch<ApiResponse<Diagnostic>>(`/diagnostics/${id}`, { result, summary }),
|
||||
|
||||
// Get diagnostics by vehicle
|
||||
getByVehicle: (vehicleId: string) =>
|
||||
api.get<ApiResponse<Diagnostic[]>>(`/vehicles/${vehicleId}/diagnostics`),
|
||||
|
||||
// Get diagnostics by order
|
||||
getByOrder: (orderId: string) =>
|
||||
api.get<ApiResponse<Diagnostic[]>>(`/service-orders/${orderId}/diagnostics`),
|
||||
};
|
||||
20
src/services/api/index.ts
Normal file
20
src/services/api/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* API Services Index
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*/
|
||||
|
||||
export { authApi } from './auth';
|
||||
export { usersApi } from './users';
|
||||
export { serviceOrdersApi } from './serviceOrders';
|
||||
export { vehiclesApi } from './vehicles';
|
||||
export { partsApi } from './parts';
|
||||
export { customersApi } from './customers';
|
||||
export { quotesApi } from './quotes';
|
||||
export { diagnosticsApi } from './diagnostics';
|
||||
export { settingsApi } from './settings';
|
||||
export { api } from './client';
|
||||
export type { LoginRequest, LoginResponse, RegisterRequest } from './auth';
|
||||
export type { CreateUserRequest, UpdateUserRequest, ResetPasswordRequest } from './users';
|
||||
export type { CreateVehicleRequest, UpdateVehicleRequest } from './vehicles';
|
||||
export type { CreatePartRequest, UpdatePartRequest } from './parts';
|
||||
export type { Customer, CreateCustomerRequest, UpdateCustomerRequest } from './customers';
|
||||
77
src/services/api/parts.ts
Normal file
77
src/services/api/parts.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, Part, PartFilters, PaginatedResult } from '../../types';
|
||||
|
||||
export interface CreatePartRequest {
|
||||
sku: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
categoryId?: string;
|
||||
brand?: string;
|
||||
manufacturer?: string;
|
||||
compatibleEngines?: string[];
|
||||
cost?: number;
|
||||
price: number;
|
||||
currentStock?: number;
|
||||
minStock?: number;
|
||||
maxStock?: number;
|
||||
reorderPoint?: number;
|
||||
locationId?: string;
|
||||
unit?: string;
|
||||
barcode?: string;
|
||||
preferredSupplierId?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePartRequest {
|
||||
sku?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
categoryId?: string | null;
|
||||
brand?: string;
|
||||
manufacturer?: string;
|
||||
compatibleEngines?: string[];
|
||||
cost?: number;
|
||||
price?: number;
|
||||
currentStock?: number;
|
||||
minStock?: number;
|
||||
maxStock?: number | null;
|
||||
reorderPoint?: number | null;
|
||||
locationId?: string | null;
|
||||
unit?: string;
|
||||
barcode?: string;
|
||||
preferredSupplierId?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const partsApi = {
|
||||
list: (filters?: PartFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.categoryId) params.append('categoryId', filters.categoryId);
|
||||
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||
if (filters?.lowStock) params.append('lowStock', 'true');
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.pageSize) params.append('limit', String(filters.pageSize));
|
||||
|
||||
return api.get<ApiResponse<PaginatedResult<Part>>>(`/parts?${params.toString()}`);
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
api.get<ApiResponse<Part>>(`/parts/${id}`),
|
||||
|
||||
create: (data: CreatePartRequest) =>
|
||||
api.post<ApiResponse<Part>>('/parts', data),
|
||||
|
||||
update: (id: string, data: UpdatePartRequest) =>
|
||||
api.patch<ApiResponse<Part>>(`/parts/${id}`, data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`/parts/${id}`),
|
||||
|
||||
// Search by SKU or barcode
|
||||
search: (query: string) =>
|
||||
api.get<ApiResponse<Part[]>>(`/parts/search?q=${encodeURIComponent(query)}`),
|
||||
|
||||
// Get low stock items
|
||||
getLowStock: () =>
|
||||
api.get<ApiResponse<Part[]>>('/parts/low-stock'),
|
||||
};
|
||||
93
src/services/api/quotes.ts
Normal file
93
src/services/api/quotes.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, PaginatedResult, BaseFilters, QuoteStatus } from '../../types';
|
||||
|
||||
// Types
|
||||
export interface Quote {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
quote_number: string;
|
||||
customer_id: string;
|
||||
customer_name: string;
|
||||
vehicle_id: string;
|
||||
vehicle_info: string;
|
||||
status: QuoteStatus;
|
||||
valid_until: string;
|
||||
labor_total: number;
|
||||
parts_total: number;
|
||||
discount_amount: number;
|
||||
tax: number;
|
||||
grand_total: number;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface QuoteItem {
|
||||
id: string;
|
||||
quote_id: string;
|
||||
item_type: 'service' | 'part';
|
||||
service_id: string | null;
|
||||
part_id: string | null;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
discount: number;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
export interface CreateQuoteRequest {
|
||||
customer_id: string;
|
||||
vehicle_id: string;
|
||||
valid_days?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface QuoteFilters extends BaseFilters {
|
||||
status?: QuoteStatus;
|
||||
customer_id?: string;
|
||||
}
|
||||
|
||||
// API
|
||||
export const quotesApi = {
|
||||
// List quotes with filters
|
||||
list: (filters?: QuoteFilters) =>
|
||||
api.get<ApiResponse<PaginatedResult<Quote>>>('/quotes', {
|
||||
params: filters,
|
||||
}),
|
||||
|
||||
// Get single quote
|
||||
getById: (id: string) =>
|
||||
api.get<ApiResponse<Quote>>(`/quotes/${id}`),
|
||||
|
||||
// Create new quote
|
||||
create: (data: CreateQuoteRequest) =>
|
||||
api.post<ApiResponse<Quote>>('/quotes', data),
|
||||
|
||||
// Update quote
|
||||
update: (id: string, data: Partial<Quote>) =>
|
||||
api.patch<ApiResponse<Quote>>(`/quotes/${id}`, data),
|
||||
|
||||
// Change status
|
||||
changeStatus: (id: string, status: QuoteStatus) =>
|
||||
api.post<ApiResponse<Quote>>(`/quotes/${id}/status`, { status }),
|
||||
|
||||
// Get quote items
|
||||
getItems: (quoteId: string) =>
|
||||
api.get<ApiResponse<QuoteItem[]>>(`/quotes/${quoteId}/items`),
|
||||
|
||||
// Add item to quote
|
||||
addItem: (quoteId: string, item: Partial<QuoteItem>) =>
|
||||
api.post<ApiResponse<QuoteItem>>(`/quotes/${quoteId}/items`, item),
|
||||
|
||||
// Remove item from quote
|
||||
removeItem: (quoteId: string, itemId: string) =>
|
||||
api.delete<ApiResponse<null>>(`/quotes/${quoteId}/items/${itemId}`),
|
||||
|
||||
// Convert to service order
|
||||
convertToOrder: (quoteId: string) =>
|
||||
api.post<ApiResponse<{ orderId: string }>>(`/quotes/${quoteId}/convert`),
|
||||
|
||||
// Send to customer
|
||||
send: (quoteId: string, email?: string) =>
|
||||
api.post<ApiResponse<Quote>>(`/quotes/${quoteId}/send`, { email }),
|
||||
};
|
||||
138
src/services/api/serviceOrders.ts
Normal file
138
src/services/api/serviceOrders.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, PaginatedResult, BaseFilters, ServiceOrderStatus } from '../../types';
|
||||
|
||||
// Types
|
||||
export interface ServiceOrder {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
order_number: string;
|
||||
customer_id: string;
|
||||
customer_name: string;
|
||||
vehicle_id: string;
|
||||
vehicle_info: string;
|
||||
status: ServiceOrderStatus;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
received_at: string;
|
||||
promised_at: string | null;
|
||||
mechanic_id: string | null;
|
||||
mechanic_name: string | null;
|
||||
bay_id: string | null;
|
||||
bay_name: string | null;
|
||||
symptoms: string;
|
||||
notes: string | null;
|
||||
labor_total: number;
|
||||
parts_total: number;
|
||||
tax: number;
|
||||
grand_total: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceOrderItem {
|
||||
id: string;
|
||||
order_id: string;
|
||||
item_type: 'service' | 'part';
|
||||
service_id: string | null;
|
||||
part_id: string | null;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
discount: number;
|
||||
subtotal: number;
|
||||
actual_hours: number | null;
|
||||
performed_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateServiceOrderRequest {
|
||||
customer_id: string;
|
||||
vehicle_id: string;
|
||||
symptoms: string;
|
||||
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||
promised_at?: string;
|
||||
mechanic_id?: string;
|
||||
bay_id?: string;
|
||||
}
|
||||
|
||||
export interface ServiceOrderFilters extends BaseFilters {
|
||||
status?: ServiceOrderStatus;
|
||||
priority?: string;
|
||||
mechanic_id?: string;
|
||||
bay_id?: string;
|
||||
customer_id?: string;
|
||||
vehicle_id?: string;
|
||||
}
|
||||
|
||||
// API
|
||||
export const serviceOrdersApi = {
|
||||
// List orders with filters
|
||||
list: (filters?: ServiceOrderFilters) =>
|
||||
api.get<ApiResponse<PaginatedResult<ServiceOrder>>>('/service-orders', {
|
||||
params: filters,
|
||||
}),
|
||||
|
||||
// Get single order
|
||||
getById: (id: string) =>
|
||||
api.get<ApiResponse<ServiceOrder>>(`/service-orders/${id}`),
|
||||
|
||||
// Create new order
|
||||
create: (data: CreateServiceOrderRequest) =>
|
||||
api.post<ApiResponse<ServiceOrder>>('/service-orders', data),
|
||||
|
||||
// Update order
|
||||
update: (id: string, data: Partial<ServiceOrder>) =>
|
||||
api.patch<ApiResponse<ServiceOrder>>(`/service-orders/${id}`, data),
|
||||
|
||||
// Change status
|
||||
changeStatus: (id: string, status: ServiceOrderStatus, notes?: string) =>
|
||||
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/status`, {
|
||||
status,
|
||||
notes,
|
||||
}),
|
||||
|
||||
// Assign mechanic and bay
|
||||
assign: (id: string, mechanicId: string, bayId: string) =>
|
||||
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/assign`, {
|
||||
mechanic_id: mechanicId,
|
||||
bay_id: bayId,
|
||||
}),
|
||||
|
||||
// Get order items
|
||||
getItems: (orderId: string) =>
|
||||
api.get<ApiResponse<ServiceOrderItem[]>>(`/service-orders/${orderId}/items`),
|
||||
|
||||
// Add item to order
|
||||
addItem: (orderId: string, item: Partial<ServiceOrderItem>) =>
|
||||
api.post<ApiResponse<ServiceOrderItem>>(`/service-orders/${orderId}/items`, item),
|
||||
|
||||
// Remove item from order
|
||||
removeItem: (orderId: string, itemId: string) =>
|
||||
api.delete<ApiResponse<null>>(`/service-orders/${orderId}/items/${itemId}`),
|
||||
|
||||
// Close order
|
||||
close: (id: string, finalOdometer: number) =>
|
||||
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/close`, {
|
||||
final_odometer: finalOdometer,
|
||||
}),
|
||||
|
||||
// Get kanban view data
|
||||
getKanbanView: () =>
|
||||
api.get<ApiResponse<Record<ServiceOrderStatus, ServiceOrder[]>>>('/service-orders/kanban'),
|
||||
|
||||
// Get order history
|
||||
getHistory: (vehicleId: string) =>
|
||||
api.get<ApiResponse<ServiceOrder[]>>(`/vehicles/${vehicleId}/service-history`),
|
||||
|
||||
// Get dashboard stats
|
||||
getStats: () =>
|
||||
api.get<ApiResponse<DashboardStats>>('/service-orders/stats'),
|
||||
};
|
||||
|
||||
// Dashboard Stats type
|
||||
export interface DashboardStats {
|
||||
totalOrders: number;
|
||||
pendingOrders: number;
|
||||
inProgressOrders: number;
|
||||
completedToday: number;
|
||||
totalRevenue: number;
|
||||
averageTicket: number;
|
||||
}
|
||||
143
src/services/api/settings.ts
Normal file
143
src/services/api/settings.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse } from '../../types';
|
||||
|
||||
// Tenant/Workshop Settings
|
||||
export interface TenantSettings {
|
||||
id: string;
|
||||
name: string;
|
||||
legal_name: string;
|
||||
rfc: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
logo_url: string | null;
|
||||
default_tax_rate: number;
|
||||
labor_rate: number;
|
||||
working_hours_start: string;
|
||||
working_hours_end: string;
|
||||
quote_validity_days: number;
|
||||
currency: string;
|
||||
timezone: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenantSettingsRequest {
|
||||
name?: string;
|
||||
legal_name?: string;
|
||||
rfc?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip_code?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
logo_url?: string | null;
|
||||
default_tax_rate?: number;
|
||||
labor_rate?: number;
|
||||
working_hours_start?: string;
|
||||
working_hours_end?: string;
|
||||
quote_validity_days?: number;
|
||||
}
|
||||
|
||||
// Work Bay Types
|
||||
export interface WorkBay {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
bay_type: 'general' | 'diesel' | 'heavy_duty';
|
||||
status: 'available' | 'occupied' | 'maintenance';
|
||||
current_order_id: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkBayRequest {
|
||||
name: string;
|
||||
bay_type: 'general' | 'diesel' | 'heavy_duty';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWorkBayRequest {
|
||||
name?: string;
|
||||
bay_type?: 'general' | 'diesel' | 'heavy_duty';
|
||||
status?: 'available' | 'occupied' | 'maintenance';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Service Catalog
|
||||
export interface ServiceCatalogItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
default_price: number;
|
||||
estimated_hours: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateServiceCatalogItemRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
default_price: number;
|
||||
estimated_hours: number;
|
||||
}
|
||||
|
||||
// API
|
||||
export const settingsApi = {
|
||||
// Get current tenant settings
|
||||
getSettings: () =>
|
||||
api.get<ApiResponse<TenantSettings>>('/settings'),
|
||||
|
||||
// Update tenant settings
|
||||
updateSettings: (data: UpdateTenantSettingsRequest) =>
|
||||
api.patch<ApiResponse<TenantSettings>>('/settings', data),
|
||||
|
||||
// Upload logo
|
||||
uploadLogo: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
return api.post<ApiResponse<{ logo_url: string }>>('/settings/logo', formData);
|
||||
},
|
||||
|
||||
// Work Bays
|
||||
listBays: () =>
|
||||
api.get<ApiResponse<WorkBay[]>>('/settings/bays'),
|
||||
|
||||
createBay: (data: CreateWorkBayRequest) =>
|
||||
api.post<ApiResponse<WorkBay>>('/settings/bays', data),
|
||||
|
||||
updateBay: (id: string, data: UpdateWorkBayRequest) =>
|
||||
api.patch<ApiResponse<WorkBay>>(`/settings/bays/${id}`, data),
|
||||
|
||||
deleteBay: (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`/settings/bays/${id}`),
|
||||
|
||||
// Service Catalog
|
||||
listServices: () =>
|
||||
api.get<ApiResponse<ServiceCatalogItem[]>>('/settings/services'),
|
||||
|
||||
createService: (data: CreateServiceCatalogItemRequest) =>
|
||||
api.post<ApiResponse<ServiceCatalogItem>>('/settings/services', data),
|
||||
|
||||
updateService: (id: string, data: Partial<CreateServiceCatalogItemRequest>) =>
|
||||
api.patch<ApiResponse<ServiceCatalogItem>>(`/settings/services/${id}`, data),
|
||||
|
||||
deleteService: (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`/settings/services/${id}`),
|
||||
|
||||
toggleServiceActive: (id: string, isActive: boolean) =>
|
||||
api.patch<ApiResponse<ServiceCatalogItem>>(`/settings/services/${id}`, { is_active: isActive }),
|
||||
};
|
||||
49
src/services/api/users.ts
Normal file
49
src/services/api/users.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, UserDetail, UserFilters, PaginatedResult, UserRole } from '../../types';
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
role: UserRole;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
fullName?: string;
|
||||
role?: UserRole;
|
||||
isActive?: boolean;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
list: (filters?: UserFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.role) params.append('role', filters.role);
|
||||
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
return api.get<ApiResponse<PaginatedResult<UserDetail>>>(`/users?${params.toString()}`);
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
api.get<ApiResponse<UserDetail>>(`/users/${id}`),
|
||||
|
||||
create: (data: CreateUserRequest) =>
|
||||
api.post<ApiResponse<UserDetail>>('/users', data),
|
||||
|
||||
update: (id: string, data: UpdateUserRequest) =>
|
||||
api.patch<ApiResponse<UserDetail>>(`/users/${id}`, data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`/users/${id}`),
|
||||
|
||||
resetPassword: (id: string, data: ResetPasswordRequest) =>
|
||||
api.patch<ApiResponse<null>>(`/users/${id}/reset-password`, data),
|
||||
};
|
||||
71
src/services/api/vehicles.ts
Normal file
71
src/services/api/vehicles.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { api } from './client';
|
||||
import type {
|
||||
ApiResponse,
|
||||
Vehicle,
|
||||
VehicleFilters,
|
||||
PaginatedResult,
|
||||
VehicleType,
|
||||
VehicleStatus,
|
||||
} from '../../types';
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
customerId: string;
|
||||
fleetId?: string;
|
||||
vin?: string;
|
||||
licensePlate: string;
|
||||
economicNumber?: string;
|
||||
make: string;
|
||||
model: string;
|
||||
year: number;
|
||||
color?: string;
|
||||
vehicleType?: VehicleType;
|
||||
currentOdometer?: number;
|
||||
photoUrl?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateVehicleRequest {
|
||||
fleetId?: string | null;
|
||||
vin?: string;
|
||||
licensePlate?: string;
|
||||
economicNumber?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
color?: string;
|
||||
vehicleType?: VehicleType;
|
||||
currentOdometer?: number;
|
||||
photoUrl?: string | null;
|
||||
status?: VehicleStatus;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const vehiclesApi = {
|
||||
list: (filters?: VehicleFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.vehicleType) params.append('vehicleType', filters.vehicleType);
|
||||
if (filters?.customerId) params.append('customerId', filters.customerId);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.pageSize) params.append('limit', String(filters.pageSize));
|
||||
|
||||
return api.get<ApiResponse<PaginatedResult<Vehicle>>>(`/vehicles?${params.toString()}`);
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
api.get<ApiResponse<Vehicle>>(`/vehicles/${id}`),
|
||||
|
||||
create: (data: CreateVehicleRequest) =>
|
||||
api.post<ApiResponse<Vehicle>>('/vehicles', data),
|
||||
|
||||
update: (id: string, data: UpdateVehicleRequest) =>
|
||||
api.patch<ApiResponse<Vehicle>>(`/vehicles/${id}`, data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`/vehicles/${id}`),
|
||||
|
||||
// Get service history for a vehicle
|
||||
getServiceHistory: (vehicleId: string) =>
|
||||
api.get<ApiResponse<{ orders: unknown[] }>>(`/vehicles/${vehicleId}/service-history`),
|
||||
};
|
||||
56
src/store/authStore.ts
Normal file
56
src/store/authStore.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User, AuthState } from '../types';
|
||||
|
||||
interface AuthStore extends AuthState {
|
||||
// Actions
|
||||
login: (user: User, token: string, refreshToken: string) => void;
|
||||
logout: () => void;
|
||||
updateUser: (user: Partial<User>) => void;
|
||||
setToken: (token: string) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
// Actions
|
||||
login: (user, token, refreshToken) =>
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
refreshToken,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
|
||||
logout: () =>
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
}),
|
||||
|
||||
updateUser: (userData) =>
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, ...userData } : null,
|
||||
})),
|
||||
|
||||
setToken: (token) => set({ token }),
|
||||
}),
|
||||
{
|
||||
name: 'mecanicas-auth-storage',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
70
src/store/tallerStore.ts
Normal file
70
src/store/tallerStore.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Types
|
||||
export interface Taller {
|
||||
id: string;
|
||||
name: string;
|
||||
legal_name: string;
|
||||
rfc: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
logo_url?: string;
|
||||
}
|
||||
|
||||
export interface WorkBay {
|
||||
id: string;
|
||||
name: string;
|
||||
bay_type: 'general' | 'diesel' | 'heavy_duty';
|
||||
status: 'available' | 'occupied' | 'maintenance';
|
||||
current_order_id?: string;
|
||||
}
|
||||
|
||||
export interface TallerState {
|
||||
currentTaller: Taller | null;
|
||||
selectedBay: WorkBay | null;
|
||||
workBays: WorkBay[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface TallerStore extends TallerState {
|
||||
setTaller: (taller: Taller) => void;
|
||||
setSelectedBay: (bay: WorkBay | null) => void;
|
||||
setWorkBays: (bays: WorkBay[]) => void;
|
||||
updateBayStatus: (bayId: string, status: WorkBay['status']) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: TallerState = {
|
||||
currentTaller: null,
|
||||
selectedBay: null,
|
||||
workBays: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const useTallerStore = create<TallerStore>((set) => ({
|
||||
...initialState,
|
||||
|
||||
setTaller: (taller) => set({ currentTaller: taller }),
|
||||
|
||||
setSelectedBay: (bay) => set({ selectedBay: bay }),
|
||||
|
||||
setWorkBays: (bays) => set({ workBays: bays }),
|
||||
|
||||
updateBayStatus: (bayId, status) =>
|
||||
set((state) => ({
|
||||
workBays: state.workBays.map((bay) =>
|
||||
bay.id === bayId ? { ...bay, status } : bay
|
||||
),
|
||||
})),
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
66
src/store/toastStore.ts
Normal file
66
src/store/toastStore.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (toast) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const newToast: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
duration: toast.duration ?? 5000,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, newToast],
|
||||
}));
|
||||
|
||||
// Auto remove after duration
|
||||
if (newToast.duration && newToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, newToast.duration);
|
||||
}
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearToasts: () => {
|
||||
set({ toasts: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper functions for convenience
|
||||
export const toast = {
|
||||
success: (title: string, message?: string) =>
|
||||
useToastStore.getState().addToast({ type: 'success', title, message }),
|
||||
error: (title: string, message?: string) =>
|
||||
useToastStore.getState().addToast({ type: 'error', title, message }),
|
||||
warning: (title: string, message?: string) =>
|
||||
useToastStore.getState().addToast({ type: 'warning', title, message }),
|
||||
info: (title: string, message?: string) =>
|
||||
useToastStore.getState().addToast({ type: 'info', title, message }),
|
||||
};
|
||||
248
src/types/index.ts
Normal file
248
src/types/index.ts
Normal file
@ -0,0 +1,248 @@
|
||||
// =============================================================================
|
||||
// TIPOS BASE - ERP MECANICAS DIESEL
|
||||
// =============================================================================
|
||||
|
||||
// Tipos de paginacion
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Tipos de filtros
|
||||
export interface BaseFilters extends PaginationParams {
|
||||
search?: string;
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
// Tipos de auditoria
|
||||
export interface AuditFields {
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
}
|
||||
|
||||
// Tipo base para entidades
|
||||
export interface BaseEntity extends AuditFields {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
// Tipos de usuario y auth
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
avatar_url?: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
// Tipos de API response
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, string[]>;
|
||||
};
|
||||
}
|
||||
|
||||
// Status de ordenes de servicio
|
||||
export type ServiceOrderStatus =
|
||||
| 'received'
|
||||
| 'diagnosing'
|
||||
| 'quoted'
|
||||
| 'approved'
|
||||
| 'in_repair'
|
||||
| 'waiting_parts'
|
||||
| 'ready'
|
||||
| 'delivered'
|
||||
| 'cancelled';
|
||||
|
||||
// Tipos de diagnostico
|
||||
export type DiagnosticType =
|
||||
| 'obd_scanner'
|
||||
| 'injector_bench'
|
||||
| 'pump_bench'
|
||||
| 'measurements';
|
||||
|
||||
// Tipos de movimiento de inventario
|
||||
export type MovementType =
|
||||
| 'purchase'
|
||||
| 'sale'
|
||||
| 'transfer'
|
||||
| 'adjustment'
|
||||
| 'return'
|
||||
| 'production';
|
||||
|
||||
// Status de cotizacion
|
||||
export type QuoteStatus =
|
||||
| 'draft'
|
||||
| 'sent'
|
||||
| 'viewed'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'expired'
|
||||
| 'converted';
|
||||
|
||||
// =============================================================================
|
||||
// ENTIDADES DE DOMINIO
|
||||
// =============================================================================
|
||||
|
||||
// Service Order
|
||||
export type ServiceOrderPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
export interface ServiceOrder {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
orderNumber: string;
|
||||
customerId: string;
|
||||
vehicleId: string;
|
||||
quoteId?: string;
|
||||
assignedTo?: string;
|
||||
bayId?: string;
|
||||
status: ServiceOrderStatus;
|
||||
priority: ServiceOrderPriority;
|
||||
receivedAt: string;
|
||||
promisedAt?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
deliveredAt?: string;
|
||||
odometerIn?: number;
|
||||
odometerOut?: number;
|
||||
customerSymptoms?: string;
|
||||
laborTotal: number;
|
||||
partsTotal: number;
|
||||
discountAmount: number;
|
||||
discountPercent: number;
|
||||
tax: number;
|
||||
grandTotal: number;
|
||||
internalNotes?: string;
|
||||
customerNotes?: string;
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// Relations
|
||||
vehicle?: Vehicle;
|
||||
assignedUser?: User;
|
||||
}
|
||||
|
||||
// Vehicle
|
||||
export type VehicleType = 'truck' | 'trailer' | 'bus' | 'pickup' | 'other';
|
||||
export type VehicleStatus = 'active' | 'inactive' | 'sold';
|
||||
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
customerId: string;
|
||||
fleetId?: string;
|
||||
vin?: string;
|
||||
licensePlate: string;
|
||||
economicNumber?: string;
|
||||
make: string;
|
||||
model: string;
|
||||
year: number;
|
||||
color?: string;
|
||||
vehicleType: VehicleType;
|
||||
currentOdometer?: number;
|
||||
odometerUpdatedAt?: string;
|
||||
photoUrl?: string;
|
||||
status: VehicleStatus;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Part (Refaccion)
|
||||
export interface Part {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
categoryId?: string;
|
||||
brand?: string;
|
||||
manufacturer?: string;
|
||||
compatibleEngines?: string[];
|
||||
cost?: number;
|
||||
price: number;
|
||||
currentStock: number;
|
||||
reservedStock: number;
|
||||
minStock: number;
|
||||
maxStock?: number;
|
||||
reorderPoint?: number;
|
||||
locationId?: string;
|
||||
unit: string;
|
||||
barcode?: string;
|
||||
preferredSupplierId?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// User (para gestión de usuarios)
|
||||
export interface UserDetail extends User {
|
||||
tenantId: string;
|
||||
isActive: boolean;
|
||||
emailVerified: boolean;
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'jefe_taller' | 'mecanico' | 'recepcion' | 'almacen';
|
||||
|
||||
// Filters
|
||||
export interface ServiceOrderFilters extends BaseFilters {
|
||||
status?: ServiceOrderStatus;
|
||||
priority?: ServiceOrderPriority;
|
||||
assignedTo?: string;
|
||||
vehicleId?: string;
|
||||
}
|
||||
|
||||
export interface VehicleFilters extends BaseFilters {
|
||||
vehicleType?: VehicleType;
|
||||
customerId?: string;
|
||||
}
|
||||
|
||||
export interface PartFilters extends BaseFilters {
|
||||
categoryId?: string;
|
||||
isActive?: boolean;
|
||||
lowStock?: boolean;
|
||||
}
|
||||
|
||||
export interface UserFilters {
|
||||
role?: UserRole;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
39
tailwind.config.js
Normal file
39
tailwind.config.js
Normal file
@ -0,0 +1,39 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
diesel: {
|
||||
50: '#fef3c7',
|
||||
100: '#fde68a',
|
||||
200: '#fcd34d',
|
||||
300: '#fbbf24',
|
||||
400: '#f59e0b',
|
||||
500: '#d97706',
|
||||
600: '#b45309',
|
||||
700: '#92400e',
|
||||
800: '#78350f',
|
||||
900: '#451a03',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
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"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user