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:
rckrdmrd 2026-01-16 08:11:27 -06:00
parent 2a897e53e4
commit abff318db4
62 changed files with 15724 additions and 2 deletions

6
.env.example Normal file
View 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
View 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
View File

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

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View 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
View 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
View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,3 @@
export { MainLayout } from './MainLayout';
export { Sidebar } from './Sidebar';
export { Header } from './Header';

View 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
View 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>
);
}

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

View 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>
);
}

View 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
View 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
View 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>,
)

View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

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

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

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

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

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

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

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View 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
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})