Migración desde erp-mecanicas-diesel/frontend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2a897e53e4
commit
abff318db4
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# API Configuration
|
||||||
|
VITE_API_URL=http://localhost:3011/api/v1
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
VITE_APP_NAME=Mecanicas Diesel
|
||||||
|
VITE_APP_ENV=development
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
153
README.md
153
README.md
@ -1,3 +1,152 @@
|
|||||||
# erp-mecanicas-diesel-frontend-v2
|
# Frontend - ERP Mecanicas Diesel
|
||||||
|
|
||||||
Frontend de erp-mecanicas-diesel - Workspace V2
|
## Stack Tecnologico
|
||||||
|
|
||||||
|
| Tecnologia | Version | Proposito |
|
||||||
|
|------------|---------|-----------|
|
||||||
|
| React | 18.x | Framework UI |
|
||||||
|
| Vite | 6.x | Build tool |
|
||||||
|
| TypeScript | 5.x | Lenguaje |
|
||||||
|
| React Router | 6.x | Routing |
|
||||||
|
| Zustand | 5.x | State management |
|
||||||
|
| React Query | 5.x | Data fetching/caching |
|
||||||
|
| Tailwind CSS | 3.x | Styling |
|
||||||
|
| React Hook Form | 7.x | Formularios |
|
||||||
|
| Zod | 3.x | Validacion |
|
||||||
|
| Axios | 1.x | HTTP client |
|
||||||
|
| Lucide React | - | Iconos |
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── common/ # Componentes reutilizables (Button, Input, etc)
|
||||||
|
│ ├── layout/ # Layout principal (Sidebar, Header, MainLayout)
|
||||||
|
│ └── features/ # Componentes especificos por modulo
|
||||||
|
├── features/ # Logica por modulo/epic
|
||||||
|
│ ├── auth/ # Autenticacion
|
||||||
|
│ ├── service-orders/ # MMD-002: Ordenes de servicio
|
||||||
|
│ ├── diagnostics/ # MMD-003: Diagnosticos
|
||||||
|
│ ├── inventory/ # MMD-004: Inventario
|
||||||
|
│ ├── vehicles/ # MMD-005: Vehiculos
|
||||||
|
│ ├── quotes/ # MMD-006: Cotizaciones
|
||||||
|
│ └── settings/ # MMD-001: Configuracion
|
||||||
|
├── store/ # Zustand stores
|
||||||
|
│ ├── authStore.ts # Estado de autenticacion
|
||||||
|
│ └── tallerStore.ts # Estado del taller
|
||||||
|
├── services/
|
||||||
|
│ └── api/ # Clientes API
|
||||||
|
│ ├── client.ts # Axios instance con interceptors
|
||||||
|
│ ├── auth.ts # Endpoints de auth
|
||||||
|
│ └── serviceOrders.ts # Endpoints de ordenes
|
||||||
|
├── pages/ # Paginas/Vistas
|
||||||
|
│ ├── Login.tsx
|
||||||
|
│ └── Dashboard.tsx
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── types/ # TypeScript types
|
||||||
|
│ └── index.ts # Tipos base
|
||||||
|
├── utils/ # Utilidades
|
||||||
|
├── App.tsx # Router principal
|
||||||
|
├── main.tsx # Entry point
|
||||||
|
└── index.css # Tailwind imports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Desarrollo
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build produccion
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Preview build
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables de Entorno
|
||||||
|
|
||||||
|
Crear archivo `.env.local`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:3041/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modulos por Implementar
|
||||||
|
|
||||||
|
### MMD-001: Fundamentos (Sprint 1-2)
|
||||||
|
- [ ] Configuracion de taller (wizard)
|
||||||
|
- [ ] Gestion de roles
|
||||||
|
- [ ] Catalogo de servicios
|
||||||
|
- [ ] Gestion de bahias
|
||||||
|
|
||||||
|
### MMD-002: Ordenes de Servicio (Sprint 2-5)
|
||||||
|
- [ ] Lista de ordenes con filtros
|
||||||
|
- [ ] Detalle de orden
|
||||||
|
- [ ] Crear orden (wizard 4 pasos)
|
||||||
|
- [ ] Tablero Kanban
|
||||||
|
- [ ] Registro de trabajos
|
||||||
|
|
||||||
|
### MMD-003: Diagnosticos (Sprint 2-4)
|
||||||
|
- [ ] Lista de diagnosticos
|
||||||
|
- [ ] Scanner OBD (DTC codes)
|
||||||
|
- [ ] Pruebas de banco
|
||||||
|
- [ ] Galeria de fotos
|
||||||
|
|
||||||
|
### MMD-004: Inventario (Sprint 4-6)
|
||||||
|
- [ ] Catalogo de refacciones
|
||||||
|
- [ ] Kardex de movimientos
|
||||||
|
- [ ] Alertas de stock
|
||||||
|
- [ ] Recepcion de mercancia
|
||||||
|
|
||||||
|
### MMD-005: Vehiculos (Sprint 4-6)
|
||||||
|
- [ ] Lista de vehiculos
|
||||||
|
- [ ] Ficha tecnica
|
||||||
|
- [ ] Especificaciones de motor
|
||||||
|
- [ ] Historial de servicios
|
||||||
|
|
||||||
|
### MMD-006: Cotizaciones (Sprint 6)
|
||||||
|
- [ ] Lista de cotizaciones
|
||||||
|
- [ ] Crear cotizacion
|
||||||
|
- [ ] Generar PDF
|
||||||
|
- [ ] Envio por email/WhatsApp
|
||||||
|
|
||||||
|
## Convenciones
|
||||||
|
|
||||||
|
### Nombres de Archivos
|
||||||
|
- Componentes: `PascalCase.tsx`
|
||||||
|
- Hooks: `useCamelCase.ts`
|
||||||
|
- Stores: `camelCaseStore.ts`
|
||||||
|
- Types: `camelCase.types.ts`
|
||||||
|
- Services: `camelCase.ts`
|
||||||
|
|
||||||
|
### Estructura de Feature
|
||||||
|
```
|
||||||
|
features/{feature}/
|
||||||
|
├── components/ # Componentes UI
|
||||||
|
├── hooks/ # Custom hooks
|
||||||
|
├── types/ # TypeScript types
|
||||||
|
└── index.ts # Exports publicos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencias del Backend
|
||||||
|
|
||||||
|
Este frontend requiere el backend de mecanicas-diesel corriendo en el puerto 3041.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desde la raiz del proyecto
|
||||||
|
cd ../backend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*ERP Mecanicas Diesel - Sistema NEXUS*
|
||||||
|
*Creado: 2025-12-08*
|
||||||
|
|||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4344
package-lock.json
generated
Normal file
4344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"lucide-react": "^0.556.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.68.0",
|
||||||
|
"react-router-dom": "^6.30.2",
|
||||||
|
"zod": "^4.1.13",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.2",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
132
src/App.tsx
Normal file
132
src/App.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { MainLayout } from './components/layout';
|
||||||
|
import { ToastContainer } from './components/ui';
|
||||||
|
import { useAuthStore } from './store/authStore';
|
||||||
|
|
||||||
|
// Lazy load pages for code splitting
|
||||||
|
const Login = lazy(() => import('./pages/Login').then(m => ({ default: m.Login })));
|
||||||
|
const Register = lazy(() => import('./pages/Register').then(m => ({ default: m.Register })));
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })));
|
||||||
|
const UsersPage = lazy(() => import('./pages/Users').then(m => ({ default: m.UsersPage })));
|
||||||
|
const ServiceOrdersPage = lazy(() => import('./pages/ServiceOrders').then(m => ({ default: m.ServiceOrdersPage })));
|
||||||
|
const ServiceOrdersKanbanPage = lazy(() => import('./pages/ServiceOrdersKanban').then(m => ({ default: m.ServiceOrdersKanbanPage })));
|
||||||
|
const ServiceOrderNewPage = lazy(() => import('./pages/ServiceOrderNew').then(m => ({ default: m.ServiceOrderNewPage })));
|
||||||
|
const ServiceOrderDetailPage = lazy(() => import('./pages/ServiceOrderDetail').then(m => ({ default: m.ServiceOrderDetailPage })));
|
||||||
|
const VehiclesPage = lazy(() => import('./pages/Vehicles').then(m => ({ default: m.VehiclesPage })));
|
||||||
|
const VehicleDetailPage = lazy(() => import('./pages/VehicleDetail').then(m => ({ default: m.VehicleDetailPage })));
|
||||||
|
const InventoryPage = lazy(() => import('./pages/Inventory').then(m => ({ default: m.InventoryPage })));
|
||||||
|
const InventoryDetailPage = lazy(() => import('./pages/InventoryDetail').then(m => ({ default: m.InventoryDetailPage })));
|
||||||
|
const CustomersPage = lazy(() => import('./pages/Customers').then(m => ({ default: m.CustomersPage })));
|
||||||
|
const CustomerDetailPage = lazy(() => import('./pages/CustomerDetail').then(m => ({ default: m.CustomerDetailPage })));
|
||||||
|
const QuotesPage = lazy(() => import('./pages/Quotes').then(m => ({ default: m.QuotesPage })));
|
||||||
|
const QuoteDetailPage = lazy(() => import('./pages/QuoteDetail').then(m => ({ default: m.QuoteDetailPage })));
|
||||||
|
const SettingsPage = lazy(() => import('./pages/Settings').then(m => ({ default: m.SettingsPage })));
|
||||||
|
const DiagnosticsPage = lazy(() => import('./pages/Diagnostics').then(m => ({ default: m.DiagnosticsPage })));
|
||||||
|
const DiagnosticsNewPage = lazy(() => import('./pages/DiagnosticsNew').then(m => ({ default: m.DiagnosticsNewPage })));
|
||||||
|
const DiagnosticDetailPage = lazy(() => import('./pages/DiagnosticDetail').then(m => ({ default: m.DiagnosticDetailPage })));
|
||||||
|
|
||||||
|
// Loading fallback component
|
||||||
|
function PageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create React Query client
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Protected route wrapper
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public route wrapper (redirect if already authenticated)
|
||||||
|
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||||
|
<Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="orders" element={<ServiceOrdersPage />} />
|
||||||
|
<Route path="orders/kanban" element={<ServiceOrdersKanbanPage />} />
|
||||||
|
<Route path="orders/new" element={<ServiceOrderNewPage />} />
|
||||||
|
<Route path="orders/:id" element={<ServiceOrderDetailPage />} />
|
||||||
|
<Route path="diagnostics" element={<DiagnosticsPage />} />
|
||||||
|
<Route path="diagnostics/new" element={<DiagnosticsNewPage />} />
|
||||||
|
<Route path="diagnostics/:id" element={<DiagnosticDetailPage />} />
|
||||||
|
<Route path="inventory" element={<InventoryPage />} />
|
||||||
|
<Route path="inventory/:id" element={<InventoryDetailPage />} />
|
||||||
|
<Route path="vehicles" element={<VehiclesPage />} />
|
||||||
|
<Route path="vehicles/:id" element={<VehicleDetailPage />} />
|
||||||
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
|
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||||
|
<Route path="quotes" element={<QuotesPage />} />
|
||||||
|
<Route path="quotes/:id" element={<QuoteDetailPage />} />
|
||||||
|
<Route path="users" element={<UsersPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* 404 */}
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900">404</h1>
|
||||||
|
<p className="text-gray-500">Pagina no encontrada</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
<ToastContainer />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
50
src/components/layout/Header.tsx
Normal file
50
src/components/layout/Header.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Bell, Search } from 'lucide-react';
|
||||||
|
import { useTallerStore } from '../../store/tallerStore';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { selectedBay, workBays, setSelectedBay } = useTallerStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-16 shrink-0 items-center gap-4 border-b border-gray-200 bg-white px-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative w-96">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar ordenes, vehiculos, clientes..."
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bay selector */}
|
||||||
|
{workBays.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500">Bahia:</span>
|
||||||
|
<select
|
||||||
|
value={selectedBay?.id || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const bay = workBays.find((b) => b.id === e.target.value);
|
||||||
|
setSelectedBay(bay || null);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
>
|
||||||
|
<option value="">Sin bahia</option>
|
||||||
|
{workBays.map((bay) => (
|
||||||
|
<option key={bay.id} value={bay.id}>
|
||||||
|
{bay.name} ({bay.status === 'available' ? 'Disponible' : 'Ocupada'})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<button className="relative rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-red-500" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/layout/MainLayout.tsx
Normal file
23
src/components/layout/MainLayout.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { Header } from './Header';
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-100">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/layout/Sidebar.tsx
Normal file
135
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Wrench,
|
||||||
|
Stethoscope,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
FileText,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
Users,
|
||||||
|
Building2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTallerStore } from '../../store/tallerStore';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{ name: 'Ordenes de Servicio', href: '/orders', icon: Wrench },
|
||||||
|
{ name: 'Clientes', href: '/customers', icon: Building2 },
|
||||||
|
{ name: 'Vehiculos', href: '/vehicles', icon: Truck },
|
||||||
|
{ name: 'Inventario', href: '/inventory', icon: Package },
|
||||||
|
{ name: 'Cotizaciones', href: '/quotes', icon: FileText },
|
||||||
|
{ name: 'Diagnosticos', href: '/diagnostics', icon: Stethoscope },
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondaryNavigation = [
|
||||||
|
{ name: 'Usuarios', href: '/users', icon: Users },
|
||||||
|
{ name: 'Configuracion', href: '/settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const { logout, user } = useAuthStore();
|
||||||
|
const { currentTaller } = useTallerStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-64 flex-col bg-gray-900">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-16 shrink-0 items-center px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-diesel-500 flex items-center justify-center">
|
||||||
|
<Wrench className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-white">Mecanicas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Taller info */}
|
||||||
|
{currentTaller && (
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
<div className="rounded-lg bg-gray-800 px-3 py-2">
|
||||||
|
<p className="text-xs text-gray-400">Taller</p>
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{currentTaller.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex flex-1 flex-col px-4 py-4">
|
||||||
|
<ul role="list" className="flex flex-1 flex-col gap-1">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<li key={item.name}>
|
||||||
|
<NavLink
|
||||||
|
to={item.href}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`group flex gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-diesel-600 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 shrink-0" />
|
||||||
|
{item.name}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Secondary navigation */}
|
||||||
|
<ul role="list" className="mt-auto space-y-1">
|
||||||
|
{secondaryNavigation.map((item) => (
|
||||||
|
<li key={item.name}>
|
||||||
|
<NavLink
|
||||||
|
to={item.href}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`group flex gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 shrink-0" />
|
||||||
|
{item.name}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="group flex w-full gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5 shrink-0" />
|
||||||
|
Cerrar Sesion
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
{user && (
|
||||||
|
<div className="mt-4 border-t border-gray-800 pt-4">
|
||||||
|
<div className="flex items-center gap-3 px-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-gray-700 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
{user.full_name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{user.full_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 truncate">{user.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/layout/index.ts
Normal file
3
src/components/layout/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { MainLayout } from './MainLayout';
|
||||||
|
export { Sidebar } from './Sidebar';
|
||||||
|
export { Header } from './Header';
|
||||||
152
src/components/ui/LoadingSpinner.tsx
Normal file
152
src/components/ui/LoadingSpinner.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
export type SpinnerVariant = 'default' | 'primary' | 'white';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: SpinnerSize;
|
||||||
|
variant?: SpinnerVariant;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<SpinnerSize, string> = {
|
||||||
|
xs: 'h-3 w-3',
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-6 w-6',
|
||||||
|
lg: 'h-8 w-8',
|
||||||
|
xl: 'h-12 w-12',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VARIANT_CLASSES: Record<SpinnerVariant, string> = {
|
||||||
|
default: 'text-gray-500',
|
||||||
|
primary: 'text-diesel-600',
|
||||||
|
white: 'text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingSpinner({
|
||||||
|
size = 'md',
|
||||||
|
variant = 'primary',
|
||||||
|
className = '',
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<Loader2
|
||||||
|
className={`animate-spin ${SIZE_CLASSES[size]} ${VARIANT_CLASSES[variant]} ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full page loading state
|
||||||
|
interface PageLoaderProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLoader({ message = 'Cargando...' }: PageLoaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
<p className="mt-3 text-sm text-gray-500">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline loading state (for buttons, etc.)
|
||||||
|
interface InlineLoaderProps {
|
||||||
|
size?: SpinnerSize;
|
||||||
|
variant?: SpinnerVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineLoader({ size = 'sm', variant = 'primary' }: InlineLoaderProps) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
<LoadingSpinner size={size} variant={variant} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay loading state
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
message?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingOverlay({ isLoading, message, children }: LoadingOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{children}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
{message && <p className="mt-3 text-sm text-gray-500">{message}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skeleton loader for content placeholders
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
variant?: 'text' | 'circular' | 'rectangular';
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
className = '',
|
||||||
|
variant = 'text',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: SkeletonProps) {
|
||||||
|
const baseClasses = 'animate-pulse bg-gray-200';
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
text: 'rounded',
|
||||||
|
circular: 'rounded-full',
|
||||||
|
rectangular: 'rounded-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
|
||||||
|
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-configured skeleton rows
|
||||||
|
export function SkeletonText({ lines = 3 }: { lines?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
variant="text"
|
||||||
|
height={16}
|
||||||
|
width={i === lines - 1 ? '60%' : '100%'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Skeleton variant="circular" width={48} height={48} />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton variant="text" height={20} width="40%" />
|
||||||
|
<Skeleton variant="text" height={16} width="60%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<SkeletonText lines={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/components/ui/Modal.tsx
Normal file
275
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_CLASSES = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
full: 'max-w-4xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
size = 'md',
|
||||||
|
closeOnBackdrop = true,
|
||||||
|
closeOnEscape = true,
|
||||||
|
showCloseButton = true,
|
||||||
|
}: ModalProps) {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !closeOnEscape) return;
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, closeOnEscape, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Focus trap
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const modal = modalRef.current;
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const focusableElements = modal.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
const firstFocusable = focusableElements[0];
|
||||||
|
const lastFocusable = focusableElements[focusableElements.length - 1];
|
||||||
|
|
||||||
|
const handleTab = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstFocusable) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastFocusable?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastFocusable) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstFocusable?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleTab);
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
return () => document.removeEventListener('keydown', handleTab);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (closeOnBackdrop && e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title ? 'modal-title' : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className={`w-full ${SIZE_CLASSES[size]} animate-in fade-in zoom-in-95 rounded-xl bg-white shadow-xl`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{(title || showCloseButton) && (
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
|
{title && (
|
||||||
|
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{showCloseButton && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
aria-label="Cerrar"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-6 py-4">{children}</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation Modal variant
|
||||||
|
export interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
variant?: 'danger' | 'warning' | 'info';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_CLASSES = {
|
||||||
|
danger: 'bg-red-600 hover:bg-red-700',
|
||||||
|
warning: 'bg-yellow-600 hover:bg-yellow-700',
|
||||||
|
info: 'bg-diesel-600 hover:bg-diesel-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirmar',
|
||||||
|
cancelText = 'Cancelar',
|
||||||
|
variant = 'info',
|
||||||
|
isLoading = false,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${VARIANT_CLASSES[variant]}`}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Procesando...' : confirmText}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-600">{message}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Modal variant (useful for quick forms)
|
||||||
|
export interface FormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
submitText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
submitText = 'Guardar',
|
||||||
|
cancelText = 'Cancelar',
|
||||||
|
isLoading = false,
|
||||||
|
size = 'md',
|
||||||
|
}: FormModalProps) {
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
size={size}
|
||||||
|
closeOnBackdrop={!isLoading}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="modal-form"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Guardando...' : submitText}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="modal-form" onSubmit={handleSubmit}>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/ui/StatusBadge.tsx
Normal file
71
src/components/ui/StatusBadge.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'default' | 'primary';
|
||||||
|
export type BadgeSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
size?: BadgeSize;
|
||||||
|
children: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
dot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||||
|
success: 'bg-green-100 text-green-800',
|
||||||
|
warning: 'bg-yellow-100 text-yellow-800',
|
||||||
|
error: 'bg-red-100 text-red-800',
|
||||||
|
info: 'bg-blue-100 text-blue-800',
|
||||||
|
default: 'bg-gray-100 text-gray-800',
|
||||||
|
primary: 'bg-diesel-100 text-diesel-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOT_CLASSES: Record<BadgeVariant, string> = {
|
||||||
|
success: 'bg-green-500',
|
||||||
|
warning: 'bg-yellow-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
info: 'bg-blue-500',
|
||||||
|
default: 'bg-gray-500',
|
||||||
|
primary: 'bg-diesel-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<BadgeSize, string> = {
|
||||||
|
sm: 'px-1.5 py-0.5 text-xs',
|
||||||
|
md: 'px-2 py-0.5 text-xs',
|
||||||
|
lg: 'px-2.5 py-1 text-sm',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
variant = 'default',
|
||||||
|
size = 'md',
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
dot = false,
|
||||||
|
}: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full font-medium ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]}`}
|
||||||
|
>
|
||||||
|
{dot && <span className={`h-1.5 w-1.5 rounded-full ${DOT_CLASSES[variant]}`} />}
|
||||||
|
{icon && <span className="flex-shrink-0">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-configured badges for common statuses
|
||||||
|
export function SuccessBadge({ children, ...props }: Omit<StatusBadgeProps, 'variant'>) {
|
||||||
|
return <StatusBadge variant="success" {...props}>{children}</StatusBadge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarningBadge({ children, ...props }: Omit<StatusBadgeProps, 'variant'>) {
|
||||||
|
return <StatusBadge variant="warning" {...props}>{children}</StatusBadge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBadge({ children, ...props }: Omit<StatusBadgeProps, 'variant'>) {
|
||||||
|
return <StatusBadge variant="error" {...props}>{children}</StatusBadge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoBadge({ children, ...props }: Omit<StatusBadgeProps, 'variant'>) {
|
||||||
|
return <StatusBadge variant="info" {...props}>{children}</StatusBadge>;
|
||||||
|
}
|
||||||
95
src/components/ui/Toast.tsx
Normal file
95
src/components/ui/Toast.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
||||||
|
import { useToastStore, type Toast as ToastType, type ToastType as ToastVariant } from '../../store/toastStore';
|
||||||
|
|
||||||
|
const TOAST_CONFIG: Record<ToastVariant, { icon: typeof CheckCircle2; bgColor: string; borderColor: string; iconColor: string }> = {
|
||||||
|
success: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200',
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
bgColor: 'bg-red-50',
|
||||||
|
borderColor: 'border-red-200',
|
||||||
|
iconColor: 'text-red-600',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
bgColor: 'bg-yellow-50',
|
||||||
|
borderColor: 'border-yellow-200',
|
||||||
|
iconColor: 'text-yellow-600',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: Info,
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200',
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ToastItemProps {
|
||||||
|
toast: ToastType;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastItem({ toast, onRemove }: ToastItemProps) {
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const config = TOAST_CONFIG[toast.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
setIsExiting(true);
|
||||||
|
setTimeout(() => onRemove(toast.id), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto exit animation before removal
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast.duration && toast.duration > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsExiting(true);
|
||||||
|
}, toast.duration - 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [toast.duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex w-80 items-start gap-3 rounded-lg border p-4 shadow-lg transition-all duration-200 ${
|
||||||
|
config.bgColor
|
||||||
|
} ${config.borderColor} ${
|
||||||
|
isExiting ? 'translate-x-full opacity-0' : 'translate-x-0 opacity-100'
|
||||||
|
}`}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<Icon className={`h-5 w-5 flex-shrink-0 ${config.iconColor}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{toast.title}</p>
|
||||||
|
{toast.message && (
|
||||||
|
<p className="mt-1 text-sm text-gray-600">{toast.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="flex-shrink-0 rounded p-1 text-gray-400 hover:bg-gray-200/50 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastContainer() {
|
||||||
|
const { toasts, removeToast } = useToastStore();
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/ui/index.ts
Normal file
24
src/components/ui/index.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export { Modal, ConfirmModal, FormModal } from './Modal';
|
||||||
|
export type { ModalProps, ConfirmModalProps, FormModalProps } from './Modal';
|
||||||
|
|
||||||
|
export { ToastContainer } from './Toast';
|
||||||
|
|
||||||
|
export {
|
||||||
|
StatusBadge,
|
||||||
|
SuccessBadge,
|
||||||
|
WarningBadge,
|
||||||
|
ErrorBadge,
|
||||||
|
InfoBadge,
|
||||||
|
} from './StatusBadge';
|
||||||
|
export type { BadgeVariant, BadgeSize } from './StatusBadge';
|
||||||
|
|
||||||
|
export {
|
||||||
|
LoadingSpinner,
|
||||||
|
PageLoader,
|
||||||
|
InlineLoader,
|
||||||
|
LoadingOverlay,
|
||||||
|
Skeleton,
|
||||||
|
SkeletonText,
|
||||||
|
SkeletonCard,
|
||||||
|
} from './LoadingSpinner';
|
||||||
|
export type { SpinnerSize, SpinnerVariant } from './LoadingSpinner';
|
||||||
38
src/index.css
Normal file
38
src/index.css
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a1a1a1;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
688
src/pages/CustomerDetail.tsx
Normal file
688
src/pages/CustomerDetail.tsx
Normal file
@ -0,0 +1,688 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Building2,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Truck,
|
||||||
|
FileText,
|
||||||
|
DollarSign,
|
||||||
|
Percent,
|
||||||
|
Calendar,
|
||||||
|
CreditCard,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Wrench,
|
||||||
|
Plus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { customersApi, type Customer } from '../services/api/customers';
|
||||||
|
import { vehiclesApi } from '../services/api/vehicles';
|
||||||
|
import { serviceOrdersApi, type ServiceOrder } from '../services/api/serviceOrders';
|
||||||
|
import { ConfirmModal } from '../components/ui';
|
||||||
|
import { toast } from '../store/toastStore';
|
||||||
|
import type { Vehicle } from '../types';
|
||||||
|
|
||||||
|
interface EditCustomerForm {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
contact_name: string;
|
||||||
|
contact_email: string;
|
||||||
|
contact_phone: string;
|
||||||
|
discount_labor_pct: number;
|
||||||
|
discount_parts_pct: number;
|
||||||
|
credit_days: number;
|
||||||
|
credit_limit: number;
|
||||||
|
notes: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800' },
|
||||||
|
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800' },
|
||||||
|
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800' },
|
||||||
|
in_repair: { label: 'En Reparacion', color: 'bg-orange-100 text-orange-800' },
|
||||||
|
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800' },
|
||||||
|
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800' },
|
||||||
|
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800' },
|
||||||
|
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomerDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch customer data
|
||||||
|
const { data: customerData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['customer', id],
|
||||||
|
queryFn: () => customersApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer: Customer | undefined = customerData?.data;
|
||||||
|
|
||||||
|
// Fetch vehicles for this customer
|
||||||
|
const { data: vehiclesData } = useQuery({
|
||||||
|
queryKey: ['customer-vehicles', id],
|
||||||
|
queryFn: () => vehiclesApi.list({ customerId: id }),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vehicles: Vehicle[] = vehiclesData?.data?.data || [];
|
||||||
|
|
||||||
|
// Fetch service orders for this customer
|
||||||
|
const { data: ordersData } = useQuery({
|
||||||
|
queryKey: ['customer-orders', id],
|
||||||
|
queryFn: () => serviceOrdersApi.list({ customer_id: id, pageSize: 50 }),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const orders: ServiceOrder[] = ordersData?.data?.data || [];
|
||||||
|
|
||||||
|
// Form setup
|
||||||
|
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<EditCustomerForm>();
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: EditCustomerForm) => customersApi.update(id!, {
|
||||||
|
name: data.name,
|
||||||
|
code: data.code || undefined,
|
||||||
|
contact_name: data.contact_name || undefined,
|
||||||
|
contact_email: data.contact_email || undefined,
|
||||||
|
contact_phone: data.contact_phone || undefined,
|
||||||
|
discount_labor_pct: data.discount_labor_pct,
|
||||||
|
discount_parts_pct: data.discount_parts_pct,
|
||||||
|
credit_days: data.credit_days,
|
||||||
|
credit_limit: data.credit_limit,
|
||||||
|
notes: data.notes || undefined,
|
||||||
|
is_active: data.is_active,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customer', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
setIsEditing(false);
|
||||||
|
toast.success('Cliente actualizado', 'Los cambios se guardaron correctamente');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Error al guardar', 'No se pudieron guardar los cambios');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => customersApi.delete(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
toast.success('Cliente eliminado');
|
||||||
|
navigate('/customers');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Error al eliminar', 'No se pudo eliminar el cliente');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (customer) {
|
||||||
|
reset({
|
||||||
|
name: customer.name,
|
||||||
|
code: customer.code || '',
|
||||||
|
contact_name: customer.contact_name || '',
|
||||||
|
contact_email: customer.contact_email || '',
|
||||||
|
contact_phone: customer.contact_phone || '',
|
||||||
|
discount_labor_pct: customer.discount_labor_pct,
|
||||||
|
discount_parts_pct: customer.discount_parts_pct,
|
||||||
|
credit_days: customer.credit_days,
|
||||||
|
credit_limit: customer.credit_limit,
|
||||||
|
notes: customer.notes || '',
|
||||||
|
is_active: customer.is_active,
|
||||||
|
});
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: EditCustomerForm) => {
|
||||||
|
updateMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !customer) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Cliente no encontrado</p>
|
||||||
|
<Link to="/customers" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||||
|
Volver a clientes
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const totalSpent = orders
|
||||||
|
.filter(o => o.status === 'delivered')
|
||||||
|
.reduce((sum, o) => sum + o.grand_total, 0);
|
||||||
|
const pendingOrders = orders.filter(o => !['delivered', 'cancelled'].includes(o.status)).length;
|
||||||
|
const completedOrders = orders.filter(o => o.status === 'delivered').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/customers"
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{customer.name}</h1>
|
||||||
|
{customer.is_active ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||||
|
Activo
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
||||||
|
Inactivo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{customer.code && (
|
||||||
|
<p className="text-sm text-gray-500">Codigo: {customer.code}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
disabled={!isDirty || updateMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
Vehiculos
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-gray-900">{vehicles.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Ordenes Activas
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-orange-600">{pendingOrders}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Completadas
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-green-600">{completedOrders}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<DollarSign className="h-4 w-4" />
|
||||||
|
Total Facturado
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-diesel-600">
|
||||||
|
${totalSpent.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Customer Info */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Building2 className="h-5 w-5 text-diesel-600" />
|
||||||
|
Informacion del Cliente
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Nombre / Razon Social</label>
|
||||||
|
<input
|
||||||
|
{...register('name', { required: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Codigo</label>
|
||||||
|
<input
|
||||||
|
{...register('code')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_active"
|
||||||
|
{...register('is_active')}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-diesel-600 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_active" className="text-sm font-medium text-gray-700">
|
||||||
|
Cliente Activo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Nombre Contacto</label>
|
||||||
|
<input
|
||||||
|
{...register('contact_name')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Telefono</label>
|
||||||
|
<input
|
||||||
|
{...register('contact_phone')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
{...register('contact_email')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
||||||
|
<textarea
|
||||||
|
{...register('notes')}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
|
{customer.contact_name && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<User className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Contacto</p>
|
||||||
|
<p className="text-sm text-gray-900">{customer.contact_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.contact_phone && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Phone className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Telefono</p>
|
||||||
|
<p className="text-sm text-gray-900">{customer.contact_phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.contact_email && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Mail className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Email</p>
|
||||||
|
<p className="text-sm text-gray-900">{customer.contact_email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Calendar className="mt-0.5 h-4 w-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Cliente desde</p>
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{new Date(customer.created_at).toLocaleDateString('es-MX')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{customer.notes && (
|
||||||
|
<div className="col-span-full">
|
||||||
|
<p className="text-xs font-medium text-gray-500">Notas</p>
|
||||||
|
<p className="text-sm text-gray-600">{customer.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vehicles */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Truck className="h-5 w-5 text-diesel-600" />
|
||||||
|
Vehiculos ({vehicles.length})
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to={`/vehicles?customerId=${customer.id}`}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Agregar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vehicles.length === 0 ? (
|
||||||
|
<div className="flex h-24 flex-col items-center justify-center text-gray-400">
|
||||||
|
<Truck className="mb-2 h-8 w-8" />
|
||||||
|
<p className="text-sm">Sin vehiculos registrados</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{vehicles.slice(0, 5).map((vehicle) => (
|
||||||
|
<Link
|
||||||
|
key={vehicle.id}
|
||||||
|
to={`/vehicles/${vehicle.id}`}
|
||||||
|
className="-mx-2 flex items-center justify-between rounded-lg px-2 py-3 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-diesel-50">
|
||||||
|
<Truck className="h-5 w-5 text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{vehicle.make} {vehicle.model} {vehicle.year}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{vehicle.licensePlate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
vehicle.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{vehicle.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{vehicles.length > 5 && (
|
||||||
|
<div className="pt-3 text-center">
|
||||||
|
<Link to={`/vehicles?customerId=${customer.id}`} className="text-sm text-diesel-600 hover:text-diesel-700">
|
||||||
|
Ver todos ({vehicles.length})
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service History */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Wrench className="h-5 w-5 text-diesel-600" />
|
||||||
|
Historial de Servicio
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to={`/orders/new?customerId=${customer.id}`}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nueva Orden
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<div className="flex h-24 flex-col items-center justify-center text-gray-400">
|
||||||
|
<FileText className="mb-2 h-8 w-8" />
|
||||||
|
<p className="text-sm">Sin historial de servicio</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{orders.slice(0, 10).map((order) => {
|
||||||
|
const statusConfig = ORDER_STATUS_CONFIG[order.status] || ORDER_STATUS_CONFIG.received;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={order.id}
|
||||||
|
to={`/orders/${order.id}`}
|
||||||
|
className="-mx-2 flex items-center justify-between rounded-lg px-2 py-3 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-diesel-50">
|
||||||
|
<Wrench className="h-5 w-5 text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{order.order_number}</p>
|
||||||
|
<p className="text-xs text-gray-500">{order.vehicle_info}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
<p className="mt-1 text-sm font-medium text-gray-900">
|
||||||
|
${order.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{orders.length > 10 && (
|
||||||
|
<div className="pt-3 text-center">
|
||||||
|
<Link to={`/orders?customerId=${customer.id}`} className="text-sm text-diesel-600 hover:text-diesel-700">
|
||||||
|
Ver todas ({orders.length})
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Commercial Terms */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<CreditCard className="h-5 w-5 text-diesel-600" />
|
||||||
|
Condiciones Comerciales
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Desc. Mano de Obra (%)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Percent className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('discount_labor_pct', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Desc. Refacciones (%)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Percent className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('discount_parts_pct', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Dias de Credito</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('credit_days', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Limite de Credito</label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('credit_limit', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Percent className="h-4 w-4" />
|
||||||
|
Desc. Mano de Obra
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{customer.discount_labor_pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Percent className="h-4 w-4" />
|
||||||
|
Desc. Refacciones
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{customer.discount_parts_pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Dias de Credito
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{customer.credit_days} dias</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
Limite de Credito
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
${customer.credit_limit.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||||
|
Resumen Financiero
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Total Facturado</span>
|
||||||
|
<span className="text-sm font-bold text-diesel-600">
|
||||||
|
${totalSpent.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Ticket Promedio</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
${completedOrders > 0 ? (totalSpent / completedOrders).toLocaleString('es-MX', { minimumFractionDigits: 2 }) : '0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Ordenes Totales</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{orders.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Indicator */}
|
||||||
|
<div className="mt-4 border-t border-gray-100 pt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{pendingOrders > 0 ? (
|
||||||
|
<>
|
||||||
|
<Clock className="h-5 w-5 text-orange-500" />
|
||||||
|
<span className="text-sm font-medium text-orange-600">{pendingOrders} ordenes pendientes</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-600">Sin ordenes pendientes</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showDeleteModal}
|
||||||
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
title="Eliminar Cliente"
|
||||||
|
message={`¿Estas seguro de eliminar a "${customer.name}"? Esta accion no se puede deshacer y eliminara toda la informacion asociada.`}
|
||||||
|
confirmText="Eliminar"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
473
src/pages/Customers.tsx
Normal file
473
src/pages/Customers.tsx
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Truck,
|
||||||
|
CreditCard,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { customersApi } from '../services/api/customers';
|
||||||
|
import type { Customer, CustomerFilters } from '../services/api/customers';
|
||||||
|
|
||||||
|
const customerSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Nombre requerido'),
|
||||||
|
code: z.string().optional(),
|
||||||
|
contact_name: z.string().optional(),
|
||||||
|
contact_email: z.string().email('Email invalido').optional().or(z.literal('')),
|
||||||
|
contact_phone: z.string().optional(),
|
||||||
|
discount_labor_pct: z.number().min(0).max(100).optional(),
|
||||||
|
discount_parts_pct: z.number().min(0).max(100).optional(),
|
||||||
|
credit_days: z.number().min(0).optional(),
|
||||||
|
credit_limit: z.number().min(0).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CustomerForm = z.infer<typeof customerSchema>;
|
||||||
|
|
||||||
|
export function CustomersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [filters, setFilters] = useState<CustomerFilters>({ page: 1, pageSize: 20 });
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CustomerForm>({
|
||||||
|
resolver: zodResolver(customerSchema),
|
||||||
|
defaultValues: {
|
||||||
|
discount_labor_pct: 0,
|
||||||
|
discount_parts_pct: 0,
|
||||||
|
credit_days: 0,
|
||||||
|
credit_limit: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch customers
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['customers', filters],
|
||||||
|
queryFn: () => customersApi.list(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CustomerForm) => customersApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<CustomerForm> }) =>
|
||||||
|
customersApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => customersApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const customers = data?.data?.data || [];
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingCustomer(null);
|
||||||
|
reset({
|
||||||
|
discount_labor_pct: 0,
|
||||||
|
discount_parts_pct: 0,
|
||||||
|
credit_days: 0,
|
||||||
|
credit_limit: 0,
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (customer: Customer) => {
|
||||||
|
setEditingCustomer(customer);
|
||||||
|
reset({
|
||||||
|
name: customer.name,
|
||||||
|
code: customer.code,
|
||||||
|
contact_name: customer.contact_name,
|
||||||
|
contact_email: customer.contact_email,
|
||||||
|
contact_phone: customer.contact_phone,
|
||||||
|
discount_labor_pct: customer.discount_labor_pct,
|
||||||
|
discount_parts_pct: customer.discount_parts_pct,
|
||||||
|
credit_days: customer.credit_days,
|
||||||
|
credit_limit: customer.credit_limit,
|
||||||
|
notes: customer.notes,
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingCustomer(null);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: CustomerForm) => {
|
||||||
|
// Clean empty email
|
||||||
|
const cleanData = {
|
||||||
|
...data,
|
||||||
|
contact_email: data.contact_email || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCustomer) {
|
||||||
|
updateMutation.mutate({ id: editingCustomer.id, data: cleanData });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(cleanData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (customer: Customer) => {
|
||||||
|
if (confirm(`Eliminar cliente ${customer.name}?`)) {
|
||||||
|
deleteMutation.mutate(customer.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
|
||||||
|
<p className="text-sm text-gray-500">Gestiona los clientes y flotas</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nuevo Cliente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre, codigo..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="col-span-full flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="col-span-full flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar clientes</p>
|
||||||
|
</div>
|
||||||
|
) : customers.length === 0 ? (
|
||||||
|
<div className="col-span-full flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<Building2 className="mb-2 h-8 w-8" />
|
||||||
|
<p>No hay clientes registrados</p>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Registrar primer cliente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
customers.map((customer: Customer) => (
|
||||||
|
<div
|
||||||
|
key={customer.id}
|
||||||
|
className="rounded-xl border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<Link to={`/customers/${customer.id}`} className="flex items-center gap-3 hover:opacity-80">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-diesel-100">
|
||||||
|
<Building2 className="h-6 w-6 text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{customer.name}</h3>
|
||||||
|
{customer.code && (
|
||||||
|
<p className="text-sm text-gray-500">{customer.code}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(customer)}
|
||||||
|
className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(customer)}
|
||||||
|
className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{customer.contact_name && (
|
||||||
|
<p className="text-sm text-gray-600">{customer.contact_name}</p>
|
||||||
|
)}
|
||||||
|
{customer.contact_phone && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Phone className="h-3.5 w-3.5" />
|
||||||
|
{customer.contact_phone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{customer.contact_email && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
{customer.contact_email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t border-gray-100 pt-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
<span>{customer.vehicle_count}</span>
|
||||||
|
</div>
|
||||||
|
{customer.credit_limit > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
<span>${customer.credit_limit.toLocaleString('es-MX')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/customers/${customer.id}`}
|
||||||
|
className="flex items-center gap-1 text-sm font-medium text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Ver detalle
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data?.data && customers.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Pagina {data.data.page} de {data.data.totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||||
|
disabled={(filters.page || 1) <= 1}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||||
|
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{editingCustomer ? 'Editar Cliente' : 'Nuevo Cliente'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={handleCloseModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Nombre de Empresa *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('name')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Transportes del Norte SA"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Codigo</label>
|
||||||
|
<input
|
||||||
|
{...register('code')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="TDN-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Nombre Contacto
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('contact_name')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Juan Perez"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Telefono</label>
|
||||||
|
<input
|
||||||
|
{...register('contact_phone')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="81 1234 5678"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
{...register('contact_email')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="contacto@empresa.com"
|
||||||
|
/>
|
||||||
|
{errors.contact_email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.contact_email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-4">
|
||||||
|
<h3 className="mb-3 text-sm font-medium text-gray-700">Condiciones Comerciales</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Descuento Mano de Obra (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('discount_labor_pct', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Descuento Refacciones (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('discount_parts_pct', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Dias de Credito
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('credit_days', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Limite de Credito
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('credit_limit', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
||||||
|
<textarea
|
||||||
|
{...register('notes')}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{(createMutation.isPending || updateMutation.isPending) && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{editingCustomer ? 'Guardar Cambios' : 'Crear Cliente'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
src/pages/Dashboard.tsx
Normal file
307
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Wrench,
|
||||||
|
Truck,
|
||||||
|
Package,
|
||||||
|
FileText,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||||
|
import { partsApi } from '../services/api/parts';
|
||||||
|
import { customersApi } from '../services/api/customers';
|
||||||
|
import type { ServiceOrder, DashboardStats } from '../services/api/serviceOrders';
|
||||||
|
import type { ServiceOrderStatus, Part } from '../types';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string }> = {
|
||||||
|
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800' },
|
||||||
|
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800' },
|
||||||
|
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800' },
|
||||||
|
in_repair: { label: 'En Reparacion', color: 'bg-orange-100 text-orange-800' },
|
||||||
|
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800' },
|
||||||
|
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800' },
|
||||||
|
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800' },
|
||||||
|
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
// Fetch dashboard stats
|
||||||
|
const { data: statsData, isLoading: statsLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: () => serviceOrdersApi.getStats(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch recent orders
|
||||||
|
const { data: ordersData, isLoading: ordersLoading } = useQuery({
|
||||||
|
queryKey: ['recent-orders'],
|
||||||
|
queryFn: () => serviceOrdersApi.list({ page: 1, pageSize: 5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch low stock parts
|
||||||
|
const { data: lowStockData } = useQuery({
|
||||||
|
queryKey: ['low-stock-parts'],
|
||||||
|
queryFn: () => partsApi.getLowStock(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch customer count
|
||||||
|
const { data: customersData } = useQuery({
|
||||||
|
queryKey: ['customers-count'],
|
||||||
|
queryFn: () => customersApi.list({ pageSize: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats: DashboardStats = statsData?.data || {
|
||||||
|
totalOrders: 0,
|
||||||
|
pendingOrders: 0,
|
||||||
|
inProgressOrders: 0,
|
||||||
|
completedToday: 0,
|
||||||
|
totalRevenue: 0,
|
||||||
|
averageTicket: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recentOrders = ordersData?.data?.data || [];
|
||||||
|
const lowStockParts: Part[] = lowStockData?.data || [];
|
||||||
|
const totalCustomers = customersData?.data?.total || 0;
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ name: 'Ordenes Activas', value: stats.pendingOrders + stats.inProgressOrders, icon: Wrench, color: 'bg-blue-500' },
|
||||||
|
{ name: 'En Proceso', value: stats.inProgressOrders, icon: Clock, color: 'bg-orange-500' },
|
||||||
|
{ name: 'Completadas Hoy', value: stats.completedToday, icon: CheckCircle, color: 'bg-green-500' },
|
||||||
|
{ name: 'Total Clientes', value: totalCustomers, icon: Users, color: 'bg-purple-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p className="text-sm text-gray-500">Resumen de operaciones del taller</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<div
|
||||||
|
key={stat.name}
|
||||||
|
className="flex items-center gap-4 rounded-lg bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className={`rounded-lg ${stat.color} p-3`}>
|
||||||
|
<stat.icon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="h-8 w-12 animate-pulse rounded bg-gray-200" />
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500">{stat.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenue stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
|
||||||
|
<div className="rounded-lg bg-emerald-500 p-3">
|
||||||
|
<FileText className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Ingresos Totales</p>
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="h-8 w-24 animate-pulse rounded bg-gray-200" />
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
${stats.totalRevenue.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
|
||||||
|
<div className="rounded-lg bg-indigo-500 p-3">
|
||||||
|
<Truck className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Ticket Promedio</p>
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="h-8 w-24 animate-pulse rounded bg-gray-200" />
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
${stats.averageTicket.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* Recent orders */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Ordenes Recientes</h2>
|
||||||
|
<Link to="/orders" className="text-sm text-diesel-600 hover:text-diesel-700">
|
||||||
|
Ver todas
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{ordersLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-16 animate-pulse rounded-lg bg-gray-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : recentOrders.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<Wrench className="mb-2 h-8 w-8" />
|
||||||
|
<p>No hay ordenes recientes</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentOrders.map((order: ServiceOrder) => {
|
||||||
|
const statusConfig = STATUS_CONFIG[order.status as ServiceOrderStatus] || STATUS_CONFIG.received;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/orders/${order.id}`}
|
||||||
|
key={order.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-gray-100 p-3 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||||
|
<Truck className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{order.order_number}</p>
|
||||||
|
<p className="text-sm text-gray-500">{order.vehicle_info}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{order.customer_name}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Acciones Rapidas
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Link
|
||||||
|
to="/orders/new"
|
||||||
|
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Wrench className="h-8 w-8 text-diesel-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Nueva Orden</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/diagnostics/new"
|
||||||
|
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors"
|
||||||
|
>
|
||||||
|
<TrendingUp className="h-8 w-8 text-diesel-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Nuevo Diagnostico</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/vehicles"
|
||||||
|
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Truck className="h-8 w-8 text-diesel-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Ver Vehiculos</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/inventory"
|
||||||
|
className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Package className="h-8 w-8 text-diesel-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Ver Inventario</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Low Stock Alert */}
|
||||||
|
{lowStockParts.length > 0 && (
|
||||||
|
<div className="mt-4 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||||
|
<span className="font-medium text-yellow-800">Stock Bajo ({lowStockParts.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lowStockParts.slice(0, 3).map((part) => (
|
||||||
|
<Link
|
||||||
|
key={part.id}
|
||||||
|
to={`/inventory/${part.id}`}
|
||||||
|
className="flex items-center justify-between text-sm hover:bg-yellow-100 rounded px-2 py-1 -mx-2"
|
||||||
|
>
|
||||||
|
<span className="text-yellow-800">{part.name}</span>
|
||||||
|
<span className="font-medium text-yellow-900">
|
||||||
|
{part.currentStock} / {part.minStock} {part.unit}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{lowStockParts.length > 3 && (
|
||||||
|
<Link to="/inventory?lowStock=true" className="text-sm text-yellow-700 hover:text-yellow-800">
|
||||||
|
Ver todos ({lowStockParts.length})
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Today's schedule */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-sm lg:col-span-2">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Resumen del Dia
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Clock className="h-5 w-5 text-yellow-500" />
|
||||||
|
<span className="font-medium text-gray-900">Pendientes</span>
|
||||||
|
</div>
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="h-9 w-12 animate-pulse rounded bg-gray-200" />
|
||||||
|
) : (
|
||||||
|
<p className="text-3xl font-bold text-gray-900">{stats.pendingOrders}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500">ordenes por iniciar</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Wrench className="h-5 w-5 text-blue-500" />
|
||||||
|
<span className="font-medium text-gray-900">En Proceso</span>
|
||||||
|
</div>
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="h-9 w-12 animate-pulse rounded bg-gray-200" />
|
||||||
|
) : (
|
||||||
|
<p className="text-3xl font-bold text-gray-900">{stats.inProgressOrders}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500">ordenes en reparacion</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
|
<span className="font-medium text-gray-900">Completadas Hoy</span>
|
||||||
|
</div>
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="h-9 w-12 animate-pulse rounded bg-gray-200" />
|
||||||
|
) : (
|
||||||
|
<p className="text-3xl font-bold text-gray-900">{stats.completedToday}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500">ordenes entregadas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
402
src/pages/DiagnosticDetail.tsx
Normal file
402
src/pages/DiagnosticDetail.tsx
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Stethoscope,
|
||||||
|
Truck,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Cpu,
|
||||||
|
Gauge,
|
||||||
|
Cog,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Printer,
|
||||||
|
Trash2,
|
||||||
|
ClipboardList,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { diagnosticsApi } from '../services/api/diagnostics';
|
||||||
|
import type { Diagnostic, DiagnosticResult, DiagnosticItem } from '../services/api/diagnostics';
|
||||||
|
import type { DiagnosticType } from '../types';
|
||||||
|
|
||||||
|
const DIAGNOSTIC_TYPES: Record<DiagnosticType, { label: string; icon: typeof Cpu }> = {
|
||||||
|
obd_scanner: { label: 'Scanner OBD', icon: Cpu },
|
||||||
|
injector_bench: { label: 'Banco Inyectores', icon: Gauge },
|
||||||
|
pump_bench: { label: 'Banco Bomba', icon: Cog },
|
||||||
|
measurements: { label: 'Mediciones', icon: Stethoscope },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_CONFIG: Record<DiagnosticResult, { label: string; color: string; bgColor: string; icon: typeof CheckCircle2 }> = {
|
||||||
|
pass: { label: 'Aprobado', color: 'text-green-700', bgColor: 'bg-green-100', icon: CheckCircle2 },
|
||||||
|
fail: { label: 'Fallo', color: 'text-red-700', bgColor: 'bg-red-100', icon: XCircle },
|
||||||
|
needs_attention: { label: 'Requiere Atencion', color: 'text-yellow-700', bgColor: 'bg-yellow-100', icon: AlertTriangle },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiagnosticDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch diagnostic
|
||||||
|
const { data: diagnosticData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['diagnostic', id],
|
||||||
|
queryFn: () => diagnosticsApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const diagnostic: Diagnostic | undefined = diagnosticData?.data;
|
||||||
|
|
||||||
|
// Fetch diagnostic items
|
||||||
|
const { data: itemsData } = useQuery({
|
||||||
|
queryKey: ['diagnostic-items', id],
|
||||||
|
queryFn: () => diagnosticsApi.getItems(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: DiagnosticItem[] = itemsData?.data || [];
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => diagnosticsApi.delete(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['diagnostics'] });
|
||||||
|
navigate('/diagnostics');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update result mutation
|
||||||
|
const updateResultMutation = useMutation({
|
||||||
|
mutationFn: ({ result, summary }: { result: DiagnosticResult; summary?: string }) =>
|
||||||
|
diagnosticsApi.setResult(id!, result, summary),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['diagnostic', id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm('¿Seguro que desea eliminar este diagnostico?')) {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetResult = (result: DiagnosticResult) => {
|
||||||
|
updateResultMutation.mutate({ result });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !diagnostic) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar el diagnostico</p>
|
||||||
|
<Link to="/diagnostics" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||||
|
Volver a diagnosticos
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig = DIAGNOSTIC_TYPES[diagnostic.diagnostic_type];
|
||||||
|
const TypeIcon = typeConfig?.icon || Stethoscope;
|
||||||
|
const resultConfig = diagnostic.result ? RESULT_CONFIG[diagnostic.result] : null;
|
||||||
|
const ResultIcon = resultConfig?.icon;
|
||||||
|
|
||||||
|
// Calculate item statistics
|
||||||
|
const passCount = items.filter((i: DiagnosticItem) => i.result === 'pass').length;
|
||||||
|
const failCount = items.filter((i: DiagnosticItem) => i.result === 'fail').length;
|
||||||
|
const attentionCount = items.filter((i: DiagnosticItem) => i.result === 'needs_attention').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/diagnostics"
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Diagnostico</h1>
|
||||||
|
<p className="text-sm text-gray-500">{typeConfig?.label || diagnostic.diagnostic_type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
Imprimir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Vehicle Info */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Truck className="h-5 w-5 text-diesel-600" />
|
||||||
|
Vehiculo
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gray-100">
|
||||||
|
<Truck className="h-7 w-7 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{diagnostic.vehicle_info}</p>
|
||||||
|
<p className="text-gray-500">{diagnostic.customer_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{diagnostic.order_number && (
|
||||||
|
<div className="mt-4 flex items-center gap-2 rounded-lg bg-gray-50 p-3">
|
||||||
|
<ClipboardList className="h-5 w-5 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-600">Orden de Servicio:</span>
|
||||||
|
<Link
|
||||||
|
to={`/orders/${diagnostic.order_id}`}
|
||||||
|
className="font-medium text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
{diagnostic.order_number}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagnostic Items */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<TypeIcon className="h-5 w-5 text-diesel-600" />
|
||||||
|
Pruebas Realizadas
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="flex items-center gap-1 text-green-600">
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> {passCount}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-red-600">
|
||||||
|
<XCircle className="h-4 w-4" /> {failCount}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-yellow-600">
|
||||||
|
<AlertTriangle className="h-4 w-4" /> {attentionCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-lg bg-gray-50 p-8 text-center text-gray-500">
|
||||||
|
<Stethoscope className="mx-auto mb-2 h-8 w-8" />
|
||||||
|
<p>No hay pruebas registradas</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Componente
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Prueba
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Valor
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Rango
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Resultado
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{items.map((item: DiagnosticItem) => {
|
||||||
|
const itemResultConfig = RESULT_CONFIG[item.result];
|
||||||
|
const ItemIcon = itemResultConfig.icon;
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 font-medium text-gray-900">
|
||||||
|
{item.component}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-gray-700">
|
||||||
|
{item.test_name}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-gray-700">
|
||||||
|
{item.value} {item.unit}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||||
|
{item.min_value !== null && item.max_value !== null
|
||||||
|
? `${item.min_value} - ${item.max_value} ${item.unit || ''}`
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${itemResultConfig.bgColor} ${itemResultConfig.color}`}
|
||||||
|
>
|
||||||
|
<ItemIcon className="h-3 w-3" />
|
||||||
|
{itemResultConfig.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.some((i: DiagnosticItem) => i.notes) && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">Notas</h3>
|
||||||
|
{items
|
||||||
|
.filter((i: DiagnosticItem) => i.notes)
|
||||||
|
.map((item: DiagnosticItem) => (
|
||||||
|
<div key={item.id} className="rounded-lg bg-gray-50 p-3 text-sm">
|
||||||
|
<span className="font-medium text-gray-700">{item.component}:</span>{' '}
|
||||||
|
<span className="text-gray-600">{item.notes}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Result */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">Resultado General</h2>
|
||||||
|
|
||||||
|
{diagnostic.result ? (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-lg p-4 ${resultConfig?.bgColor}`}
|
||||||
|
>
|
||||||
|
{ResultIcon && <ResultIcon className={`h-6 w-6 ${resultConfig?.color}`} />}
|
||||||
|
<span className={`text-lg font-semibold ${resultConfig?.color}`}>
|
||||||
|
{resultConfig?.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg bg-gray-50 p-4 text-center text-gray-500">
|
||||||
|
Pendiente de evaluacion
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-gray-700">Establecer Resultado:</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{Object.entries(RESULT_CONFIG).map(([key, config]) => {
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => handleSetResult(key as DiagnosticResult)}
|
||||||
|
disabled={updateResultMutation.isPending}
|
||||||
|
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-xs transition-colors ${
|
||||||
|
diagnostic.result === key
|
||||||
|
? `${config.bgColor} ${config.color} border-current`
|
||||||
|
: 'border-gray-200 text-gray-500 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{config.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">Detalles</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<TypeIcon className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Tipo</p>
|
||||||
|
<p className="font-medium text-gray-900">{typeConfig?.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{diagnostic.equipment && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<Cpu className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Equipo</p>
|
||||||
|
<p className="font-medium text-gray-900">{diagnostic.equipment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<Calendar className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Fecha</p>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{new Date(diagnostic.performed_at).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{diagnostic.technician_name && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<User className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Tecnico</p>
|
||||||
|
<p className="font-medium text-gray-900">{diagnostic.technician_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{diagnostic.summary && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">Observaciones</h2>
|
||||||
|
<p className="text-gray-700">{diagnostic.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
src/pages/Diagnostics.tsx
Normal file
279
src/pages/Diagnostics.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Stethoscope,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Truck,
|
||||||
|
Calendar,
|
||||||
|
Cpu,
|
||||||
|
Gauge,
|
||||||
|
Cog,
|
||||||
|
Eye,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { diagnosticsApi } from '../services/api/diagnostics';
|
||||||
|
import type { Diagnostic, DiagnosticResult, DiagnosticFilters } from '../services/api/diagnostics';
|
||||||
|
import type { DiagnosticType } from '../types';
|
||||||
|
|
||||||
|
const DIAGNOSTIC_TYPES: { value: DiagnosticType; label: string; icon: typeof Cpu }[] = [
|
||||||
|
{ value: 'obd_scanner', label: 'Scanner OBD', icon: Cpu },
|
||||||
|
{ value: 'injector_bench', label: 'Banco Inyectores', icon: Gauge },
|
||||||
|
{ value: 'pump_bench', label: 'Banco Bomba', icon: Cog },
|
||||||
|
{ value: 'measurements', label: 'Mediciones', icon: Stethoscope },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESULT_CONFIG: Record<DiagnosticResult, { label: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||||
|
pass: { label: 'Aprobado', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
|
||||||
|
fail: { label: 'Fallo', color: 'bg-red-100 text-red-700', icon: XCircle },
|
||||||
|
needs_attention: { label: 'Requiere Atencion', color: 'bg-yellow-100 text-yellow-700', icon: AlertTriangle },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiagnosticsPage() {
|
||||||
|
const [filters, setFilters] = useState<DiagnosticFilters>({ page: 1, pageSize: 20 });
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Fetch diagnostics
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['diagnostics', filters],
|
||||||
|
queryFn: () => diagnosticsApi.list(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
const diagnostics = data?.data?.data || [];
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeFilter = (type: DiagnosticType | undefined) => {
|
||||||
|
setFilters({ ...filters, diagnostic_type: type, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Diagnosticos</h1>
|
||||||
|
<p className="text-sm text-gray-500">Pruebas y analisis de vehiculos</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/diagnostics/new"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nuevo Diagnostico
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Filters */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{DIAGNOSTIC_TYPES.map((type) => {
|
||||||
|
const Icon = type.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
onClick={() => handleTypeFilter(filters.diagnostic_type === type.value ? undefined : type.value)}
|
||||||
|
className={`flex items-center gap-3 rounded-lg border p-4 transition-colors ${
|
||||||
|
filters.diagnostic_type === type.value
|
||||||
|
? 'border-diesel-500 bg-diesel-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-5 w-5 ${filters.diagnostic_type === type.value ? 'text-diesel-600' : 'text-gray-400'}`} />
|
||||||
|
<span className="text-sm font-medium text-gray-700">{type.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por vehiculo, cliente..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={filters.result || ''}
|
||||||
|
onChange={(e) => setFilters({ ...filters, result: e.target.value as DiagnosticResult || undefined, page: 1 })}
|
||||||
|
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Todos los resultados</option>
|
||||||
|
{Object.entries(RESULT_CONFIG).map(([key, config]) => (
|
||||||
|
<option key={key} value={key}>{config.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar diagnosticos</p>
|
||||||
|
</div>
|
||||||
|
) : diagnostics.length === 0 ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<Stethoscope className="mb-2 h-8 w-8" />
|
||||||
|
<p>No hay diagnosticos registrados</p>
|
||||||
|
<Link
|
||||||
|
to="/diagnostics/new"
|
||||||
|
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Realizar primer diagnostico
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Vehiculo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Tipo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Resultado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Orden
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Fecha
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{diagnostics.map((diagnostic: Diagnostic) => {
|
||||||
|
const typeConfig = DIAGNOSTIC_TYPES.find(t => t.value === diagnostic.diagnostic_type);
|
||||||
|
const TypeIcon = typeConfig?.icon || Stethoscope;
|
||||||
|
const resultConfig = diagnostic.result ? RESULT_CONFIG[diagnostic.result] : null;
|
||||||
|
const ResultIcon = resultConfig?.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={diagnostic.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<Truck className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{diagnostic.vehicle_info}</p>
|
||||||
|
<p className="text-sm text-gray-500">{diagnostic.customer_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TypeIcon className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-700">{typeConfig?.label || diagnostic.diagnostic_type}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
{resultConfig ? (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${resultConfig.color}`}>
|
||||||
|
{ResultIcon && <ResultIcon className="h-3 w-3" />}
|
||||||
|
{resultConfig.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400">Pendiente</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
{diagnostic.order_number ? (
|
||||||
|
<Link
|
||||||
|
to={`/orders/${diagnostic.order_id}`}
|
||||||
|
className="text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
{diagnostic.order_number}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{new Date(diagnostic.performed_at).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<Link
|
||||||
|
to={`/diagnostics/${diagnostic.id}`}
|
||||||
|
className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data?.data && diagnostics.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Pagina {data.data.page} de {data.data.totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||||
|
disabled={(filters.page || 1) <= 1}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||||
|
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
526
src/pages/DiagnosticsNew.tsx
Normal file
526
src/pages/DiagnosticsNew.tsx
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Stethoscope,
|
||||||
|
Truck,
|
||||||
|
Search,
|
||||||
|
Cpu,
|
||||||
|
Gauge,
|
||||||
|
Cog,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { diagnosticsApi } from '../services/api/diagnostics';
|
||||||
|
import { vehiclesApi } from '../services/api/vehicles';
|
||||||
|
import type { DiagnosticResult } from '../services/api/diagnostics';
|
||||||
|
import type { DiagnosticType, Vehicle } from '../types';
|
||||||
|
|
||||||
|
const diagnosticSchema = z.object({
|
||||||
|
vehicle_id: z.string().min(1, 'Seleccione un vehiculo'),
|
||||||
|
order_id: z.string().optional(),
|
||||||
|
diagnostic_type: z.enum(['obd_scanner', 'injector_bench', 'pump_bench', 'measurements']),
|
||||||
|
equipment: z.string().optional(),
|
||||||
|
summary: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DiagnosticFormData = z.infer<typeof diagnosticSchema>;
|
||||||
|
|
||||||
|
interface DiagnosticItemForm {
|
||||||
|
component: string;
|
||||||
|
test_name: string;
|
||||||
|
value: string;
|
||||||
|
unit: string;
|
||||||
|
min_value: string;
|
||||||
|
max_value: string;
|
||||||
|
result: DiagnosticResult;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIAGNOSTIC_TYPES: { value: DiagnosticType; label: string; description: string; icon: typeof Cpu }[] = [
|
||||||
|
{ value: 'obd_scanner', label: 'Scanner OBD', description: 'Lectura de codigos de falla', icon: Cpu },
|
||||||
|
{ value: 'injector_bench', label: 'Banco Inyectores', description: 'Prueba de inyectores diesel', icon: Gauge },
|
||||||
|
{ value: 'pump_bench', label: 'Banco Bomba', description: 'Prueba de bomba de inyeccion', icon: Cog },
|
||||||
|
{ value: 'measurements', label: 'Mediciones', description: 'Mediciones electricas y mecanicas', icon: Stethoscope },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESULT_OPTIONS: { value: DiagnosticResult; label: string; color: string; icon: typeof CheckCircle2 }[] = [
|
||||||
|
{ value: 'pass', label: 'Aprobado', color: 'bg-green-100 text-green-700 border-green-300', icon: CheckCircle2 },
|
||||||
|
{ value: 'fail', label: 'Fallo', color: 'bg-red-100 text-red-700 border-red-300', icon: XCircle },
|
||||||
|
{ value: 'needs_attention', label: 'Atencion', color: 'bg-yellow-100 text-yellow-700 border-yellow-300', icon: AlertTriangle },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_ITEM: DiagnosticItemForm = {
|
||||||
|
component: '',
|
||||||
|
test_name: '',
|
||||||
|
value: '',
|
||||||
|
unit: '',
|
||||||
|
min_value: '',
|
||||||
|
max_value: '',
|
||||||
|
result: 'pass',
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiagnosticsNewPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [vehicleSearch, setVehicleSearch] = useState('');
|
||||||
|
const [showVehicleSearch, setShowVehicleSearch] = useState(false);
|
||||||
|
const [selectedVehicle, setSelectedVehicle] = useState<{ id: string; info: string } | null>(null);
|
||||||
|
const [items, setItems] = useState<DiagnosticItemForm[]>([{ ...EMPTY_ITEM }]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<DiagnosticFormData>({
|
||||||
|
resolver: zodResolver(diagnosticSchema),
|
||||||
|
defaultValues: {
|
||||||
|
diagnostic_type: 'obd_scanner',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedType = watch('diagnostic_type');
|
||||||
|
|
||||||
|
// Search vehicles
|
||||||
|
const { data: vehiclesData, isLoading: searchingVehicles } = useQuery({
|
||||||
|
queryKey: ['vehicles-search', vehicleSearch],
|
||||||
|
queryFn: () => vehiclesApi.list({ search: vehicleSearch, pageSize: 10 }),
|
||||||
|
enabled: vehicleSearch.length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vehicles = vehiclesData?.data?.data || [];
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (data: DiagnosticFormData) => {
|
||||||
|
// First create the diagnostic
|
||||||
|
const response = await diagnosticsApi.create({
|
||||||
|
vehicle_id: data.vehicle_id,
|
||||||
|
order_id: data.order_id,
|
||||||
|
diagnostic_type: data.diagnostic_type,
|
||||||
|
equipment: data.equipment,
|
||||||
|
summary: data.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
const diagnosticId = response.data?.id;
|
||||||
|
|
||||||
|
// Then add items if we have them
|
||||||
|
if (diagnosticId && items.some(item => item.component && item.test_name)) {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.component && item.test_name) {
|
||||||
|
await diagnosticsApi.addItem(diagnosticId, {
|
||||||
|
component: item.component,
|
||||||
|
test_name: item.test_name,
|
||||||
|
value: item.value,
|
||||||
|
unit: item.unit || null,
|
||||||
|
min_value: item.min_value ? parseFloat(item.min_value) : null,
|
||||||
|
max_value: item.max_value ? parseFloat(item.max_value) : null,
|
||||||
|
result: item.result,
|
||||||
|
notes: item.notes || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const id = response.data?.id;
|
||||||
|
if (id) {
|
||||||
|
navigate(`/diagnostics/${id}`);
|
||||||
|
} else {
|
||||||
|
navigate('/diagnostics');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: DiagnosticFormData) => {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectVehicle = (vehicle: Vehicle) => {
|
||||||
|
setSelectedVehicle({
|
||||||
|
id: vehicle.id,
|
||||||
|
info: `${vehicle.licensePlate} - ${vehicle.make} ${vehicle.model} ${vehicle.year}`,
|
||||||
|
});
|
||||||
|
setValue('vehicle_id', vehicle.id);
|
||||||
|
setShowVehicleSearch(false);
|
||||||
|
setVehicleSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setItems([...items, { ...EMPTY_ITEM }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
if (items.length > 1) {
|
||||||
|
setItems(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (index: number, field: keyof DiagnosticItemForm, value: string) => {
|
||||||
|
const newItems = [...items];
|
||||||
|
newItems[index] = { ...newItems[index], [field]: value };
|
||||||
|
setItems(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/diagnostics"
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Nuevo Diagnostico</h1>
|
||||||
|
<p className="text-sm text-gray-500">Registre pruebas y analisis del vehiculo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Vehicle Selection */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Truck className="h-5 w-5 text-diesel-600" />
|
||||||
|
Vehiculo
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{selectedVehicle ? (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-diesel-200 bg-diesel-50 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-diesel-100">
|
||||||
|
<Truck className="h-5 w-5 text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-900">{selectedVehicle.info}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedVehicle(null);
|
||||||
|
setValue('vehicle_id', '');
|
||||||
|
}}
|
||||||
|
className="text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Cambiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={vehicleSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVehicleSearch(e.target.value);
|
||||||
|
setShowVehicleSearch(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowVehicleSearch(true)}
|
||||||
|
placeholder="Buscar por placas, marca o modelo..."
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-3 pl-10 pr-4 focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showVehicleSearch && vehicleSearch.length >= 2 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||||
|
{searchingVehicles ? (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
) : vehicles.length > 0 ? (
|
||||||
|
<div className="max-h-60 overflow-y-auto p-2">
|
||||||
|
{vehicles.map((vehicle) => (
|
||||||
|
<button
|
||||||
|
key={vehicle.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectVehicle(vehicle)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg p-3 text-left hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Truck className="h-5 w-5 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{vehicle.licensePlate} - {vehicle.make} {vehicle.model}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{vehicle.year} | {vehicle.vehicleType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-sm text-gray-500">
|
||||||
|
No se encontraron vehiculos
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.vehicle_id && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.vehicle_id.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagnostic Type */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Stethoscope className="h-5 w-5 text-diesel-600" />
|
||||||
|
Tipo de Diagnostico
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{DIAGNOSTIC_TYPES.map((type) => {
|
||||||
|
const Icon = type.icon;
|
||||||
|
const isSelected = selectedType === type.value;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={type.value}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-diesel-500 bg-diesel-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
{...register('diagnostic_type')}
|
||||||
|
value={type.value}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<Icon className={`h-5 w-5 ${isSelected ? 'text-diesel-600' : 'text-gray-400'}`} />
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${isSelected ? 'text-diesel-700' : 'text-gray-700'}`}>
|
||||||
|
{type.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">{type.description}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Equipo Utilizado (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('equipment')}
|
||||||
|
placeholder="Ej: Scanner LAUNCH X431, Banco Asnu GDI..."
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagnostic Items */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Pruebas Realizadas</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addItem}
|
||||||
|
className="flex items-center gap-1 text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Agregar Prueba
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} className="rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Prueba {index + 1}</span>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="text-red-500 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">Componente</label>
|
||||||
|
<input
|
||||||
|
value={item.component}
|
||||||
|
onChange={(e) => updateItem(index, 'component', e.target.value)}
|
||||||
|
placeholder="Ej: Inyector 1"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">Prueba</label>
|
||||||
|
<input
|
||||||
|
value={item.test_name}
|
||||||
|
onChange={(e) => updateItem(index, 'test_name', e.target.value)}
|
||||||
|
placeholder="Ej: Caudal Retorno"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">Valor</label>
|
||||||
|
<input
|
||||||
|
value={item.value}
|
||||||
|
onChange={(e) => updateItem(index, 'value', e.target.value)}
|
||||||
|
placeholder="Ej: 85"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">Unidad</label>
|
||||||
|
<input
|
||||||
|
value={item.unit}
|
||||||
|
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
||||||
|
placeholder="Ej: ml/min"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">Valor Min</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.min_value}
|
||||||
|
onChange={(e) => updateItem(index, 'min_value', e.target.value)}
|
||||||
|
placeholder="Ej: 70"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">Valor Max</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.max_value}
|
||||||
|
onChange={(e) => updateItem(index, 'max_value', e.target.value)}
|
||||||
|
placeholder="Ej: 100"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">Resultado</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{RESULT_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateItem(index, 'result', option.value)}
|
||||||
|
className={`flex flex-1 items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
item.result === option.value
|
||||||
|
? option.color
|
||||||
|
: 'border-gray-200 text-gray-500 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-600">Notas</label>
|
||||||
|
<input
|
||||||
|
value={item.notes}
|
||||||
|
onChange={(e) => updateItem(index, 'notes', e.target.value)}
|
||||||
|
placeholder="Observaciones adicionales..."
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">Resumen</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Observaciones Generales
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('summary')}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Describa hallazgos, recomendaciones..."
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Orden de Servicio (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('order_id')}
|
||||||
|
placeholder="ID de orden relacionada"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-diesel-600 px-4 py-3 font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Guardando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stethoscope className="h-4 w-4" />
|
||||||
|
Guardar Diagnostico
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/diagnostics"
|
||||||
|
className="block w-full rounded-lg border border-gray-300 px-4 py-3 text-center font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createMutation.error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||||
|
Error al crear el diagnostico. Intente nuevamente.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
564
src/pages/Inventory.tsx
Normal file
564
src/pages/Inventory.tsx
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
DollarSign,
|
||||||
|
Tag,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { partsApi } from '../services/api/parts';
|
||||||
|
import type { Part, PartFilters } from '../types';
|
||||||
|
|
||||||
|
const partSchema = z.object({
|
||||||
|
sku: z.string().min(1, 'SKU requerido'),
|
||||||
|
name: z.string().min(1, 'Nombre requerido'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
brand: z.string().optional(),
|
||||||
|
manufacturer: z.string().optional(),
|
||||||
|
cost: z.number().optional(),
|
||||||
|
price: z.number().min(0, 'Precio requerido'),
|
||||||
|
currentStock: z.number().min(0, 'Stock requerido'),
|
||||||
|
minStock: z.number().min(0, 'Stock minimo requerido'),
|
||||||
|
maxStock: z.number().optional(),
|
||||||
|
reorderPoint: z.number().optional(),
|
||||||
|
unit: z.string().min(1, 'Unidad requerida'),
|
||||||
|
barcode: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type PartForm = z.infer<typeof partSchema>;
|
||||||
|
|
||||||
|
export function InventoryPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [filters, setFilters] = useState<PartFilters>({ page: 1, pageSize: 20 });
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingPart, setEditingPart] = useState<Part | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<PartForm>({
|
||||||
|
resolver: zodResolver(partSchema),
|
||||||
|
defaultValues: {
|
||||||
|
unit: 'PZA',
|
||||||
|
currentStock: 0,
|
||||||
|
minStock: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch parts
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['parts', filters],
|
||||||
|
queryFn: () => partsApi.list(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: PartForm) => partsApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<PartForm> }) =>
|
||||||
|
partsApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => partsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parts = data?.data?.data || [];
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingPart(null);
|
||||||
|
reset({
|
||||||
|
unit: 'PZA',
|
||||||
|
currentStock: 0,
|
||||||
|
minStock: 0,
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (part: Part) => {
|
||||||
|
setEditingPart(part);
|
||||||
|
reset({
|
||||||
|
sku: part.sku,
|
||||||
|
name: part.name,
|
||||||
|
description: part.description,
|
||||||
|
brand: part.brand,
|
||||||
|
manufacturer: part.manufacturer,
|
||||||
|
cost: part.cost,
|
||||||
|
price: part.price,
|
||||||
|
currentStock: part.currentStock,
|
||||||
|
minStock: part.minStock,
|
||||||
|
maxStock: part.maxStock,
|
||||||
|
reorderPoint: part.reorderPoint,
|
||||||
|
unit: part.unit,
|
||||||
|
barcode: part.barcode,
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingPart(null);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: PartForm) => {
|
||||||
|
if (editingPart) {
|
||||||
|
updateMutation.mutate({ id: editingPart.id, data });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (part: Part) => {
|
||||||
|
if (confirm(`Eliminar refaccion ${part.name}?`)) {
|
||||||
|
deleteMutation.mutate(part.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const lowStockParts = parts.filter((p: Part) => p.currentStock <= p.minStock);
|
||||||
|
const totalValue = parts.reduce((sum: number, p: Part) => sum + (p.currentStock * p.price), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
|
||||||
|
<p className="text-sm text-gray-500">Gestiona las refacciones y materiales</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nueva Refaccion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-blue-100 p-2">
|
||||||
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Total Productos</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{data?.data?.total || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-yellow-100 p-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Stock Bajo</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{lowStockParts.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-green-100 p-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Valor en Inventario</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
${totalValue.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por SKU, nombre, marca..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-gray-400" />
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, lowStock: !filters.lowStock, page: 1 })}
|
||||||
|
className={`rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
filters.lowStock
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Stock Bajo
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar inventario</p>
|
||||||
|
</div>
|
||||||
|
) : parts.length === 0 ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<Package className="mb-2 h-8 w-8" />
|
||||||
|
<p>No hay refacciones registradas</p>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Agregar primera refaccion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Producto
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
SKU
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Marca
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Stock
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Precio
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{parts.map((part: Part) => {
|
||||||
|
const isLowStock = part.currentStock <= part.minStock;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={part.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
|
||||||
|
isLowStock ? 'bg-yellow-100' : 'bg-gray-100'
|
||||||
|
}`}>
|
||||||
|
<Package className={`h-5 w-5 ${isLowStock ? 'text-yellow-600' : 'text-gray-600'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{part.name}</p>
|
||||||
|
{part.description && (
|
||||||
|
<p className="text-sm text-gray-500 truncate max-w-xs">{part.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<span className="flex items-center gap-1 text-sm text-gray-600">
|
||||||
|
<Tag className="h-3 w-3" />
|
||||||
|
{part.sku}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
{part.brand || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{isLowStock && (
|
||||||
|
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<span className={`font-medium ${isLowStock ? 'text-yellow-600' : 'text-gray-900'}`}>
|
||||||
|
{part.currentStock} {part.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">Min: {part.minStock}</p>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
${part.price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
{part.cost && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Costo: ${part.cost.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(part)}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(part)}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data?.data && parts.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Pagina {data.data.page} de {data.data.totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||||
|
disabled={(filters.page || 1) <= 1}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||||
|
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{editingPart ? 'Editar Refaccion' : 'Nueva Refaccion'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={handleCloseModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">SKU *</label>
|
||||||
|
<input
|
||||||
|
{...register('sku')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="REF-001"
|
||||||
|
/>
|
||||||
|
{errors.sku && <p className="mt-1 text-sm text-red-600">{errors.sku.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Codigo Barras</label>
|
||||||
|
<input
|
||||||
|
{...register('barcode')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Nombre *</label>
|
||||||
|
<input
|
||||||
|
{...register('name')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Filtro de aceite"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Descripcion</label>
|
||||||
|
<textarea
|
||||||
|
{...register('description')}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca</label>
|
||||||
|
<input
|
||||||
|
{...register('brand')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Fleetguard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Fabricante</label>
|
||||||
|
<input
|
||||||
|
{...register('manufacturer')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Costo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('cost', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Precio *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('price', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
{errors.price && <p className="mt-1 text-sm text-red-600">{errors.price.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Unidad *</label>
|
||||||
|
<select
|
||||||
|
{...register('unit')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="PZA">PZA</option>
|
||||||
|
<option value="LT">LT</option>
|
||||||
|
<option value="KG">KG</option>
|
||||||
|
<option value="MT">MT</option>
|
||||||
|
<option value="JGO">JGO</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Actual</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('currentStock', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Minimo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('minStock', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Maximo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('maxStock', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{(createMutation.isPending || updateMutation.isPending) && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{editingPart ? 'Guardar Cambios' : 'Crear Refaccion'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
650
src/pages/InventoryDetail.tsx
Normal file
650
src/pages/InventoryDetail.tsx
Normal file
@ -0,0 +1,650 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Package,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
DollarSign,
|
||||||
|
Hash,
|
||||||
|
Boxes,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Barcode,
|
||||||
|
MapPin,
|
||||||
|
Tag,
|
||||||
|
Settings,
|
||||||
|
TrendingUp,
|
||||||
|
Truck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { partsApi } from '../services/api/parts';
|
||||||
|
import { ConfirmModal } from '../components/ui';
|
||||||
|
import { toast } from '../store/toastStore';
|
||||||
|
import type { Part } from '../types';
|
||||||
|
|
||||||
|
interface EditPartForm {
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
brand: string;
|
||||||
|
manufacturer: string;
|
||||||
|
cost: number;
|
||||||
|
price: number;
|
||||||
|
currentStock: number;
|
||||||
|
minStock: number;
|
||||||
|
maxStock: number;
|
||||||
|
reorderPoint: number;
|
||||||
|
unit: string;
|
||||||
|
barcode: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch part data
|
||||||
|
const { data: partData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['part', id],
|
||||||
|
queryFn: () => partsApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const part: Part | undefined = partData?.data;
|
||||||
|
|
||||||
|
// Form setup
|
||||||
|
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<EditPartForm>();
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: EditPartForm) => partsApi.update(id!, {
|
||||||
|
sku: data.sku,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || undefined,
|
||||||
|
brand: data.brand || undefined,
|
||||||
|
manufacturer: data.manufacturer || undefined,
|
||||||
|
cost: data.cost || undefined,
|
||||||
|
price: data.price,
|
||||||
|
currentStock: data.currentStock,
|
||||||
|
minStock: data.minStock,
|
||||||
|
maxStock: data.maxStock || undefined,
|
||||||
|
reorderPoint: data.reorderPoint || undefined,
|
||||||
|
unit: data.unit,
|
||||||
|
barcode: data.barcode || undefined,
|
||||||
|
isActive: data.isActive,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['part', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||||
|
setIsEditing(false);
|
||||||
|
toast.success('Refaccion actualizada', 'Los cambios se guardaron correctamente');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Error al guardar', 'No se pudieron guardar los cambios');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => partsApi.delete(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['parts'] });
|
||||||
|
toast.success('Refaccion eliminada');
|
||||||
|
navigate('/inventory');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Error al eliminar', 'No se pudo eliminar la refaccion');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (part) {
|
||||||
|
reset({
|
||||||
|
sku: part.sku,
|
||||||
|
name: part.name,
|
||||||
|
description: part.description || '',
|
||||||
|
brand: part.brand || '',
|
||||||
|
manufacturer: part.manufacturer || '',
|
||||||
|
cost: part.cost || 0,
|
||||||
|
price: part.price,
|
||||||
|
currentStock: part.currentStock,
|
||||||
|
minStock: part.minStock,
|
||||||
|
maxStock: part.maxStock || 0,
|
||||||
|
reorderPoint: part.reorderPoint || 0,
|
||||||
|
unit: part.unit,
|
||||||
|
barcode: part.barcode || '',
|
||||||
|
isActive: part.isActive,
|
||||||
|
});
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: EditPartForm) => {
|
||||||
|
updateMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !part) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Refaccion no encontrada</p>
|
||||||
|
<Link to="/inventory" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||||
|
Volver a inventario
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLowStock = part.currentStock <= part.minStock;
|
||||||
|
const availableStock = part.currentStock - part.reservedStock;
|
||||||
|
const stockPercentage = part.maxStock ? Math.round((part.currentStock / part.maxStock) * 100) : 0;
|
||||||
|
const profit = part.price - (part.cost || 0);
|
||||||
|
const profitMargin = part.cost ? Math.round((profit / part.cost) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/inventory"
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{part.name}</h1>
|
||||||
|
{part.isActive ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||||
|
Activo
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
||||||
|
Inactivo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLowStock && (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Stock Bajo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">SKU: {part.sku}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
disabled={!isDirty || updateMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Part Info */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Package className="h-5 w-5 text-diesel-600" />
|
||||||
|
Informacion de la Refaccion
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">SKU</label>
|
||||||
|
<input
|
||||||
|
{...register('sku', { required: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Codigo de Barras</label>
|
||||||
|
<input
|
||||||
|
{...register('barcode')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Nombre</label>
|
||||||
|
<input
|
||||||
|
{...register('name', { required: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Descripcion</label>
|
||||||
|
<textarea
|
||||||
|
{...register('description')}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca</label>
|
||||||
|
<input
|
||||||
|
{...register('brand')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Fabricante</label>
|
||||||
|
<input
|
||||||
|
{...register('manufacturer')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Unidad</label>
|
||||||
|
<input
|
||||||
|
{...register('unit', { required: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="pza, lt, kg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
{...register('isActive')}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-diesel-600 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||||
|
Activo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">SKU</p>
|
||||||
|
<p className="text-sm font-mono font-medium text-gray-900">{part.sku}</p>
|
||||||
|
</div>
|
||||||
|
{part.barcode && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Codigo de Barras</p>
|
||||||
|
<p className="text-sm font-mono text-gray-900">{part.barcode}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{part.brand && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Marca</p>
|
||||||
|
<p className="text-sm text-gray-900">{part.brand}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{part.manufacturer && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Fabricante</p>
|
||||||
|
<p className="text-sm text-gray-900">{part.manufacturer}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Unidad</p>
|
||||||
|
<p className="text-sm text-gray-900">{part.unit}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Registrado</p>
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{new Date(part.createdAt).toLocaleDateString('es-MX')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{part.description && (
|
||||||
|
<div className="col-span-full">
|
||||||
|
<p className="text-xs font-medium text-gray-500">Descripcion</p>
|
||||||
|
<p className="text-sm text-gray-600">{part.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{part.compatibleEngines && part.compatibleEngines.length > 0 && (
|
||||||
|
<div className="col-span-full">
|
||||||
|
<p className="text-xs font-medium text-gray-500">Motores Compatibles</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{part.compatibleEngines.map((engine, idx) => (
|
||||||
|
<span key={idx} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
|
||||||
|
{engine}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||||
|
Precios
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Costo</label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('cost', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Precio de Venta</label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('price', { required: true, valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Costo</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
|
${(part.cost || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Precio Venta</p>
|
||||||
|
<p className="text-lg font-semibold text-diesel-600">
|
||||||
|
${part.price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Utilidad</p>
|
||||||
|
<p className="text-lg font-semibold text-green-600">
|
||||||
|
${profit.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Margen</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{profitMargin}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock Management */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Boxes className="h-5 w-5 text-diesel-600" />
|
||||||
|
Control de Inventario
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Actual</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('currentStock', { required: true, valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Minimo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('minStock', { required: true, valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Stock Maximo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('maxStock', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Punto de Reorden</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('reorderPoint', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stock Bar */}
|
||||||
|
{part.maxStock && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Nivel de Stock</span>
|
||||||
|
<span className="font-medium text-gray-900">{stockPercentage}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-full rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
className={`h-3 rounded-full transition-all ${
|
||||||
|
stockPercentage <= 25 ? 'bg-red-500' :
|
||||||
|
stockPercentage <= 50 ? 'bg-yellow-500' :
|
||||||
|
'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(stockPercentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 text-center">
|
||||||
|
<p className="text-xs font-medium text-gray-500">Actual</p>
|
||||||
|
<p className={`text-2xl font-bold ${isLowStock ? 'text-red-600' : 'text-gray-900'}`}>
|
||||||
|
{part.currentStock}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{part.unit}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-blue-50 p-3 text-center">
|
||||||
|
<p className="text-xs font-medium text-blue-600">Reservado</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{part.reservedStock}</p>
|
||||||
|
<p className="text-xs text-blue-500">{part.unit}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-green-50 p-3 text-center">
|
||||||
|
<p className="text-xs font-medium text-green-600">Disponible</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{availableStock}</p>
|
||||||
|
<p className="text-xs text-green-500">{part.unit}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 text-center">
|
||||||
|
<p className="text-xs font-medium text-gray-500">Minimo</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{part.minStock}</p>
|
||||||
|
<p className="text-xs text-gray-500">{part.unit}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{part.reorderPoint && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Punto de reorden: {part.reorderPoint} {part.unit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Settings className="h-5 w-5 text-diesel-600" />
|
||||||
|
Acciones Rapidas
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button className="flex w-full items-center gap-3 rounded-lg border border-gray-200 p-3 text-left text-sm hover:bg-gray-50">
|
||||||
|
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Entrada de Stock</p>
|
||||||
|
<p className="text-xs text-gray-500">Registrar compra o devolucion</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button className="flex w-full items-center gap-3 rounded-lg border border-gray-200 p-3 text-left text-sm hover:bg-gray-50">
|
||||||
|
<Truck className="h-5 w-5 text-orange-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Salida de Stock</p>
|
||||||
|
<p className="text-xs text-gray-500">Registrar uso o ajuste</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button className="flex w-full items-center gap-3 rounded-lg border border-gray-200 p-3 text-left text-sm hover:bg-gray-50">
|
||||||
|
<Barcode className="h-5 w-5 text-diesel-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Imprimir Etiqueta</p>
|
||||||
|
<p className="text-xs text-gray-500">Generar codigo de barras</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Summary */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Hash className="h-5 w-5 text-diesel-600" />
|
||||||
|
Resumen
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
Valor en Stock
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
${(part.currentStock * part.price).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<DollarSign className="h-4 w-4" />
|
||||||
|
Costo en Stock
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
${(part.currentStock * (part.cost || 0)).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{part.maxStock && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Boxes className="h-4 w-4" />
|
||||||
|
Stock Maximo
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{part.maxStock} {part.unit}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{part.locationId && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
Ubicacion
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{part.locationId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Indicator */}
|
||||||
|
<div className="mt-4 border-t border-gray-100 pt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isLowStock ? (
|
||||||
|
<>
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||||
|
<span className="text-sm font-medium text-red-600">Requiere reabastecimiento</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-600">Stock saludable</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showDeleteModal}
|
||||||
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
title="Eliminar Refaccion"
|
||||||
|
message={`¿Estas seguro de eliminar "${part.name}" (${part.sku})? Esta accion no se puede deshacer.`}
|
||||||
|
confirmText="Eliminar"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/pages/Login.tsx
Normal file
171
src/pages/Login.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Wrench, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { authApi } from '../services/api/auth';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Email invalido'),
|
||||||
|
password: z.string().min(8, 'Minimo 8 caracteres'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authApi.login(data);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
login(response.data.user, response.data.token, response.data.refreshToken);
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response?.data?.error) {
|
||||||
|
setError(err.response.data.error.message || 'Error de autenticacion');
|
||||||
|
} else {
|
||||||
|
setError('Error de conexion. Verifica que el servidor este activo.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-diesel-500">
|
||||||
|
<Wrench className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Mecanicas Diesel</h1>
|
||||||
|
<p className="text-sm text-gray-500">Sistema de gestion de taller</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="rounded-xl bg-white p-8 shadow-lg">
|
||||||
|
<h2 className="mb-6 text-xl font-semibold text-gray-900">
|
||||||
|
Iniciar Sesion
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Correo electronico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('email')}
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
placeholder="usuario@taller.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Contrasena
|
||||||
|
</label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<input
|
||||||
|
{...register('password')}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
className="block w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Forgot password */}
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Olvidaste tu contrasena?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-lg bg-diesel-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-diesel-700 focus:outline-none focus:ring-2 focus:ring-diesel-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Ingresando...' : 'Ingresar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Register link */}
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
No tienes cuenta?{' '}
|
||||||
|
<Link to="/register" className="font-medium text-diesel-600 hover:text-diesel-700">
|
||||||
|
Registra tu taller
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="mt-4 text-center text-xs text-gray-500">
|
||||||
|
ERP Mecanicas Diesel - Sistema NEXUS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
src/pages/QuoteDetail.tsx
Normal file
486
src/pages/QuoteDetail.tsx
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
FileText,
|
||||||
|
Truck,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Printer,
|
||||||
|
Mail,
|
||||||
|
MessageCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
ArrowRightCircle,
|
||||||
|
Wrench,
|
||||||
|
Package,
|
||||||
|
Eye,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { quotesApi } from '../services/api/quotes';
|
||||||
|
import type { Quote, QuoteItem } from '../services/api/quotes';
|
||||||
|
import type { QuoteStatus } from '../types';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<QuoteStatus, { label: string; color: string; bgColor: string; icon: typeof Clock }> = {
|
||||||
|
draft: { label: 'Borrador', color: 'text-gray-700', bgColor: 'bg-gray-100', icon: FileText },
|
||||||
|
sent: { label: 'Enviada', color: 'text-blue-700', bgColor: 'bg-blue-100', icon: Mail },
|
||||||
|
viewed: { label: 'Vista', color: 'text-purple-700', bgColor: 'bg-purple-100', icon: Eye },
|
||||||
|
approved: { label: 'Aprobada', color: 'text-green-700', bgColor: 'bg-green-100', icon: CheckCircle2 },
|
||||||
|
rejected: { label: 'Rechazada', color: 'text-red-700', bgColor: 'bg-red-100', icon: XCircle },
|
||||||
|
expired: { label: 'Expirada', color: 'text-yellow-700', bgColor: 'bg-yellow-100', icon: Clock },
|
||||||
|
converted: { label: 'Convertida', color: 'text-emerald-700', bgColor: 'bg-emerald-100', icon: ArrowRightCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuoteDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch quote
|
||||||
|
const { data: quoteData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['quote', id],
|
||||||
|
queryFn: () => quotesApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const quote: Quote | undefined = quoteData?.data;
|
||||||
|
|
||||||
|
// Fetch quote items
|
||||||
|
const { data: itemsData } = useQuery({
|
||||||
|
queryKey: ['quote-items', id],
|
||||||
|
queryFn: () => quotesApi.getItems(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: QuoteItem[] = itemsData?.data || [];
|
||||||
|
|
||||||
|
// Send quote mutation
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: () => quotesApi.send(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['quote', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change status mutation
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (status: QuoteStatus) => quotesApi.changeStatus(id!, status),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['quote', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to order mutation
|
||||||
|
const convertMutation = useMutation({
|
||||||
|
mutationFn: () => quotesApi.convertToOrder(id!),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['quote', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-orders'] });
|
||||||
|
// Navigate to the new order
|
||||||
|
const orderId = response.data?.orderId;
|
||||||
|
if (orderId) {
|
||||||
|
navigate(`/orders/${orderId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !quote) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar la cotizacion</p>
|
||||||
|
<Link to="/quotes" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||||
|
Volver a cotizaciones
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = STATUS_CONFIG[quote.status as QuoteStatus] || STATUS_CONFIG.draft;
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
|
||||||
|
// Calculate totals from items
|
||||||
|
const laborItems = items.filter(i => i.item_type === 'service');
|
||||||
|
const partItems = items.filter(i => i.item_type === 'part');
|
||||||
|
const subtotal = items.reduce((sum, item) => sum + item.subtotal, 0);
|
||||||
|
|
||||||
|
// Check if quote is expired
|
||||||
|
const isExpired = quote.valid_until && new Date(quote.valid_until) < new Date();
|
||||||
|
const canSend = quote.status === 'draft';
|
||||||
|
const canApprove = (quote.status === 'sent' || quote.status === 'viewed') && !isExpired;
|
||||||
|
const canConvert = quote.status === 'approved';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/quotes"
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{quote.quote_number}</h1>
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-medium ${statusConfig.bgColor} ${statusConfig.color}`}>
|
||||||
|
<StatusIcon className="h-4 w-4" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Creada el {new Date(quote.created_at).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
Imprimir
|
||||||
|
</button>
|
||||||
|
{canSend && (
|
||||||
|
<button
|
||||||
|
onClick={() => sendMutation.mutate()}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-blue-300 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
{sendMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canApprove && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => statusMutation.mutate('approved')}
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Aprobar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => statusMutation.mutate('rejected')}
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
Rechazar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canConvert && (
|
||||||
|
<button
|
||||||
|
onClick={() => convertMutation.mutate()}
|
||||||
|
disabled={convertMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
{convertMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowRightCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Convertir a Orden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content - Quote Preview */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-8 print:border-0 print:p-0">
|
||||||
|
{/* Quote Header */}
|
||||||
|
<div className="mb-8 border-b border-gray-200 pb-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">COTIZACION</h2>
|
||||||
|
<p className="text-lg font-semibold text-diesel-600">{quote.quote_number}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">Fecha de Emision</p>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{new Date(quote.created_at).toLocaleDateString('es-MX')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">Valida Hasta</p>
|
||||||
|
<p className={`font-medium ${isExpired ? 'text-red-600' : 'text-gray-900'}`}>
|
||||||
|
{quote.valid_until
|
||||||
|
? new Date(quote.valid_until).toLocaleDateString('es-MX')
|
||||||
|
: 'Sin fecha limite'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer & Vehicle Info */}
|
||||||
|
<div className="mb-8 grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-sm font-semibold uppercase text-gray-500">Cliente</h3>
|
||||||
|
<p className="font-medium text-gray-900">{quote.customer_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-sm font-semibold uppercase text-gray-500">Vehiculo</h3>
|
||||||
|
<p className="font-medium text-gray-900">{quote.vehicle_info}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Table */}
|
||||||
|
<div className="mb-8">
|
||||||
|
{/* Labor Items */}
|
||||||
|
{laborItems.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase text-gray-500">
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
Mano de Obra
|
||||||
|
</h3>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="pb-2 text-left text-sm font-medium text-gray-500">Descripcion</th>
|
||||||
|
<th className="pb-2 text-right text-sm font-medium text-gray-500">Cant</th>
|
||||||
|
<th className="pb-2 text-right text-sm font-medium text-gray-500">Precio</th>
|
||||||
|
<th className="pb-2 text-right text-sm font-medium text-gray-500">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{laborItems.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-100">
|
||||||
|
<td className="py-3">
|
||||||
|
<p className="font-medium text-gray-900">{item.description}</p>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right text-gray-700">{item.quantity}</td>
|
||||||
|
<td className="py-3 text-right text-gray-700">
|
||||||
|
${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right font-medium text-gray-900">
|
||||||
|
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Part Items */}
|
||||||
|
{partItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase text-gray-500">
|
||||||
|
<Package className="h-4 w-4" />
|
||||||
|
Refacciones
|
||||||
|
</h3>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="pb-2 text-left text-sm font-medium text-gray-500">Descripcion</th>
|
||||||
|
<th className="pb-2 text-right text-sm font-medium text-gray-500">Cant</th>
|
||||||
|
<th className="pb-2 text-right text-sm font-medium text-gray-500">Precio</th>
|
||||||
|
<th className="pb-2 text-right text-sm font-medium text-gray-500">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{partItems.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-100">
|
||||||
|
<td className="py-3">
|
||||||
|
<p className="font-medium text-gray-900">{item.description}</p>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right text-gray-700">{item.quantity}</td>
|
||||||
|
<td className="py-3 text-right text-gray-700">
|
||||||
|
${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right font-medium text-gray-900">
|
||||||
|
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="border-t border-gray-200 pt-6">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="w-64 space-y-2">
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Mano de Obra</span>
|
||||||
|
<span>${quote.labor_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Refacciones</span>
|
||||||
|
<span>${quote.parts_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
{quote.discount_amount > 0 && (
|
||||||
|
<div className="flex justify-between text-green-600">
|
||||||
|
<span>Descuento</span>
|
||||||
|
<span>-${quote.discount_amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>${subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>IVA</span>
|
||||||
|
<span>${quote.tax.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t border-gray-200 pt-2 text-xl font-bold text-gray-900">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>${quote.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{quote.notes && (
|
||||||
|
<div className="mt-8 rounded-lg bg-gray-50 p-4">
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-gray-700">Notas</h3>
|
||||||
|
<p className="text-sm text-gray-600">{quote.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terms */}
|
||||||
|
<div className="mt-8 border-t border-gray-200 pt-6 text-xs text-gray-500">
|
||||||
|
<p>* Precios expresados en Pesos Mexicanos (MXN)</p>
|
||||||
|
<p>* Los precios estan sujetos a disponibilidad de refacciones</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Customer Info */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<User className="h-5 w-5 text-diesel-600" />
|
||||||
|
Cliente
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="font-medium text-gray-900">{quote.customer_name}</p>
|
||||||
|
{quote.customer_id && (
|
||||||
|
<Link
|
||||||
|
to={`/customers?id=${quote.customer_id}`}
|
||||||
|
className="text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Ver perfil del cliente
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vehicle Info */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Truck className="h-5 w-5 text-diesel-600" />
|
||||||
|
Vehiculo
|
||||||
|
</h2>
|
||||||
|
<p className="font-medium text-gray-900">{quote.vehicle_info}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline / Actions */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Calendar className="h-5 w-5 text-diesel-600" />
|
||||||
|
Fechas
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Creada</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{new Date(quote.created_at).toLocaleDateString('es-MX')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Valida hasta</span>
|
||||||
|
<span className={isExpired ? 'text-red-600' : 'text-gray-900'}>
|
||||||
|
{quote.valid_until
|
||||||
|
? new Date(quote.valid_until).toLocaleDateString('es-MX')
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{quote.updated_at && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Actualizada</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{new Date(quote.updated_at).toLocaleDateString('es-MX')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||||
|
Resumen
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Servicios</span>
|
||||||
|
<span className="text-gray-900">{laborItems.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Refacciones</span>
|
||||||
|
<span className="text-gray-900">{partItems.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t border-gray-200 pt-2 font-medium">
|
||||||
|
<span className="text-gray-700">Total</span>
|
||||||
|
<span className="text-diesel-600">
|
||||||
|
${quote.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">Acciones Rapidas</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button className="flex w-full items-center gap-2 rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
Enviar por WhatsApp
|
||||||
|
</button>
|
||||||
|
<button className="flex w-full items-center gap-2 rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Enviar por Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
291
src/pages/Quotes.tsx
Normal file
291
src/pages/Quotes.tsx
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Send,
|
||||||
|
Eye,
|
||||||
|
ArrowRight,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Calendar,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { quotesApi } from '../services/api/quotes';
|
||||||
|
import type { Quote, QuoteFilters } from '../services/api/quotes';
|
||||||
|
import type { QuoteStatus } from '../types';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<QuoteStatus, { label: string; color: string; icon: typeof Clock }> = {
|
||||||
|
draft: { label: 'Borrador', color: 'bg-gray-100 text-gray-700', icon: FileText },
|
||||||
|
sent: { label: 'Enviada', color: 'bg-blue-100 text-blue-700', icon: Send },
|
||||||
|
viewed: { label: 'Vista', color: 'bg-purple-100 text-purple-700', icon: Eye },
|
||||||
|
approved: { label: 'Aprobada', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
|
||||||
|
rejected: { label: 'Rechazada', color: 'bg-red-100 text-red-700', icon: XCircle },
|
||||||
|
expired: { label: 'Expirada', color: 'bg-gray-100 text-gray-500', icon: Clock },
|
||||||
|
converted: { label: 'Convertida', color: 'bg-emerald-100 text-emerald-700', icon: ArrowRight },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuotesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [filters, setFilters] = useState<QuoteFilters>({ page: 1, pageSize: 20 });
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Fetch quotes
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['quotes', filters],
|
||||||
|
queryFn: () => quotesApi.list(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to order mutation
|
||||||
|
const convertMutation = useMutation({
|
||||||
|
mutationFn: (quoteId: string) => quotesApi.convertToOrder(quoteId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send mutation
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: (quoteId: string) => quotesApi.send(quoteId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['quotes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const quotes = data?.data?.data || [];
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusFilter = (status: QuoteStatus | undefined) => {
|
||||||
|
setFilters({ ...filters, status, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (validUntil: string) => {
|
||||||
|
return new Date(validUntil) < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Cotizaciones</h1>
|
||||||
|
<p className="text-sm text-gray-500">Gestiona las cotizaciones para clientes</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/quotes/new"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nueva Cotizacion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status summary */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
|
||||||
|
{(Object.keys(STATUS_CONFIG) as QuoteStatus[]).map((status) => {
|
||||||
|
const config = STATUS_CONFIG[status];
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => handleStatusFilter(filters.status === status ? undefined : status)}
|
||||||
|
className={`rounded-lg border p-3 text-left transition-colors ${
|
||||||
|
filters.status === status
|
||||||
|
? 'border-diesel-500 bg-diesel-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`h-4 w-4 ${filters.status === status ? 'text-diesel-600' : 'text-gray-400'}`} />
|
||||||
|
<span className="text-xs font-medium text-gray-600">{config.label}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por numero, cliente..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar cotizaciones</p>
|
||||||
|
</div>
|
||||||
|
) : quotes.length === 0 ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<FileText className="mb-2 h-8 w-8" />
|
||||||
|
<p>No hay cotizaciones</p>
|
||||||
|
<Link
|
||||||
|
to="/quotes/new"
|
||||||
|
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Crear primera cotizacion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Cotizacion
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Cliente / Vehiculo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Vigencia
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Total
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{quotes.map((quote: Quote) => {
|
||||||
|
const statusConfig = STATUS_CONFIG[quote.status as QuoteStatus] || STATUS_CONFIG.draft;
|
||||||
|
const expired = isExpired(quote.valid_until);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={quote.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<Link
|
||||||
|
to={`/quotes/${quote.id}`}
|
||||||
|
className="font-medium text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
{quote.quote_number}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(quote.created_at).toLocaleDateString('es-MX')}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{quote.customer_name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{quote.vehicle_info}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm">
|
||||||
|
<div className={`flex items-center gap-1 ${expired && quote.status !== 'converted' ? 'text-red-500' : 'text-gray-500'}`}>
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{new Date(quote.valid_until).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
{expired && quote.status !== 'converted' && (
|
||||||
|
<span className="ml-1 text-xs">(vencida)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right font-medium text-gray-900">
|
||||||
|
${quote.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{quote.status === 'draft' && (
|
||||||
|
<button
|
||||||
|
onClick={() => sendMutation.mutate(quote.id)}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
className="rounded p-1.5 text-blue-600 hover:bg-blue-50"
|
||||||
|
title="Enviar"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{quote.status === 'approved' && (
|
||||||
|
<button
|
||||||
|
onClick={() => convertMutation.mutate(quote.id)}
|
||||||
|
disabled={convertMutation.isPending}
|
||||||
|
className="rounded p-1.5 text-green-600 hover:bg-green-50"
|
||||||
|
title="Convertir a Orden"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={`/quotes/${quote.id}`}
|
||||||
|
className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data?.data && quotes.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Pagina {data.data.page} de {data.data.totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||||
|
disabled={(filters.page || 1) <= 1}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||||
|
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
src/pages/Register.tsx
Normal file
253
src/pages/Register.tsx
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Wrench, Eye, EyeOff, Building2 } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { authApi } from '../services/api/auth';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
taller_name: z.string().min(3, 'Nombre del taller requerido (min 3 caracteres)'),
|
||||||
|
full_name: z.string().min(2, 'Nombre completo requerido'),
|
||||||
|
email: z.string().email('Email invalido'),
|
||||||
|
password: z.string().min(8, 'Minimo 8 caracteres'),
|
||||||
|
confirmPassword: z.string().min(8, 'Confirma tu contrasena'),
|
||||||
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Las contrasenas no coinciden',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterForm = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
export function Register() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterForm>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authApi.register({
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
full_name: data.full_name,
|
||||||
|
taller_name: data.taller_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
login(response.data.user, response.data.token, response.data.refreshToken);
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response?.data?.error) {
|
||||||
|
setError(err.response.data.error.message || 'Error en el registro');
|
||||||
|
} else {
|
||||||
|
setError('Error de conexion. Verifica que el servidor este activo.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4 py-8">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-diesel-500">
|
||||||
|
<Wrench className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Mecanicas Diesel</h1>
|
||||||
|
<p className="text-sm text-gray-500">Registra tu taller</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="rounded-xl bg-white p-8 shadow-lg">
|
||||||
|
<h2 className="mb-6 text-xl font-semibold text-gray-900">
|
||||||
|
Crear Cuenta
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Taller Name */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="taller_name"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Nombre del Taller
|
||||||
|
</label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<Building2 className="h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
{...register('taller_name')}
|
||||||
|
type="text"
|
||||||
|
id="taller_name"
|
||||||
|
className="block w-full rounded-lg border border-gray-300 py-2.5 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
placeholder="Taller Mecanico XYZ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.taller_name && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">{errors.taller_name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Name */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="full_name"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Tu nombre completo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('full_name')}
|
||||||
|
type="text"
|
||||||
|
id="full_name"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
placeholder="Juan Perez"
|
||||||
|
/>
|
||||||
|
{errors.full_name && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">{errors.full_name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Correo electronico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('email')}
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
placeholder="usuario@taller.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Contrasena
|
||||||
|
</label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<input
|
||||||
|
{...register('password')}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
className="block w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
placeholder="Minimo 8 caracteres"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Confirmar contrasena
|
||||||
|
</label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<input
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
id="confirmPassword"
|
||||||
|
className="block w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
placeholder="Repite tu contrasena"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">
|
||||||
|
{errors.confirmPassword.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-lg bg-diesel-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-diesel-700 focus:outline-none focus:ring-2 focus:ring-diesel-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Registrando...' : 'Registrar Taller'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login link */}
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
Ya tienes cuenta?{' '}
|
||||||
|
<Link to="/login" className="font-medium text-diesel-600 hover:text-diesel-700">
|
||||||
|
Inicia sesion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="mt-4 text-center text-xs text-gray-500">
|
||||||
|
ERP Mecanicas Diesel - Sistema NEXUS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
406
src/pages/ServiceOrderDetail.tsx
Normal file
406
src/pages/ServiceOrderDetail.tsx
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Truck,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Wrench,
|
||||||
|
Package,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
FileText,
|
||||||
|
Edit2,
|
||||||
|
Loader2,
|
||||||
|
DollarSign,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||||
|
import type { ServiceOrder, ServiceOrderItem } from '../services/api/serviceOrders';
|
||||||
|
import type { ServiceOrderStatus } from '../types';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string; bgColor: string }> = {
|
||||||
|
received: { label: 'Recibido', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||||
|
diagnosing: { label: 'Diagnosticando', color: 'text-purple-700', bgColor: 'bg-purple-100' },
|
||||||
|
quoted: { label: 'Cotizado', color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
|
||||||
|
approved: { label: 'Aprobado', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||||
|
in_repair: { label: 'En Reparacion', color: 'text-orange-700', bgColor: 'bg-orange-100' },
|
||||||
|
waiting_parts: { label: 'Esperando Partes', color: 'text-red-700', bgColor: 'bg-red-100' },
|
||||||
|
ready: { label: 'Listo', color: 'text-emerald-700', bgColor: 'bg-emerald-100' },
|
||||||
|
delivered: { label: 'Entregado', color: 'text-gray-700', bgColor: 'bg-gray-100' },
|
||||||
|
cancelled: { label: 'Cancelado', color: 'text-gray-500', bgColor: 'bg-gray-100' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_CONFIG = {
|
||||||
|
low: { label: 'Baja', color: 'text-gray-600' },
|
||||||
|
medium: { label: 'Media', color: 'text-blue-600' },
|
||||||
|
high: { label: 'Alta', color: 'text-orange-600' },
|
||||||
|
urgent: { label: 'Urgente', color: 'text-red-600' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status transitions
|
||||||
|
const STATUS_ACTIONS: Record<ServiceOrderStatus, { next: ServiceOrderStatus; label: string; icon: typeof Play }[]> = {
|
||||||
|
received: [
|
||||||
|
{ next: 'diagnosing', label: 'Iniciar Diagnostico', icon: Play },
|
||||||
|
{ next: 'in_repair', label: 'Iniciar Reparacion', icon: Wrench },
|
||||||
|
],
|
||||||
|
diagnosing: [
|
||||||
|
{ next: 'quoted', label: 'Crear Cotizacion', icon: FileText },
|
||||||
|
],
|
||||||
|
quoted: [
|
||||||
|
{ next: 'approved', label: 'Aprobar', icon: CheckCircle2 },
|
||||||
|
],
|
||||||
|
approved: [
|
||||||
|
{ next: 'in_repair', label: 'Iniciar Reparacion', icon: Wrench },
|
||||||
|
],
|
||||||
|
in_repair: [
|
||||||
|
{ next: 'waiting_parts', label: 'Esperando Partes', icon: Pause },
|
||||||
|
{ next: 'ready', label: 'Marcar Listo', icon: CheckCircle2 },
|
||||||
|
],
|
||||||
|
waiting_parts: [
|
||||||
|
{ next: 'in_repair', label: 'Continuar Reparacion', icon: Play },
|
||||||
|
],
|
||||||
|
ready: [
|
||||||
|
{ next: 'delivered', label: 'Entregar', icon: Truck },
|
||||||
|
],
|
||||||
|
delivered: [],
|
||||||
|
cancelled: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceOrderDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch order details
|
||||||
|
const { data: orderData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['service-order', id],
|
||||||
|
queryFn: () => serviceOrdersApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch order items
|
||||||
|
const { data: itemsData } = useQuery({
|
||||||
|
queryKey: ['service-order-items', id],
|
||||||
|
queryFn: () => serviceOrdersApi.getItems(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status change mutation
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: ({ status, notes }: { status: ServiceOrderStatus; notes?: string }) =>
|
||||||
|
serviceOrdersApi.changeStatus(id!, status, notes),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-order', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-orders'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const order: ServiceOrder | undefined = orderData?.data;
|
||||||
|
const items: ServiceOrderItem[] = itemsData?.data || [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !order) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Orden no encontrada</p>
|
||||||
|
<Link to="/orders" className="mt-2 text-sm text-diesel-600 hover:text-diesel-700">
|
||||||
|
Volver a ordenes
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = STATUS_CONFIG[order.status as ServiceOrderStatus] || STATUS_CONFIG.received;
|
||||||
|
const priorityConfig = PRIORITY_CONFIG[order.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG.medium;
|
||||||
|
const actions = STATUS_ACTIONS[order.status as ServiceOrderStatus] || [];
|
||||||
|
|
||||||
|
const laborItems = items.filter(i => i.item_type === 'service');
|
||||||
|
const partItems = items.filter(i => i.item_type === 'part');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/orders')}
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{order.order_number}</h1>
|
||||||
|
<span className={`rounded-full px-3 py-1 text-sm font-medium ${statusConfig.bgColor} ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Creada el {new Date(order.received_at).toLocaleDateString('es-MX', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Actions */}
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{actions.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action.next}
|
||||||
|
onClick={() => statusMutation.mutate({ status: action.next })}
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{statusMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Customer & Vehicle */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
{/* Customer */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Cliente
|
||||||
|
</h3>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{order.customer_name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vehicle */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
Vehiculo
|
||||||
|
</h3>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{order.vehicle_info}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Symptoms */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<AlertCircle className="h-5 w-5 text-diesel-600" />
|
||||||
|
Sintomas Reportados
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap">
|
||||||
|
{order.symptoms || 'Sin sintomas registrados'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Package className="h-5 w-5 text-diesel-600" />
|
||||||
|
Trabajos y Refacciones
|
||||||
|
</h3>
|
||||||
|
<button className="flex items-center gap-1 text-sm text-diesel-600 hover:text-diesel-700">
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">No hay items agregados</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Labor */}
|
||||||
|
{laborItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
Mano de Obra
|
||||||
|
</h4>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{laborItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{item.description}</p>
|
||||||
|
{item.actual_hours && (
|
||||||
|
<p className="text-sm text-gray-500">{item.actual_hours} hrs</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parts */}
|
||||||
|
{partItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||||
|
<Package className="h-4 w-4" />
|
||||||
|
Refacciones
|
||||||
|
</h4>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{partItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{item.description}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{item.quantity} x ${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{order.notes && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h3 className="mb-3 text-lg font-semibold text-gray-900">Notas Internas</h3>
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap">{order.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Info Card */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold text-gray-900">Informacion</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-500">Prioridad</span>
|
||||||
|
<span className={`font-medium ${priorityConfig.color}`}>{priorityConfig.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-500">Mecanico</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{order.mechanic_name || 'Sin asignar'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-500">Bahia</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{order.bay_name || 'Sin asignar'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.promised_at && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1 text-gray-500">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Prometido
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{new Date(order.promised_at).toLocaleDateString('es-MX', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h3 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||||
|
Totales
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Mano de Obra</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
${order.labor_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Refacciones</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
${order.parts_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">IVA</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
${order.tax.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-gray-900">Total</span>
|
||||||
|
<span className="text-xl font-bold text-diesel-600">
|
||||||
|
${order.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline placeholder */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h3 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Clock className="h-5 w-5 text-diesel-600" />
|
||||||
|
Historial
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-diesel-500" />
|
||||||
|
<span>Orden creada</span>
|
||||||
|
<span className="ml-auto">
|
||||||
|
{new Date(order.created_at).toLocaleDateString('es-MX', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
src/pages/ServiceOrderNew.tsx
Normal file
384
src/pages/ServiceOrderNew.tsx
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Truck,
|
||||||
|
User,
|
||||||
|
Search,
|
||||||
|
Calendar,
|
||||||
|
Gauge,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||||
|
import { customersApi } from '../services/api/customers';
|
||||||
|
import { vehiclesApi } from '../services/api/vehicles';
|
||||||
|
import type { Customer } from '../services/api/customers';
|
||||||
|
import type { Vehicle, ServiceOrderPriority } from '../types';
|
||||||
|
|
||||||
|
const PRIORITY_OPTIONS: { value: ServiceOrderPriority; label: string; color: string }[] = [
|
||||||
|
{ value: 'low', label: 'Baja', color: 'bg-gray-100 text-gray-700' },
|
||||||
|
{ value: 'medium', label: 'Media', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
{ value: 'high', label: 'Alta', color: 'bg-orange-100 text-orange-700' },
|
||||||
|
{ value: 'urgent', label: 'Urgente', color: 'bg-red-100 text-red-700' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const serviceOrderSchema = z.object({
|
||||||
|
customerId: z.string().min(1, 'Selecciona un cliente'),
|
||||||
|
vehicleId: z.string().min(1, 'Selecciona un vehiculo'),
|
||||||
|
customerSymptoms: z.string().min(10, 'Describe los sintomas (min. 10 caracteres)'),
|
||||||
|
priority: z.enum(['low', 'medium', 'high', 'urgent']),
|
||||||
|
promisedAt: z.string().optional(),
|
||||||
|
odometerIn: z.number().optional(),
|
||||||
|
internalNotes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ServiceOrderForm = z.infer<typeof serviceOrderSchema>;
|
||||||
|
|
||||||
|
export function ServiceOrderNewPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [customerSearch, setCustomerSearch] = useState('');
|
||||||
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||||
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||||
|
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ServiceOrderForm>({
|
||||||
|
resolver: zodResolver(serviceOrderSchema),
|
||||||
|
defaultValues: {
|
||||||
|
priority: 'medium',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerId = watch('customerId');
|
||||||
|
|
||||||
|
// Fetch customers for search
|
||||||
|
const { data: customersData } = useQuery({
|
||||||
|
queryKey: ['customers', customerSearch],
|
||||||
|
queryFn: () => customersApi.list({ search: customerSearch, pageSize: 10 }),
|
||||||
|
enabled: customerSearch.length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch vehicles for selected customer
|
||||||
|
const { data: vehiclesData, isLoading: vehiclesLoading } = useQuery({
|
||||||
|
queryKey: ['vehicles', customerId],
|
||||||
|
queryFn: () => vehiclesApi.list({ customerId, pageSize: 100 }),
|
||||||
|
enabled: !!customerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: ServiceOrderForm) => serviceOrdersApi.create({
|
||||||
|
customer_id: data.customerId,
|
||||||
|
vehicle_id: data.vehicleId,
|
||||||
|
symptoms: data.customerSymptoms,
|
||||||
|
priority: data.priority,
|
||||||
|
promised_at: data.promisedAt,
|
||||||
|
}),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.data?.id) {
|
||||||
|
navigate(`/orders/${response.data.id}`);
|
||||||
|
} else {
|
||||||
|
navigate('/orders');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const customers = customersData?.data?.data || [];
|
||||||
|
const vehicles = vehiclesData?.data?.data || [];
|
||||||
|
|
||||||
|
const handleCustomerSelect = (customer: Customer) => {
|
||||||
|
setSelectedCustomer(customer);
|
||||||
|
setValue('customerId', customer.id);
|
||||||
|
setShowCustomerDropdown(false);
|
||||||
|
setCustomerSearch(customer.name);
|
||||||
|
// Reset vehicle when customer changes
|
||||||
|
setSelectedVehicle(null);
|
||||||
|
setValue('vehicleId', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVehicleSelect = (vehicle: Vehicle) => {
|
||||||
|
setSelectedVehicle(vehicle);
|
||||||
|
setValue('vehicleId', vehicle.id);
|
||||||
|
if (vehicle.currentOdometer) {
|
||||||
|
setValue('odometerIn', vehicle.currentOdometer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: ServiceOrderForm) => {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/orders')}
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Nueva Orden de Servicio</h1>
|
||||||
|
<p className="text-sm text-gray-500">Registra un nuevo vehiculo para servicio</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Form */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Customer Selection */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<User className="h-5 w-5 text-diesel-600" />
|
||||||
|
Cliente
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar cliente por nombre..."
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerSearch(e.target.value);
|
||||||
|
setShowCustomerDropdown(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowCustomerDropdown(true)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2.5 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customers Dropdown */}
|
||||||
|
{showCustomerDropdown && customers.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||||
|
{customers.map((customer: Customer) => (
|
||||||
|
<button
|
||||||
|
key={customer.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCustomerSelect(customer)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<User className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{customer.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{customer.contact_name} - {customer.vehicle_count} vehiculos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.customerId && (
|
||||||
|
<p className="mt-2 text-sm text-red-600">{errors.customerId.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Customer Info */}
|
||||||
|
{selectedCustomer && (
|
||||||
|
<div className="mt-4 rounded-lg bg-diesel-50 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{selectedCustomer.name}</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{selectedCustomer.contact_name} - {selectedCustomer.contact_phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Check className="h-5 w-5 text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vehicle Selection */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Truck className="h-5 w-5 text-diesel-600" />
|
||||||
|
Vehiculo
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!customerId ? (
|
||||||
|
<p className="text-sm text-gray-500">Selecciona primero un cliente</p>
|
||||||
|
) : vehiclesLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Cargando vehiculos...
|
||||||
|
</div>
|
||||||
|
) : vehicles.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">El cliente no tiene vehiculos registrados</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{vehicles.map((vehicle: Vehicle) => (
|
||||||
|
<button
|
||||||
|
key={vehicle.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVehicleSelect(vehicle)}
|
||||||
|
className={`flex items-center gap-3 rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
selectedVehicle?.id === vehicle.id
|
||||||
|
? 'border-diesel-500 bg-diesel-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<Truck className="h-6 w-6 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{vehicle.make} {vehicle.model}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{vehicle.year} - {vehicle.licensePlate}
|
||||||
|
</p>
|
||||||
|
{vehicle.economicNumber && (
|
||||||
|
<p className="text-xs text-gray-400">Eco: {vehicle.economicNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedVehicle?.id === vehicle.id && (
|
||||||
|
<Check className="h-5 w-5 text-diesel-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.vehicleId && (
|
||||||
|
<p className="mt-2 text-sm text-red-600">{errors.vehicleId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Symptoms */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<AlertCircle className="h-5 w-5 text-diesel-600" />
|
||||||
|
Sintomas Reportados
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
{...register('customerSymptoms')}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Describe los problemas o sintomas que reporta el cliente..."
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors.customerSymptoms && (
|
||||||
|
<p className="mt-2 text-sm text-red-600">{errors.customerSymptoms.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Internal Notes */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">Notas Internas</h2>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
{...register('internalNotes')}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Notas adicionales para el equipo de trabajo (opcional)..."
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">Prioridad</h2>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PRIORITY_OPTIONS.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors ${
|
||||||
|
watch('priority') === option.value
|
||||||
|
? 'border-diesel-500 bg-diesel-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
{...register('priority')}
|
||||||
|
value={option.value}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${option.color}`}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Info */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">Informacion Adicional</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Fecha Prometida
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
{...register('promisedAt')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||||
|
<Gauge className="h-4 w-4" />
|
||||||
|
Odometro Entrada (km)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('odometerIn', { valueAsNumber: true })}
|
||||||
|
placeholder="Ej: 125000"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-diesel-600 px-4 py-3 font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Creando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Crear Orden de Servicio'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{createMutation.isError && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||||
|
Error al crear la orden. Intenta de nuevo.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
290
src/pages/ServiceOrders.tsx
Normal file
290
src/pages/ServiceOrders.tsx
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Wrench,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Truck,
|
||||||
|
Calendar,
|
||||||
|
LayoutGrid,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||||
|
import type { ServiceOrder, ServiceOrderFilters } from '../services/api/serviceOrders';
|
||||||
|
import type { ServiceOrderStatus } from '../types';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string; icon: typeof Clock }> = {
|
||||||
|
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800', icon: Clock },
|
||||||
|
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800', icon: AlertCircle },
|
||||||
|
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||||
|
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800', icon: CheckCircle2 },
|
||||||
|
in_repair: { label: 'En Reparación', color: 'bg-orange-100 text-orange-800', icon: Wrench },
|
||||||
|
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800', icon: Clock },
|
||||||
|
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800', icon: CheckCircle2 },
|
||||||
|
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800', icon: Truck },
|
||||||
|
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500', icon: AlertCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_CONFIG = {
|
||||||
|
low: { label: 'Baja', color: 'text-gray-500' },
|
||||||
|
medium: { label: 'Media', color: 'text-blue-500' },
|
||||||
|
high: { label: 'Alta', color: 'text-orange-500' },
|
||||||
|
urgent: { label: 'Urgente', color: 'text-red-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceOrdersPage() {
|
||||||
|
const [filters, setFilters] = useState<ServiceOrderFilters>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['service-orders', filters],
|
||||||
|
queryFn: () => serviceOrdersApi.list(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusFilter = (status: ServiceOrderStatus | undefined) => {
|
||||||
|
setFilters({ ...filters, status, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Órdenes de Servicio</h1>
|
||||||
|
<p className="text-sm text-gray-500">Gestiona las órdenes de trabajo del taller</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/orders/kanban"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
Vista Kanban
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/orders/new"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nueva Orden
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status summary cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{(['received', 'in_repair', 'waiting_parts', 'ready', 'delivered'] as ServiceOrderStatus[]).map(
|
||||||
|
(status) => {
|
||||||
|
const config = STATUS_CONFIG[status];
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => handleStatusFilter(filters.status === status ? undefined : status)}
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
filters.status === status
|
||||||
|
? 'border-diesel-500 bg-diesel-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`h-4 w-4 ${filters.status === status ? 'text-diesel-600' : 'text-gray-400'}`} />
|
||||||
|
<span className="text-xs font-medium text-gray-500">{config.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-gray-900">-</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por número de orden, cliente o vehículo..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={filters.status || ''}
|
||||||
|
onChange={(e) => handleStatusFilter((e.target.value as ServiceOrderStatus) || undefined)}
|
||||||
|
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{config.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filters.priority || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, priority: e.target.value || undefined, page: 1 })
|
||||||
|
}
|
||||||
|
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Todas las prioridades</option>
|
||||||
|
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{config.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orders Table */}
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-diesel-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar órdenes</p>
|
||||||
|
<p className="text-sm">Verifica que el servidor esté activo</p>
|
||||||
|
</div>
|
||||||
|
) : !data?.data?.data?.length ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<Wrench className="mb-2 h-8 w-8" />
|
||||||
|
<p>No hay órdenes de servicio</p>
|
||||||
|
<Link
|
||||||
|
to="/orders/new"
|
||||||
|
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Crear primera orden
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Orden
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Cliente / Vehículo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Prioridad
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Fecha
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Total
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{data.data.data.map((order: ServiceOrder) => {
|
||||||
|
const statusConfig = STATUS_CONFIG[order.status as ServiceOrderStatus] || STATUS_CONFIG.received;
|
||||||
|
const priorityConfig = PRIORITY_CONFIG[order.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG.medium;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={order.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<Link
|
||||||
|
to={`/orders/${order.id}`}
|
||||||
|
className="font-medium text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
{order.order_number}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{order.customer_name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{order.vehicle_info}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<span className={`text-sm font-medium ${priorityConfig.color}`}>
|
||||||
|
{priorityConfig.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{new Date(order.received_at).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-medium text-gray-900">
|
||||||
|
${order.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data?.data && data.data.data.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Página {data.data.page} de {data.data.totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||||
|
disabled={filters.page === 1}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||||
|
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
src/pages/ServiceOrdersKanban.tsx
Normal file
320
src/pages/ServiceOrdersKanban.tsx
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Truck,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
List,
|
||||||
|
ChevronRight,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||||
|
import type { ServiceOrder } from '../services/api/serviceOrders';
|
||||||
|
import type { ServiceOrderStatus } from '../types';
|
||||||
|
|
||||||
|
// Kanban columns configuration
|
||||||
|
const KANBAN_COLUMNS: { status: ServiceOrderStatus; label: string; color: string; bgColor: string }[] = [
|
||||||
|
{ status: 'received', label: 'Recibidos', color: 'border-blue-500', bgColor: 'bg-blue-50' },
|
||||||
|
{ status: 'diagnosing', label: 'Diagnosticando', color: 'border-purple-500', bgColor: 'bg-purple-50' },
|
||||||
|
{ status: 'quoted', label: 'Cotizados', color: 'border-yellow-500', bgColor: 'bg-yellow-50' },
|
||||||
|
{ status: 'approved', label: 'Aprobados', color: 'border-green-500', bgColor: 'bg-green-50' },
|
||||||
|
{ status: 'in_repair', label: 'En Reparacion', color: 'border-orange-500', bgColor: 'bg-orange-50' },
|
||||||
|
{ status: 'waiting_parts', label: 'Esperando Partes', color: 'border-red-500', bgColor: 'bg-red-50' },
|
||||||
|
{ status: 'ready', label: 'Listos', color: 'border-emerald-500', bgColor: 'bg-emerald-50' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRIORITY_CONFIG = {
|
||||||
|
low: { label: 'Baja', color: 'text-gray-500', dot: 'bg-gray-400' },
|
||||||
|
medium: { label: 'Media', color: 'text-blue-500', dot: 'bg-blue-400' },
|
||||||
|
high: { label: 'Alta', color: 'text-orange-500', dot: 'bg-orange-400' },
|
||||||
|
urgent: { label: 'Urgente', color: 'text-red-500', dot: 'bg-red-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface KanbanCardProps {
|
||||||
|
order: ServiceOrder;
|
||||||
|
onMoveToNext: (orderId: string, currentStatus: ServiceOrderStatus) => void;
|
||||||
|
isMoving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanCard({ order, onMoveToNext, isMoving }: KanbanCardProps) {
|
||||||
|
const priorityConfig = PRIORITY_CONFIG[order.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG.medium;
|
||||||
|
const isUrgent = order.priority === 'urgent' || order.priority === 'high';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group rounded-lg border bg-white p-3 shadow-sm transition-all hover:shadow-md ${
|
||||||
|
isUrgent ? 'border-l-4 border-l-red-500' : 'border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start justify-between">
|
||||||
|
<Link
|
||||||
|
to={`/orders/${order.id}`}
|
||||||
|
className="font-medium text-gray-900 hover:text-diesel-600"
|
||||||
|
>
|
||||||
|
{order.order_number}
|
||||||
|
</Link>
|
||||||
|
<span className={`flex items-center gap-1 text-xs ${priorityConfig.color}`}>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${priorityConfig.dot}`} />
|
||||||
|
{priorityConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
<span className="truncate">{order.vehicle_info}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
<span className="truncate">{order.customer_name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.promised_at && (
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
Prometido: {new Date(order.promised_at).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.symptoms && (
|
||||||
|
<p className="mb-2 text-xs text-gray-500 line-clamp-2">{order.symptoms}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(order.received_at).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onMoveToNext(order.id, order.status as ServiceOrderStatus)}
|
||||||
|
disabled={isMoving}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-diesel-600 opacity-0 transition-opacity hover:bg-diesel-50 group-hover:opacity-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isMoving ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Avanzar
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KanbanColumnProps {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
|
orders: ServiceOrder[];
|
||||||
|
onMoveToNext: (orderId: string, currentStatus: ServiceOrderStatus) => void;
|
||||||
|
movingOrderId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanColumn({ label, color, bgColor, orders, onMoveToNext, movingOrderId }: KanbanColumnProps) {
|
||||||
|
const urgentCount = orders.filter(o => o.priority === 'urgent' || o.priority === 'high').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-72 flex-shrink-0 flex-col rounded-lg border border-gray-200 bg-gray-50">
|
||||||
|
<div className={`flex items-center justify-between border-b-2 ${color} p-3 ${bgColor}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-gray-900">{label}</h3>
|
||||||
|
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-600">
|
||||||
|
{orders.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{urgentCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-red-600">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{urgentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-2 overflow-y-auto p-2" style={{ maxHeight: 'calc(100vh - 280px)' }}>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<div className="flex h-24 items-center justify-center text-sm text-gray-400">
|
||||||
|
Sin ordenes
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
orders.map((order) => (
|
||||||
|
<KanbanCard
|
||||||
|
key={order.id}
|
||||||
|
order={order}
|
||||||
|
onMoveToNext={onMoveToNext}
|
||||||
|
isMoving={movingOrderId === order.id}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status transitions for quick move
|
||||||
|
const STATUS_NEXT: Record<ServiceOrderStatus, ServiceOrderStatus | null> = {
|
||||||
|
received: 'diagnosing',
|
||||||
|
diagnosing: 'quoted',
|
||||||
|
quoted: 'approved',
|
||||||
|
approved: 'in_repair',
|
||||||
|
in_repair: 'ready',
|
||||||
|
waiting_parts: 'in_repair',
|
||||||
|
ready: 'delivered',
|
||||||
|
delivered: null,
|
||||||
|
cancelled: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceOrdersKanbanPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [movingOrderId, setMovingOrderId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch all orders for kanban (exclude delivered and cancelled)
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['service-orders-kanban'],
|
||||||
|
queryFn: () => serviceOrdersApi.list({ pageSize: 200 }), // Get all active orders
|
||||||
|
});
|
||||||
|
|
||||||
|
const allOrders: ServiceOrder[] = data?.data?.data || [];
|
||||||
|
|
||||||
|
// Filter out delivered and cancelled
|
||||||
|
const activeOrders = allOrders.filter(
|
||||||
|
o => o.status !== 'delivered' && o.status !== 'cancelled'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group orders by status
|
||||||
|
const ordersByStatus = KANBAN_COLUMNS.reduce((acc, col) => {
|
||||||
|
acc[col.status] = activeOrders
|
||||||
|
.filter(o => o.status === col.status)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by priority (urgent first) then by date
|
||||||
|
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
|
||||||
|
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 2;
|
||||||
|
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 2;
|
||||||
|
if (aPriority !== bPriority) return aPriority - bPriority;
|
||||||
|
return new Date(a.received_at).getTime() - new Date(b.received_at).getTime();
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<ServiceOrderStatus, ServiceOrder[]>);
|
||||||
|
|
||||||
|
// Status change mutation
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: ({ orderId, status }: { orderId: string; status: ServiceOrderStatus }) =>
|
||||||
|
serviceOrdersApi.changeStatus(orderId, status),
|
||||||
|
onMutate: ({ orderId }) => {
|
||||||
|
setMovingOrderId(orderId);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-orders-kanban'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-orders'] });
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setMovingOrderId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMoveToNext = (orderId: string, currentStatus: ServiceOrderStatus) => {
|
||||||
|
const nextStatus = STATUS_NEXT[currentStatus];
|
||||||
|
if (nextStatus) {
|
||||||
|
statusMutation.mutate({ orderId, status: nextStatus });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar ordenes</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Tablero de Ordenes</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{activeOrders.length} ordenes activas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/orders"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
Vista Lista
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/orders/new"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nueva Orden
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Summary */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 lg:grid-cols-7">
|
||||||
|
{KANBAN_COLUMNS.map((col) => {
|
||||||
|
const count = ordersByStatus[col.status]?.length || 0;
|
||||||
|
const urgentCount = ordersByStatus[col.status]?.filter(
|
||||||
|
o => o.priority === 'urgent' || o.priority === 'high'
|
||||||
|
).length || 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.status}
|
||||||
|
className={`rounded-lg border-l-4 ${col.color} bg-white p-3 shadow-sm`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium text-gray-500">{col.label}</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{count}</p>
|
||||||
|
{urgentCount > 0 && (
|
||||||
|
<span className="text-xs text-red-500">({urgentCount} urgentes)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kanban Board */}
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{KANBAN_COLUMNS.map((col) => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={col.status}
|
||||||
|
label={col.label}
|
||||||
|
color={col.color}
|
||||||
|
bgColor={col.bgColor}
|
||||||
|
orders={ordersByStatus[col.status] || []}
|
||||||
|
onMoveToNext={handleMoveToNext}
|
||||||
|
movingOrderId={movingOrderId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
375
src/pages/Settings.tsx
Normal file
375
src/pages/Settings.tsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Building2,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Percent,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { settingsApi } from '../services/api/settings';
|
||||||
|
import type { TenantSettings, UpdateTenantSettingsRequest } from '../services/api/settings';
|
||||||
|
|
||||||
|
interface WorkshopSettingsForm {
|
||||||
|
name: string;
|
||||||
|
legal_name: string;
|
||||||
|
rfc: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
website: string;
|
||||||
|
default_tax_rate: number;
|
||||||
|
labor_rate: number;
|
||||||
|
working_hours_start: string;
|
||||||
|
working_hours_end: string;
|
||||||
|
quote_validity_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch current settings
|
||||||
|
const { data: settingsData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: () => settingsApi.getSettings(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings: TenantSettings | undefined = settingsData?.data;
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isDirty },
|
||||||
|
} = useForm<WorkshopSettingsForm>();
|
||||||
|
|
||||||
|
// Populate form when settings load
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
reset({
|
||||||
|
name: settings.name || '',
|
||||||
|
legal_name: settings.legal_name || '',
|
||||||
|
rfc: settings.rfc || '',
|
||||||
|
address: settings.address || '',
|
||||||
|
city: settings.city || '',
|
||||||
|
state: settings.state || '',
|
||||||
|
zip_code: settings.zip_code || '',
|
||||||
|
phone: settings.phone || '',
|
||||||
|
email: settings.email || '',
|
||||||
|
website: settings.website || '',
|
||||||
|
default_tax_rate: settings.default_tax_rate || 16,
|
||||||
|
labor_rate: settings.labor_rate || 450,
|
||||||
|
working_hours_start: settings.working_hours_start || '08:00',
|
||||||
|
working_hours_end: settings.working_hours_end || '18:00',
|
||||||
|
quote_validity_days: settings.quote_validity_days || 15,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings, reset]);
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: UpdateTenantSettingsRequest) => settingsApi.updateSettings(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: WorkshopSettingsForm) => {
|
||||||
|
updateMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar configuracion</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Configuracion</h1>
|
||||||
|
<p className="text-sm text-gray-500">Ajustes del taller y preferencias del sistema</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Settings */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Workshop Info */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Building2 className="h-5 w-5 text-diesel-600" />
|
||||||
|
Informacion del Taller
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Nombre Comercial
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('name')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Razon Social
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('legal_name')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Taller Mecanico SA de CV"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">RFC</label>
|
||||||
|
<input
|
||||||
|
{...register('rfc')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="TME123456XXX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Telefono
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
{...register('phone')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="81 1234 5678"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
{...register('email')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="contacto@taller.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Sitio Web
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('website')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="www.taller.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<MapPin className="h-5 w-5 text-diesel-600" />
|
||||||
|
Direccion
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Calle y Numero
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('address')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Av. Industria #123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Ciudad</label>
|
||||||
|
<input
|
||||||
|
{...register('city')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Monterrey"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Estado</label>
|
||||||
|
<input
|
||||||
|
{...register('state')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Nuevo Leon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Codigo Postal
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('zip_code')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="64000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Business Settings */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<DollarSign className="h-5 w-5 text-diesel-600" />
|
||||||
|
Configuracion Comercial
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Tasa de IVA (%)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Percent className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('default_tax_rate', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Tarifa Mano de Obra ($/hr)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('labor_rate', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Vigencia Cotizaciones (dias)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('quote_validity_days', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Working Hours */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Clock className="h-5 w-5 text-diesel-600" />
|
||||||
|
Horario de Trabajo
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Hora de Apertura
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
{...register('working_hours_start')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Hora de Cierre
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
{...register('working_hours_end')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending || !isDirty}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-diesel-600 px-4 py-3 font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Guardando...
|
||||||
|
</>
|
||||||
|
) : updateMutation.isSuccess ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
Guardado
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Guardar Cambios
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{updateMutation.error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||||
|
Error al guardar. Intente nuevamente.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="rounded-lg bg-gray-50 p-4 text-sm text-gray-600">
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<SettingsIcon className="h-4 w-4" />
|
||||||
|
Los cambios se aplicaran inmediatamente a todo el sistema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
579
src/pages/Users.tsx
Normal file
579
src/pages/Users.tsx
Normal file
@ -0,0 +1,579 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Key,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { usersApi } from '../services/api';
|
||||||
|
import type { UserDetail, UserRole, UserFilters } from '../types';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
const ROLES: { value: UserRole; label: string; color: string }[] = [
|
||||||
|
{ value: 'admin', label: 'Administrador', color: 'bg-purple-100 text-purple-800' },
|
||||||
|
{ value: 'jefe_taller', label: 'Jefe de Taller', color: 'bg-blue-100 text-blue-800' },
|
||||||
|
{ value: 'mecanico', label: 'Mecánico', color: 'bg-green-100 text-green-800' },
|
||||||
|
{ value: 'recepcion', label: 'Recepción', color: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
{ value: 'almacen', label: 'Almacén', color: 'bg-gray-100 text-gray-800' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getRoleInfo(role: string) {
|
||||||
|
return ROLES.find((r) => r.value === role) || { label: role, color: 'bg-gray-100 text-gray-800' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user: currentUser } = useAuthStore();
|
||||||
|
const [filters, setFilters] = useState<UserFilters>({ page: 1, limit: 20 });
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
|
||||||
|
const [resetPasswordUser, setResetPasswordUser] = useState<UserDetail | null>(null);
|
||||||
|
|
||||||
|
// Fetch users
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['users', filters],
|
||||||
|
queryFn: () => usersApi.list(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => usersApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterRole = (role: UserRole | undefined) => {
|
||||||
|
setFilters({ ...filters, role, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (user: UserDetail) => {
|
||||||
|
if (confirm(`¿Estás seguro de eliminar a ${user.full_name}?`)) {
|
||||||
|
deleteMutation.mutate(user.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canManageUsers = currentUser?.role === 'admin' || currentUser?.role === 'jefe_taller';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
|
||||||
|
<p className="text-sm text-gray-500">Gestiona los usuarios del sistema</p>
|
||||||
|
</div>
|
||||||
|
{canManageUsers && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nuevo Usuario
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre o email..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={filters.role || ''}
|
||||||
|
onChange={(e) => handleFilterRole(e.target.value as UserRole || undefined)}
|
||||||
|
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Todos los roles</option>
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<option key={role.value} value={role.value}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filters.isActive === undefined ? '' : String(filters.isActive)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
isActive: e.target.value === '' ? undefined : e.target.value === 'true',
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Activos</option>
|
||||||
|
<option value="false">Inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-diesel-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-red-600">
|
||||||
|
Error al cargar usuarios
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Usuario
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Rol
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Último acceso
|
||||||
|
</th>
|
||||||
|
{canManageUsers && (
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{data?.data?.data?.map((user) => {
|
||||||
|
const roleInfo = getRoleInfo(user.role);
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-diesel-100 text-diesel-600">
|
||||||
|
{user.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar_url}
|
||||||
|
alt={user.full_name}
|
||||||
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user.full_name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{user.full_name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${roleInfo.color}`}>
|
||||||
|
{roleInfo.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
{user.isActive ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
Activo
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Inactivo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
{user.lastLoginAt
|
||||||
|
? new Date(user.lastLoginAt).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: 'Nunca'}
|
||||||
|
</td>
|
||||||
|
{canManageUsers && (
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingUser(user)}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setResetPasswordUser(user)}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
title="Cambiar contraseña"
|
||||||
|
>
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{user.id !== currentUser?.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user)}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data?.data && (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Mostrando {((data.data.page - 1) * data.data.pageSize) + 1} - {Math.min(data.data.page * data.data.pageSize, data.data.total)} de {data.data.total}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||||
|
disabled={filters.page === 1}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||||
|
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<UserFormModal
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit User Modal */}
|
||||||
|
{editingUser && (
|
||||||
|
<UserFormModal
|
||||||
|
user={editingUser}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditingUser(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
{resetPasswordUser && (
|
||||||
|
<ResetPasswordModal
|
||||||
|
user={resetPasswordUser}
|
||||||
|
onClose={() => setResetPasswordUser(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setResetPasswordUser(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Form Modal Component
|
||||||
|
function UserFormModal({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
user?: UserDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: user?.email || '',
|
||||||
|
password: '',
|
||||||
|
fullName: user?.full_name || '',
|
||||||
|
role: (user?.role as UserRole) || 'mecanico',
|
||||||
|
isActive: user?.isActive ?? true,
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (user) {
|
||||||
|
await usersApi.update(user.id, {
|
||||||
|
fullName: formData.fullName,
|
||||||
|
role: formData.role,
|
||||||
|
isActive: formData.isActive,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await usersApi.create({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
fullName: formData.fullName,
|
||||||
|
role: formData.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response?.data?.error?.message) {
|
||||||
|
setError(err.response.data.error.message);
|
||||||
|
} else {
|
||||||
|
setError('Error al guardar usuario');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">
|
||||||
|
{user ? 'Editar Usuario' : 'Nuevo Usuario'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{!user && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!user && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Contraseña</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Mínimo 8 caracteres"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Nombre completo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.fullName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, fullName: e.target.value })}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Rol</label>
|
||||||
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserRole })}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<option key={role.value} value={role.value}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 text-diesel-600 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="text-sm text-gray-700">
|
||||||
|
Usuario activo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Guardando...' : user ? 'Guardar cambios' : 'Crear usuario'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset Password Modal
|
||||||
|
function ResetPasswordModal({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
user: UserDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Las contraseñas no coinciden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await usersApi.resetPassword(user.id, { newPassword: password });
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response?.data?.error?.message) {
|
||||||
|
setError(err.response.data.error.message);
|
||||||
|
} else {
|
||||||
|
setError('Error al cambiar contraseña');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900">
|
||||||
|
Cambiar contraseña de {user.full_name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Nueva contraseña</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Mínimo 8 caracteres"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Confirmar contraseña</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Guardando...' : 'Cambiar contraseña'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
591
src/pages/VehicleDetail.tsx
Normal file
591
src/pages/VehicleDetail.tsx
Normal file
@ -0,0 +1,591 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Truck,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Calendar,
|
||||||
|
Gauge,
|
||||||
|
User,
|
||||||
|
FileText,
|
||||||
|
Wrench,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
Settings,
|
||||||
|
Hash,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { vehiclesApi } from '../services/api/vehicles';
|
||||||
|
import { customersApi } from '../services/api/customers';
|
||||||
|
import { serviceOrdersApi, type ServiceOrder } from '../services/api/serviceOrders';
|
||||||
|
import { ConfirmModal } from '../components/ui';
|
||||||
|
import type { Vehicle, VehicleType, VehicleStatus } from '../types';
|
||||||
|
|
||||||
|
const VEHICLE_TYPE_LABELS: Record<VehicleType, string> = {
|
||||||
|
truck: 'Camion',
|
||||||
|
trailer: 'Trailer',
|
||||||
|
bus: 'Autobus',
|
||||||
|
pickup: 'Pickup',
|
||||||
|
other: 'Otro',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VEHICLE_STATUS_CONFIG: Record<VehicleStatus, { label: string; color: string }> = {
|
||||||
|
active: { label: 'Activo', color: 'bg-green-100 text-green-800' },
|
||||||
|
inactive: { label: 'Inactivo', color: 'bg-gray-100 text-gray-800' },
|
||||||
|
sold: { label: 'Vendido', color: 'bg-red-100 text-red-800' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORDER_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800' },
|
||||||
|
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800' },
|
||||||
|
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800' },
|
||||||
|
in_repair: { label: 'En Reparacion', color: 'bg-orange-100 text-orange-800' },
|
||||||
|
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800' },
|
||||||
|
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800' },
|
||||||
|
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800' },
|
||||||
|
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EditVehicleForm {
|
||||||
|
licensePlate: string;
|
||||||
|
economicNumber: string;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
year: number;
|
||||||
|
color: string;
|
||||||
|
vehicleType: VehicleType;
|
||||||
|
vin: string;
|
||||||
|
currentOdometer: number;
|
||||||
|
notes: string;
|
||||||
|
status: VehicleStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VehicleDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch vehicle data
|
||||||
|
const { data: vehicleData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['vehicle', id],
|
||||||
|
queryFn: () => vehiclesApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vehicle: Vehicle | undefined = vehicleData?.data;
|
||||||
|
|
||||||
|
// Fetch customer data
|
||||||
|
const { data: customerData } = useQuery({
|
||||||
|
queryKey: ['customer', vehicle?.customerId],
|
||||||
|
queryFn: () => customersApi.getById(vehicle!.customerId),
|
||||||
|
enabled: !!vehicle?.customerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = customerData?.data;
|
||||||
|
|
||||||
|
// Fetch service history
|
||||||
|
const { data: historyData } = useQuery({
|
||||||
|
queryKey: ['vehicle-history', id],
|
||||||
|
queryFn: () => serviceOrdersApi.getHistory(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceHistory: ServiceOrder[] = historyData?.data || [];
|
||||||
|
|
||||||
|
// Form setup
|
||||||
|
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<EditVehicleForm>();
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: EditVehicleForm) => vehiclesApi.update(id!, {
|
||||||
|
licensePlate: data.licensePlate,
|
||||||
|
economicNumber: data.economicNumber || undefined,
|
||||||
|
make: data.make,
|
||||||
|
model: data.model,
|
||||||
|
year: data.year,
|
||||||
|
color: data.color || undefined,
|
||||||
|
vehicleType: data.vehicleType,
|
||||||
|
vin: data.vin || undefined,
|
||||||
|
currentOdometer: data.currentOdometer || undefined,
|
||||||
|
notes: data.notes || undefined,
|
||||||
|
status: data.status,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vehicle', id] });
|
||||||
|
setIsEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => vehiclesApi.delete(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||||
|
navigate('/vehicles');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (vehicle) {
|
||||||
|
reset({
|
||||||
|
licensePlate: vehicle.licensePlate,
|
||||||
|
economicNumber: vehicle.economicNumber || '',
|
||||||
|
make: vehicle.make,
|
||||||
|
model: vehicle.model,
|
||||||
|
year: vehicle.year,
|
||||||
|
color: vehicle.color || '',
|
||||||
|
vehicleType: vehicle.vehicleType,
|
||||||
|
vin: vehicle.vin || '',
|
||||||
|
currentOdometer: vehicle.currentOdometer || 0,
|
||||||
|
notes: vehicle.notes || '',
|
||||||
|
status: vehicle.status,
|
||||||
|
});
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: EditVehicleForm) => {
|
||||||
|
updateMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !vehicle) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Vehiculo no encontrado</p>
|
||||||
|
<Link to="/vehicles" className="mt-2 text-diesel-600 hover:text-diesel-700">
|
||||||
|
Volver a vehiculos
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = VEHICLE_STATUS_CONFIG[vehicle.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/vehicles"
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{vehicle.make} {vehicle.model} {vehicle.year}
|
||||||
|
</h1>
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{vehicle.licensePlate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
disabled={!isDirty || updateMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Vehicle Info */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Main Info Card */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Truck className="h-5 w-5 text-diesel-600" />
|
||||||
|
Informacion del Vehiculo
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Placas</label>
|
||||||
|
<input
|
||||||
|
{...register('licensePlate', { required: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">No. Economico</label>
|
||||||
|
<input
|
||||||
|
{...register('economicNumber')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca</label>
|
||||||
|
<input
|
||||||
|
{...register('make', { required: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Modelo</label>
|
||||||
|
<input
|
||||||
|
{...register('model', { required: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Ano</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('year', { required: true, valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Color</label>
|
||||||
|
<input
|
||||||
|
{...register('color')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Tipo</label>
|
||||||
|
<select
|
||||||
|
{...register('vehicleType')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{Object.entries(VEHICLE_TYPE_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Estado</label>
|
||||||
|
<select
|
||||||
|
{...register('status')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{Object.entries(VEHICLE_STATUS_CONFIG).map(([key, config]) => (
|
||||||
|
<option key={key} value={key}>{config.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">VIN</label>
|
||||||
|
<input
|
||||||
|
{...register('vin')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Odometro Actual</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('currentOdometer', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
||||||
|
<textarea
|
||||||
|
{...register('notes')}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Placas</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{vehicle.licensePlate}</p>
|
||||||
|
</div>
|
||||||
|
{vehicle.economicNumber && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">No. Economico</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{vehicle.economicNumber}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Tipo</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{VEHICLE_TYPE_LABELS[vehicle.vehicleType]}</p>
|
||||||
|
</div>
|
||||||
|
{vehicle.color && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Color</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{vehicle.color}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vehicle.vin && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-xs font-medium text-gray-500">VIN</p>
|
||||||
|
<p className="text-sm font-mono text-gray-900">{vehicle.vin}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vehicle.currentOdometer && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Odometro</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{vehicle.currentOdometer.toLocaleString()} km
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Registrado</p>
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{new Date(vehicle.createdAt).toLocaleDateString('es-MX')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{vehicle.notes && (
|
||||||
|
<div className="col-span-full">
|
||||||
|
<p className="text-xs font-medium text-gray-500">Notas</p>
|
||||||
|
<p className="text-sm text-gray-600">{vehicle.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service History */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Wrench className="h-5 w-5 text-diesel-600" />
|
||||||
|
Historial de Servicio
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to={`/orders/new?vehicleId=${vehicle.id}`}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
Nueva Orden
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{serviceHistory.length === 0 ? (
|
||||||
|
<div className="flex h-32 flex-col items-center justify-center text-gray-400">
|
||||||
|
<FileText className="mb-2 h-8 w-8" />
|
||||||
|
<p className="text-sm">Sin historial de servicio</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{serviceHistory.map((order) => {
|
||||||
|
const statusConfig = ORDER_STATUS_CONFIG[order.status] || ORDER_STATUS_CONFIG.received;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={order.id}
|
||||||
|
to={`/orders/${order.id}`}
|
||||||
|
className="flex items-center justify-between py-3 hover:bg-gray-50 -mx-2 px-2 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-diesel-50">
|
||||||
|
<Wrench className="h-5 w-5 text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{order.order_number}</p>
|
||||||
|
<p className="text-xs text-gray-500 line-clamp-1">{order.symptoms}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-right">
|
||||||
|
<div>
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{new Date(order.received_at).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
${order.grand_total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Owner Card */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<User className="h-5 w-5 text-diesel-600" />
|
||||||
|
Propietario
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{customer ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Cliente</p>
|
||||||
|
<Link
|
||||||
|
to={`/customers?id=${customer.id}`}
|
||||||
|
className="text-sm font-medium text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
{customer.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{customer.contact_name && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Contacto</p>
|
||||||
|
<p className="text-sm text-gray-900">{customer.contact_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.contact_phone && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Telefono</p>
|
||||||
|
<p className="text-sm text-gray-900">{customer.contact_phone}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">Cargando...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-gray-900">
|
||||||
|
<Settings className="h-5 w-5 text-diesel-600" />
|
||||||
|
Resumen
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Hash className="h-4 w-4" />
|
||||||
|
Total Ordenes
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{serviceHistory.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Completadas
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{serviceHistory.filter(o => o.status === 'delivered').length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
En Proceso
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{serviceHistory.filter(o => !['delivered', 'cancelled'].includes(o.status)).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Gauge className="h-4 w-4" />
|
||||||
|
Odometro
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{vehicle.currentOdometer ? `${vehicle.currentOdometer.toLocaleString()} km` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{vehicle.odometerUpdatedAt && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Ultima Lectura
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
{new Date(vehicle.odometerUpdatedAt).toLocaleDateString('es-MX')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Spent */}
|
||||||
|
<div className="mt-4 border-t border-gray-100 pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Total Invertido</span>
|
||||||
|
<span className="text-lg font-bold text-diesel-600">
|
||||||
|
${serviceHistory.reduce((sum, o) => sum + (o.status === 'delivered' ? o.grand_total : 0), 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showDeleteModal}
|
||||||
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
title="Eliminar Vehiculo"
|
||||||
|
message={`¿Estas seguro de eliminar el vehiculo ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate})? Esta accion no se puede deshacer.`}
|
||||||
|
confirmText="Eliminar"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
564
src/pages/Vehicles.tsx
Normal file
564
src/pages/Vehicles.tsx
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
Truck,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Gauge,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { vehiclesApi } from '../services/api/vehicles';
|
||||||
|
import { customersApi } from '../services/api/customers';
|
||||||
|
import type { Vehicle, VehicleType, VehicleStatus, VehicleFilters } from '../types';
|
||||||
|
import type { Customer } from '../services/api/customers';
|
||||||
|
|
||||||
|
const VEHICLE_TYPES: { value: VehicleType; label: string }[] = [
|
||||||
|
{ value: 'truck', label: 'Camion' },
|
||||||
|
{ value: 'trailer', label: 'Trailer' },
|
||||||
|
{ value: 'bus', label: 'Autobus' },
|
||||||
|
{ value: 'pickup', label: 'Pickup' },
|
||||||
|
{ value: 'other', label: 'Otro' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VEHICLE_STATUS: { value: VehicleStatus; label: string; color: string }[] = [
|
||||||
|
{ value: 'active', label: 'Activo', color: 'bg-green-100 text-green-800' },
|
||||||
|
{ value: 'inactive', label: 'Inactivo', color: 'bg-gray-100 text-gray-800' },
|
||||||
|
{ value: 'sold', label: 'Vendido', color: 'bg-red-100 text-red-800' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const vehicleSchema = z.object({
|
||||||
|
customerId: z.string().min(1, 'Cliente requerido'),
|
||||||
|
licensePlate: z.string().min(1, 'Placas requeridas'),
|
||||||
|
vin: z.string().optional(),
|
||||||
|
economicNumber: z.string().optional(),
|
||||||
|
make: z.string().min(1, 'Marca requerida'),
|
||||||
|
model: z.string().min(1, 'Modelo requerido'),
|
||||||
|
year: z.number().min(1900).max(new Date().getFullYear() + 1),
|
||||||
|
color: z.string().optional(),
|
||||||
|
vehicleType: z.enum(['truck', 'trailer', 'bus', 'pickup', 'other']),
|
||||||
|
currentOdometer: z.number().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type VehicleForm = z.infer<typeof vehicleSchema>;
|
||||||
|
|
||||||
|
export function VehiclesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [filters, setFilters] = useState<VehicleFilters>({ page: 1, pageSize: 20 });
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
|
||||||
|
const [customerSearch, setCustomerSearch] = useState('');
|
||||||
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
||||||
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<VehicleForm>({
|
||||||
|
resolver: zodResolver(vehicleSchema),
|
||||||
|
defaultValues: {
|
||||||
|
vehicleType: 'truck',
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch vehicles
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['vehicles', filters],
|
||||||
|
queryFn: () => vehiclesApi.list(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch customers for search
|
||||||
|
const { data: customersData } = useQuery({
|
||||||
|
queryKey: ['customers', customerSearch],
|
||||||
|
queryFn: () => customersApi.list({ search: customerSearch, pageSize: 10 }),
|
||||||
|
enabled: customerSearch.length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: VehicleForm) => vehiclesApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<VehicleForm> }) =>
|
||||||
|
vehiclesApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => vehiclesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const vehicles = data?.data?.data || [];
|
||||||
|
const customers = customersData?.data?.data || [];
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingVehicle(null);
|
||||||
|
setSelectedCustomer(null);
|
||||||
|
setCustomerSearch('');
|
||||||
|
reset({
|
||||||
|
vehicleType: 'truck',
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (vehicle: Vehicle) => {
|
||||||
|
setEditingVehicle(vehicle);
|
||||||
|
reset({
|
||||||
|
customerId: vehicle.customerId,
|
||||||
|
licensePlate: vehicle.licensePlate,
|
||||||
|
vin: vehicle.vin,
|
||||||
|
economicNumber: vehicle.economicNumber,
|
||||||
|
make: vehicle.make,
|
||||||
|
model: vehicle.model,
|
||||||
|
year: vehicle.year,
|
||||||
|
color: vehicle.color,
|
||||||
|
vehicleType: vehicle.vehicleType,
|
||||||
|
currentOdometer: vehicle.currentOdometer,
|
||||||
|
notes: vehicle.notes,
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingVehicle(null);
|
||||||
|
setSelectedCustomer(null);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomerSelect = (customer: Customer) => {
|
||||||
|
setSelectedCustomer(customer);
|
||||||
|
setValue('customerId', customer.id);
|
||||||
|
setShowCustomerDropdown(false);
|
||||||
|
setCustomerSearch(customer.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: VehicleForm) => {
|
||||||
|
if (editingVehicle) {
|
||||||
|
updateMutation.mutate({ id: editingVehicle.id, data });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (vehicle: Vehicle) => {
|
||||||
|
if (confirm(`Eliminar vehiculo ${vehicle.make} ${vehicle.model}?`)) {
|
||||||
|
deleteMutation.mutate(vehicle.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Vehiculos</h1>
|
||||||
|
<p className="text-sm text-gray-500">Gestiona los vehiculos registrados</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nuevo Vehiculo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por placa, marca, modelo..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={filters.vehicleType || ''}
|
||||||
|
onChange={(e) => setFilters({ ...filters, vehicleType: e.target.value as VehicleType || undefined, page: 1 })}
|
||||||
|
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
{VEHICLE_TYPES.map((type) => (
|
||||||
|
<option key={type.value} value={type.value}>{type.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<AlertCircle className="mb-2 h-8 w-8" />
|
||||||
|
<p>Error al cargar vehiculos</p>
|
||||||
|
</div>
|
||||||
|
) : vehicles.length === 0 ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
||||||
|
<Truck className="mb-2 h-8 w-8" />
|
||||||
|
<p>No hay vehiculos registrados</p>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
Registrar primer vehiculo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Vehiculo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Placas
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Tipo
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Odometro
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{vehicles.map((vehicle: Vehicle) => {
|
||||||
|
const typeLabel = VEHICLE_TYPES.find(t => t.value === vehicle.vehicleType)?.label || vehicle.vehicleType;
|
||||||
|
const statusConfig = VEHICLE_STATUS.find(s => s.value === vehicle.status) || VEHICLE_STATUS[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={vehicle.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<Truck className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{vehicle.make} {vehicle.model}</p>
|
||||||
|
<p className="text-sm text-gray-500">{vehicle.year}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<p className="font-medium text-gray-900">{vehicle.licensePlate}</p>
|
||||||
|
{vehicle.economicNumber && (
|
||||||
|
<p className="text-sm text-gray-500">Eco: {vehicle.economicNumber}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
{typeLabel}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
{vehicle.currentOdometer ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Gauge className="h-4 w-4" />
|
||||||
|
{vehicle.currentOdometer.toLocaleString()} km
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusConfig.color}`}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(vehicle)}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(vehicle)}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data?.data && vehicles.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Pagina {data.data.page} de {data.data.totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
||||||
|
disabled={(filters.page || 1) <= 1}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
||||||
|
disabled={(filters.page || 1) >= data.data.totalPages}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{editingVehicle ? 'Editar Vehiculo' : 'Nuevo Vehiculo'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={handleCloseModal} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Customer Search */}
|
||||||
|
{!editingVehicle && (
|
||||||
|
<div className="relative">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Cliente *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar cliente..."
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerSearch(e.target.value);
|
||||||
|
setShowCustomerDropdown(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowCustomerDropdown(true)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCustomerDropdown && customers.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{customers.map((customer: Customer) => (
|
||||||
|
<button
|
||||||
|
key={customer.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCustomerSelect(customer)}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{customer.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCustomer && (
|
||||||
|
<div className="mt-2 rounded-lg bg-diesel-50 px-3 py-2 text-sm text-diesel-700">
|
||||||
|
Cliente: {selectedCustomer.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.customerId && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.customerId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca *</label>
|
||||||
|
<input
|
||||||
|
{...register('make')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Kenworth"
|
||||||
|
/>
|
||||||
|
{errors.make && <p className="mt-1 text-sm text-red-600">{errors.make.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Modelo *</label>
|
||||||
|
<input
|
||||||
|
{...register('model')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="T800"
|
||||||
|
/>
|
||||||
|
{errors.model && <p className="mt-1 text-sm text-red-600">{errors.model.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Ano *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('year', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{errors.year && <p className="mt-1 text-sm text-red-600">{errors.year.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Tipo</label>
|
||||||
|
<select
|
||||||
|
{...register('vehicleType')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{VEHICLE_TYPES.map((type) => (
|
||||||
|
<option key={type.value} value={type.value}>{type.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Placas *</label>
|
||||||
|
<input
|
||||||
|
{...register('licensePlate')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="ABC-123-XY"
|
||||||
|
/>
|
||||||
|
{errors.licensePlate && <p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">No. Economico</label>
|
||||||
|
<input
|
||||||
|
{...register('economicNumber')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="ECO-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">VIN</label>
|
||||||
|
<input
|
||||||
|
{...register('vin')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="1XKAD49X04J038445"
|
||||||
|
maxLength={17}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Color</label>
|
||||||
|
<input
|
||||||
|
{...register('color')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="Blanco"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Odometro (km)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('currentOdometer', { valueAsNumber: true })}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
placeholder="125000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
||||||
|
<textarea
|
||||||
|
{...register('notes')}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{(createMutation.isPending || updateMutation.isPending) && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{editingVehicle ? 'Guardar Cambios' : 'Crear Vehiculo'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/services/api/auth.ts
Normal file
112
src/services/api/auth.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { User, ApiResponse } from '../../types';
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend user response (camelCase)
|
||||||
|
interface BackendUser {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
role: string;
|
||||||
|
isActive: boolean;
|
||||||
|
emailVerified: boolean;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend login response
|
||||||
|
interface BackendLoginResponse {
|
||||||
|
user: BackendUser;
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
user: User;
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
full_name: string;
|
||||||
|
taller_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map backend user to frontend user
|
||||||
|
function mapUser(backendUser: BackendUser): User {
|
||||||
|
return {
|
||||||
|
id: backendUser.id,
|
||||||
|
email: backendUser.email,
|
||||||
|
full_name: backendUser.fullName,
|
||||||
|
avatar_url: backendUser.avatarUrl,
|
||||||
|
role: backendUser.role,
|
||||||
|
permissions: getRolePermissions(backendUser.role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get permissions based on role
|
||||||
|
function getRolePermissions(role: string): string[] {
|
||||||
|
const permissionMap: Record<string, string[]> = {
|
||||||
|
admin: ['*'],
|
||||||
|
jefe_taller: ['service_orders.*', 'vehicles.*', 'users.read', 'users.create', 'quotes.*', 'diagnostics.*'],
|
||||||
|
mecanico: ['service_orders.read', 'service_orders.update', 'vehicles.read', 'diagnostics.*'],
|
||||||
|
recepcion: ['service_orders.create', 'service_orders.read', 'vehicles.*', 'quotes.*'],
|
||||||
|
almacen: ['parts.*', 'inventory.*'],
|
||||||
|
};
|
||||||
|
return permissionMap[role] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: async (data: LoginRequest): Promise<ApiResponse<LoginResponse>> => {
|
||||||
|
const response = await api.post<ApiResponse<BackendLoginResponse>>('/auth/login', data);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
data: {
|
||||||
|
user: mapUser(response.data.user),
|
||||||
|
token: response.data.token,
|
||||||
|
refreshToken: response.data.refreshToken,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (data: RegisterRequest): Promise<ApiResponse<LoginResponse>> => {
|
||||||
|
const response = await api.post<ApiResponse<BackendLoginResponse>>('/auth/register', data);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
data: {
|
||||||
|
user: mapUser(response.data.user),
|
||||||
|
token: response.data.token,
|
||||||
|
refreshToken: response.data.refreshToken,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: (refreshToken: string) =>
|
||||||
|
api.post<ApiResponse<null>>('/auth/logout', { refreshToken }),
|
||||||
|
|
||||||
|
refreshToken: (refreshToken: string) =>
|
||||||
|
api.post<ApiResponse<{ token: string }>>('/auth/refresh', { refreshToken }),
|
||||||
|
|
||||||
|
getProfile: async (): Promise<ApiResponse<User>> => {
|
||||||
|
const response = await api.get<ApiResponse<BackendUser>>('/auth/profile');
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
data: mapUser(response.data),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
|
api.post<ApiResponse<null>>('/auth/change-password', {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
}),
|
||||||
|
};
|
||||||
85
src/services/api/client.ts
Normal file
85
src/services/api/client.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
|
// API Base URL
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3011/api/v1';
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor - add auth token
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = useAuthStore.getState().token;
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle errors
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
// Handle 401 - Unauthorized
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = useAuthStore.getState().refreshToken;
|
||||||
|
if (refreshToken) {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token } = response.data.data;
|
||||||
|
useAuthStore.getState().setToken(token);
|
||||||
|
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh failed, logout user
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
|
|
||||||
|
// Generic API methods
|
||||||
|
export const api = {
|
||||||
|
get: <T>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.get<T>(url, config).then((res) => res.data),
|
||||||
|
|
||||||
|
post: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||||
|
|
||||||
|
put: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.put<T>(url, data, config).then((res) => res.data),
|
||||||
|
|
||||||
|
patch: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||||
|
|
||||||
|
delete: <T>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||||
|
};
|
||||||
71
src/services/api/customers.ts
Normal file
71
src/services/api/customers.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { ApiResponse, PaginatedResult, BaseFilters } from '../../types';
|
||||||
|
|
||||||
|
// Customer types (using Fleet as customer for commercial customers)
|
||||||
|
export interface Customer {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
contact_email?: string;
|
||||||
|
contact_phone?: string;
|
||||||
|
discount_labor_pct: number;
|
||||||
|
discount_parts_pct: number;
|
||||||
|
credit_days: number;
|
||||||
|
credit_limit: number;
|
||||||
|
vehicle_count: number;
|
||||||
|
notes?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerFilters extends BaseFilters {
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomerRequest {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
contact_email?: string;
|
||||||
|
contact_phone?: string;
|
||||||
|
discount_labor_pct?: number;
|
||||||
|
discount_parts_pct?: number;
|
||||||
|
credit_days?: number;
|
||||||
|
credit_limit?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCustomerRequest extends Partial<CreateCustomerRequest> {
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customersApi = {
|
||||||
|
list: (filters?: CustomerFilters) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||||
|
if (filters?.search) params.append('search', filters.search);
|
||||||
|
if (filters?.page) params.append('page', String(filters.page));
|
||||||
|
if (filters?.pageSize) params.append('limit', String(filters.pageSize));
|
||||||
|
|
||||||
|
return api.get<ApiResponse<PaginatedResult<Customer>>>(`/fleets?${params.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
api.get<ApiResponse<Customer>>(`/fleets/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreateCustomerRequest) =>
|
||||||
|
api.post<ApiResponse<Customer>>('/fleets', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateCustomerRequest) =>
|
||||||
|
api.patch<ApiResponse<Customer>>(`/fleets/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/fleets/${id}`),
|
||||||
|
|
||||||
|
// Get vehicles for a customer
|
||||||
|
getVehicles: (customerId: string) =>
|
||||||
|
api.get<ApiResponse<{ data: unknown[] }>>(`/vehicles?customerId=${customerId}`),
|
||||||
|
};
|
||||||
100
src/services/api/diagnostics.ts
Normal file
100
src/services/api/diagnostics.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { ApiResponse, PaginatedResult, BaseFilters, DiagnosticType } from '../../types';
|
||||||
|
|
||||||
|
// Diagnostic Result type
|
||||||
|
export type DiagnosticResult = 'pass' | 'fail' | 'needs_attention';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface Diagnostic {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
order_id: string | null;
|
||||||
|
order_number: string | null;
|
||||||
|
vehicle_id: string;
|
||||||
|
vehicle_info: string;
|
||||||
|
customer_name: string;
|
||||||
|
diagnostic_type: DiagnosticType;
|
||||||
|
equipment: string | null;
|
||||||
|
performed_at: string;
|
||||||
|
performed_by: string | null;
|
||||||
|
technician_name: string | null;
|
||||||
|
result: DiagnosticResult | null;
|
||||||
|
summary: string | null;
|
||||||
|
raw_data: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticItem {
|
||||||
|
id: string;
|
||||||
|
diagnostic_id: string;
|
||||||
|
component: string;
|
||||||
|
test_name: string;
|
||||||
|
value: string;
|
||||||
|
unit: string | null;
|
||||||
|
min_value: number | null;
|
||||||
|
max_value: number | null;
|
||||||
|
result: DiagnosticResult;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDiagnosticRequest {
|
||||||
|
vehicle_id: string;
|
||||||
|
order_id?: string;
|
||||||
|
diagnostic_type: DiagnosticType;
|
||||||
|
equipment?: string;
|
||||||
|
performed_by?: string;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticFilters extends BaseFilters {
|
||||||
|
diagnostic_type?: DiagnosticType;
|
||||||
|
vehicle_id?: string;
|
||||||
|
order_id?: string;
|
||||||
|
result?: DiagnosticResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
|
export const diagnosticsApi = {
|
||||||
|
// List diagnostics with filters
|
||||||
|
list: (filters?: DiagnosticFilters) =>
|
||||||
|
api.get<ApiResponse<PaginatedResult<Diagnostic>>>('/diagnostics', {
|
||||||
|
params: filters,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get single diagnostic
|
||||||
|
getById: (id: string) =>
|
||||||
|
api.get<ApiResponse<Diagnostic>>(`/diagnostics/${id}`),
|
||||||
|
|
||||||
|
// Create new diagnostic
|
||||||
|
create: (data: CreateDiagnosticRequest) =>
|
||||||
|
api.post<ApiResponse<Diagnostic>>('/diagnostics', data),
|
||||||
|
|
||||||
|
// Update diagnostic
|
||||||
|
update: (id: string, data: Partial<Diagnostic>) =>
|
||||||
|
api.patch<ApiResponse<Diagnostic>>(`/diagnostics/${id}`, data),
|
||||||
|
|
||||||
|
// Delete diagnostic
|
||||||
|
delete: (id: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/diagnostics/${id}`),
|
||||||
|
|
||||||
|
// Get diagnostic items
|
||||||
|
getItems: (diagnosticId: string) =>
|
||||||
|
api.get<ApiResponse<DiagnosticItem[]>>(`/diagnostics/${diagnosticId}/items`),
|
||||||
|
|
||||||
|
// Add item to diagnostic
|
||||||
|
addItem: (diagnosticId: string, item: Partial<DiagnosticItem>) =>
|
||||||
|
api.post<ApiResponse<DiagnosticItem>>(`/diagnostics/${diagnosticId}/items`, item),
|
||||||
|
|
||||||
|
// Update result
|
||||||
|
setResult: (id: string, result: DiagnosticResult, summary?: string) =>
|
||||||
|
api.patch<ApiResponse<Diagnostic>>(`/diagnostics/${id}`, { result, summary }),
|
||||||
|
|
||||||
|
// Get diagnostics by vehicle
|
||||||
|
getByVehicle: (vehicleId: string) =>
|
||||||
|
api.get<ApiResponse<Diagnostic[]>>(`/vehicles/${vehicleId}/diagnostics`),
|
||||||
|
|
||||||
|
// Get diagnostics by order
|
||||||
|
getByOrder: (orderId: string) =>
|
||||||
|
api.get<ApiResponse<Diagnostic[]>>(`/service-orders/${orderId}/diagnostics`),
|
||||||
|
};
|
||||||
20
src/services/api/index.ts
Normal file
20
src/services/api/index.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* API Services Index
|
||||||
|
* Mecánicas Diesel - ERP Suite
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { authApi } from './auth';
|
||||||
|
export { usersApi } from './users';
|
||||||
|
export { serviceOrdersApi } from './serviceOrders';
|
||||||
|
export { vehiclesApi } from './vehicles';
|
||||||
|
export { partsApi } from './parts';
|
||||||
|
export { customersApi } from './customers';
|
||||||
|
export { quotesApi } from './quotes';
|
||||||
|
export { diagnosticsApi } from './diagnostics';
|
||||||
|
export { settingsApi } from './settings';
|
||||||
|
export { api } from './client';
|
||||||
|
export type { LoginRequest, LoginResponse, RegisterRequest } from './auth';
|
||||||
|
export type { CreateUserRequest, UpdateUserRequest, ResetPasswordRequest } from './users';
|
||||||
|
export type { CreateVehicleRequest, UpdateVehicleRequest } from './vehicles';
|
||||||
|
export type { CreatePartRequest, UpdatePartRequest } from './parts';
|
||||||
|
export type { Customer, CreateCustomerRequest, UpdateCustomerRequest } from './customers';
|
||||||
77
src/services/api/parts.ts
Normal file
77
src/services/api/parts.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { ApiResponse, Part, PartFilters, PaginatedResult } from '../../types';
|
||||||
|
|
||||||
|
export interface CreatePartRequest {
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
brand?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
compatibleEngines?: string[];
|
||||||
|
cost?: number;
|
||||||
|
price: number;
|
||||||
|
currentStock?: number;
|
||||||
|
minStock?: number;
|
||||||
|
maxStock?: number;
|
||||||
|
reorderPoint?: number;
|
||||||
|
locationId?: string;
|
||||||
|
unit?: string;
|
||||||
|
barcode?: string;
|
||||||
|
preferredSupplierId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePartRequest {
|
||||||
|
sku?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string | null;
|
||||||
|
brand?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
compatibleEngines?: string[];
|
||||||
|
cost?: number;
|
||||||
|
price?: number;
|
||||||
|
currentStock?: number;
|
||||||
|
minStock?: number;
|
||||||
|
maxStock?: number | null;
|
||||||
|
reorderPoint?: number | null;
|
||||||
|
locationId?: string | null;
|
||||||
|
unit?: string;
|
||||||
|
barcode?: string;
|
||||||
|
preferredSupplierId?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const partsApi = {
|
||||||
|
list: (filters?: PartFilters) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.categoryId) params.append('categoryId', filters.categoryId);
|
||||||
|
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||||
|
if (filters?.lowStock) params.append('lowStock', 'true');
|
||||||
|
if (filters?.search) params.append('search', filters.search);
|
||||||
|
if (filters?.page) params.append('page', String(filters.page));
|
||||||
|
if (filters?.pageSize) params.append('limit', String(filters.pageSize));
|
||||||
|
|
||||||
|
return api.get<ApiResponse<PaginatedResult<Part>>>(`/parts?${params.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
api.get<ApiResponse<Part>>(`/parts/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreatePartRequest) =>
|
||||||
|
api.post<ApiResponse<Part>>('/parts', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdatePartRequest) =>
|
||||||
|
api.patch<ApiResponse<Part>>(`/parts/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/parts/${id}`),
|
||||||
|
|
||||||
|
// Search by SKU or barcode
|
||||||
|
search: (query: string) =>
|
||||||
|
api.get<ApiResponse<Part[]>>(`/parts/search?q=${encodeURIComponent(query)}`),
|
||||||
|
|
||||||
|
// Get low stock items
|
||||||
|
getLowStock: () =>
|
||||||
|
api.get<ApiResponse<Part[]>>('/parts/low-stock'),
|
||||||
|
};
|
||||||
93
src/services/api/quotes.ts
Normal file
93
src/services/api/quotes.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { ApiResponse, PaginatedResult, BaseFilters, QuoteStatus } from '../../types';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface Quote {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
quote_number: string;
|
||||||
|
customer_id: string;
|
||||||
|
customer_name: string;
|
||||||
|
vehicle_id: string;
|
||||||
|
vehicle_info: string;
|
||||||
|
status: QuoteStatus;
|
||||||
|
valid_until: string;
|
||||||
|
labor_total: number;
|
||||||
|
parts_total: number;
|
||||||
|
discount_amount: number;
|
||||||
|
tax: number;
|
||||||
|
grand_total: number;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteItem {
|
||||||
|
id: string;
|
||||||
|
quote_id: string;
|
||||||
|
item_type: 'service' | 'part';
|
||||||
|
service_id: string | null;
|
||||||
|
part_id: string | null;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
discount: number;
|
||||||
|
subtotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuoteRequest {
|
||||||
|
customer_id: string;
|
||||||
|
vehicle_id: string;
|
||||||
|
valid_days?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteFilters extends BaseFilters {
|
||||||
|
status?: QuoteStatus;
|
||||||
|
customer_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
|
export const quotesApi = {
|
||||||
|
// List quotes with filters
|
||||||
|
list: (filters?: QuoteFilters) =>
|
||||||
|
api.get<ApiResponse<PaginatedResult<Quote>>>('/quotes', {
|
||||||
|
params: filters,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get single quote
|
||||||
|
getById: (id: string) =>
|
||||||
|
api.get<ApiResponse<Quote>>(`/quotes/${id}`),
|
||||||
|
|
||||||
|
// Create new quote
|
||||||
|
create: (data: CreateQuoteRequest) =>
|
||||||
|
api.post<ApiResponse<Quote>>('/quotes', data),
|
||||||
|
|
||||||
|
// Update quote
|
||||||
|
update: (id: string, data: Partial<Quote>) =>
|
||||||
|
api.patch<ApiResponse<Quote>>(`/quotes/${id}`, data),
|
||||||
|
|
||||||
|
// Change status
|
||||||
|
changeStatus: (id: string, status: QuoteStatus) =>
|
||||||
|
api.post<ApiResponse<Quote>>(`/quotes/${id}/status`, { status }),
|
||||||
|
|
||||||
|
// Get quote items
|
||||||
|
getItems: (quoteId: string) =>
|
||||||
|
api.get<ApiResponse<QuoteItem[]>>(`/quotes/${quoteId}/items`),
|
||||||
|
|
||||||
|
// Add item to quote
|
||||||
|
addItem: (quoteId: string, item: Partial<QuoteItem>) =>
|
||||||
|
api.post<ApiResponse<QuoteItem>>(`/quotes/${quoteId}/items`, item),
|
||||||
|
|
||||||
|
// Remove item from quote
|
||||||
|
removeItem: (quoteId: string, itemId: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/quotes/${quoteId}/items/${itemId}`),
|
||||||
|
|
||||||
|
// Convert to service order
|
||||||
|
convertToOrder: (quoteId: string) =>
|
||||||
|
api.post<ApiResponse<{ orderId: string }>>(`/quotes/${quoteId}/convert`),
|
||||||
|
|
||||||
|
// Send to customer
|
||||||
|
send: (quoteId: string, email?: string) =>
|
||||||
|
api.post<ApiResponse<Quote>>(`/quotes/${quoteId}/send`, { email }),
|
||||||
|
};
|
||||||
138
src/services/api/serviceOrders.ts
Normal file
138
src/services/api/serviceOrders.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { ApiResponse, PaginatedResult, BaseFilters, ServiceOrderStatus } from '../../types';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface ServiceOrder {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
order_number: string;
|
||||||
|
customer_id: string;
|
||||||
|
customer_name: string;
|
||||||
|
vehicle_id: string;
|
||||||
|
vehicle_info: string;
|
||||||
|
status: ServiceOrderStatus;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
|
received_at: string;
|
||||||
|
promised_at: string | null;
|
||||||
|
mechanic_id: string | null;
|
||||||
|
mechanic_name: string | null;
|
||||||
|
bay_id: string | null;
|
||||||
|
bay_name: string | null;
|
||||||
|
symptoms: string;
|
||||||
|
notes: string | null;
|
||||||
|
labor_total: number;
|
||||||
|
parts_total: number;
|
||||||
|
tax: number;
|
||||||
|
grand_total: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceOrderItem {
|
||||||
|
id: string;
|
||||||
|
order_id: string;
|
||||||
|
item_type: 'service' | 'part';
|
||||||
|
service_id: string | null;
|
||||||
|
part_id: string | null;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
discount: number;
|
||||||
|
subtotal: number;
|
||||||
|
actual_hours: number | null;
|
||||||
|
performed_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateServiceOrderRequest {
|
||||||
|
customer_id: string;
|
||||||
|
vehicle_id: string;
|
||||||
|
symptoms: string;
|
||||||
|
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
|
promised_at?: string;
|
||||||
|
mechanic_id?: string;
|
||||||
|
bay_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceOrderFilters extends BaseFilters {
|
||||||
|
status?: ServiceOrderStatus;
|
||||||
|
priority?: string;
|
||||||
|
mechanic_id?: string;
|
||||||
|
bay_id?: string;
|
||||||
|
customer_id?: string;
|
||||||
|
vehicle_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
|
export const serviceOrdersApi = {
|
||||||
|
// List orders with filters
|
||||||
|
list: (filters?: ServiceOrderFilters) =>
|
||||||
|
api.get<ApiResponse<PaginatedResult<ServiceOrder>>>('/service-orders', {
|
||||||
|
params: filters,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get single order
|
||||||
|
getById: (id: string) =>
|
||||||
|
api.get<ApiResponse<ServiceOrder>>(`/service-orders/${id}`),
|
||||||
|
|
||||||
|
// Create new order
|
||||||
|
create: (data: CreateServiceOrderRequest) =>
|
||||||
|
api.post<ApiResponse<ServiceOrder>>('/service-orders', data),
|
||||||
|
|
||||||
|
// Update order
|
||||||
|
update: (id: string, data: Partial<ServiceOrder>) =>
|
||||||
|
api.patch<ApiResponse<ServiceOrder>>(`/service-orders/${id}`, data),
|
||||||
|
|
||||||
|
// Change status
|
||||||
|
changeStatus: (id: string, status: ServiceOrderStatus, notes?: string) =>
|
||||||
|
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/status`, {
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Assign mechanic and bay
|
||||||
|
assign: (id: string, mechanicId: string, bayId: string) =>
|
||||||
|
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/assign`, {
|
||||||
|
mechanic_id: mechanicId,
|
||||||
|
bay_id: bayId,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get order items
|
||||||
|
getItems: (orderId: string) =>
|
||||||
|
api.get<ApiResponse<ServiceOrderItem[]>>(`/service-orders/${orderId}/items`),
|
||||||
|
|
||||||
|
// Add item to order
|
||||||
|
addItem: (orderId: string, item: Partial<ServiceOrderItem>) =>
|
||||||
|
api.post<ApiResponse<ServiceOrderItem>>(`/service-orders/${orderId}/items`, item),
|
||||||
|
|
||||||
|
// Remove item from order
|
||||||
|
removeItem: (orderId: string, itemId: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/service-orders/${orderId}/items/${itemId}`),
|
||||||
|
|
||||||
|
// Close order
|
||||||
|
close: (id: string, finalOdometer: number) =>
|
||||||
|
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/close`, {
|
||||||
|
final_odometer: finalOdometer,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get kanban view data
|
||||||
|
getKanbanView: () =>
|
||||||
|
api.get<ApiResponse<Record<ServiceOrderStatus, ServiceOrder[]>>>('/service-orders/kanban'),
|
||||||
|
|
||||||
|
// Get order history
|
||||||
|
getHistory: (vehicleId: string) =>
|
||||||
|
api.get<ApiResponse<ServiceOrder[]>>(`/vehicles/${vehicleId}/service-history`),
|
||||||
|
|
||||||
|
// Get dashboard stats
|
||||||
|
getStats: () =>
|
||||||
|
api.get<ApiResponse<DashboardStats>>('/service-orders/stats'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dashboard Stats type
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalOrders: number;
|
||||||
|
pendingOrders: number;
|
||||||
|
inProgressOrders: number;
|
||||||
|
completedToday: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
averageTicket: number;
|
||||||
|
}
|
||||||
143
src/services/api/settings.ts
Normal file
143
src/services/api/settings.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { ApiResponse } from '../../types';
|
||||||
|
|
||||||
|
// Tenant/Workshop Settings
|
||||||
|
export interface TenantSettings {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legal_name: string;
|
||||||
|
rfc: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
website: string;
|
||||||
|
logo_url: string | null;
|
||||||
|
default_tax_rate: number;
|
||||||
|
labor_rate: number;
|
||||||
|
working_hours_start: string;
|
||||||
|
working_hours_end: string;
|
||||||
|
quote_validity_days: number;
|
||||||
|
currency: string;
|
||||||
|
timezone: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTenantSettingsRequest {
|
||||||
|
name?: string;
|
||||||
|
legal_name?: string;
|
||||||
|
rfc?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip_code?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
website?: string;
|
||||||
|
logo_url?: string | null;
|
||||||
|
default_tax_rate?: number;
|
||||||
|
labor_rate?: number;
|
||||||
|
working_hours_start?: string;
|
||||||
|
working_hours_end?: string;
|
||||||
|
quote_validity_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work Bay Types
|
||||||
|
export interface WorkBay {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
bay_type: 'general' | 'diesel' | 'heavy_duty';
|
||||||
|
status: 'available' | 'occupied' | 'maintenance';
|
||||||
|
current_order_id: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWorkBayRequest {
|
||||||
|
name: string;
|
||||||
|
bay_type: 'general' | 'diesel' | 'heavy_duty';
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWorkBayRequest {
|
||||||
|
name?: string;
|
||||||
|
bay_type?: 'general' | 'diesel' | 'heavy_duty';
|
||||||
|
status?: 'available' | 'occupied' | 'maintenance';
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service Catalog
|
||||||
|
export interface ServiceCatalogItem {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string;
|
||||||
|
default_price: number;
|
||||||
|
estimated_hours: number;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateServiceCatalogItemRequest {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
default_price: number;
|
||||||
|
estimated_hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
|
export const settingsApi = {
|
||||||
|
// Get current tenant settings
|
||||||
|
getSettings: () =>
|
||||||
|
api.get<ApiResponse<TenantSettings>>('/settings'),
|
||||||
|
|
||||||
|
// Update tenant settings
|
||||||
|
updateSettings: (data: UpdateTenantSettingsRequest) =>
|
||||||
|
api.patch<ApiResponse<TenantSettings>>('/settings', data),
|
||||||
|
|
||||||
|
// Upload logo
|
||||||
|
uploadLogo: (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('logo', file);
|
||||||
|
return api.post<ApiResponse<{ logo_url: string }>>('/settings/logo', formData);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Work Bays
|
||||||
|
listBays: () =>
|
||||||
|
api.get<ApiResponse<WorkBay[]>>('/settings/bays'),
|
||||||
|
|
||||||
|
createBay: (data: CreateWorkBayRequest) =>
|
||||||
|
api.post<ApiResponse<WorkBay>>('/settings/bays', data),
|
||||||
|
|
||||||
|
updateBay: (id: string, data: UpdateWorkBayRequest) =>
|
||||||
|
api.patch<ApiResponse<WorkBay>>(`/settings/bays/${id}`, data),
|
||||||
|
|
||||||
|
deleteBay: (id: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/settings/bays/${id}`),
|
||||||
|
|
||||||
|
// Service Catalog
|
||||||
|
listServices: () =>
|
||||||
|
api.get<ApiResponse<ServiceCatalogItem[]>>('/settings/services'),
|
||||||
|
|
||||||
|
createService: (data: CreateServiceCatalogItemRequest) =>
|
||||||
|
api.post<ApiResponse<ServiceCatalogItem>>('/settings/services', data),
|
||||||
|
|
||||||
|
updateService: (id: string, data: Partial<CreateServiceCatalogItemRequest>) =>
|
||||||
|
api.patch<ApiResponse<ServiceCatalogItem>>(`/settings/services/${id}`, data),
|
||||||
|
|
||||||
|
deleteService: (id: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/settings/services/${id}`),
|
||||||
|
|
||||||
|
toggleServiceActive: (id: string, isActive: boolean) =>
|
||||||
|
api.patch<ApiResponse<ServiceCatalogItem>>(`/settings/services/${id}`, { is_active: isActive }),
|
||||||
|
};
|
||||||
49
src/services/api/users.ts
Normal file
49
src/services/api/users.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { ApiResponse, UserDetail, UserFilters, PaginatedResult, UserRole } from '../../types';
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
fullName: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
fullName?: string;
|
||||||
|
role?: UserRole;
|
||||||
|
isActive?: boolean;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
list: (filters?: UserFilters) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.role) params.append('role', filters.role);
|
||||||
|
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||||
|
if (filters?.search) params.append('search', filters.search);
|
||||||
|
if (filters?.page) params.append('page', String(filters.page));
|
||||||
|
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||||
|
|
||||||
|
return api.get<ApiResponse<PaginatedResult<UserDetail>>>(`/users?${params.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
api.get<ApiResponse<UserDetail>>(`/users/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreateUserRequest) =>
|
||||||
|
api.post<ApiResponse<UserDetail>>('/users', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateUserRequest) =>
|
||||||
|
api.patch<ApiResponse<UserDetail>>(`/users/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/users/${id}`),
|
||||||
|
|
||||||
|
resetPassword: (id: string, data: ResetPasswordRequest) =>
|
||||||
|
api.patch<ApiResponse<null>>(`/users/${id}/reset-password`, data),
|
||||||
|
};
|
||||||
71
src/services/api/vehicles.ts
Normal file
71
src/services/api/vehicles.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
Vehicle,
|
||||||
|
VehicleFilters,
|
||||||
|
PaginatedResult,
|
||||||
|
VehicleType,
|
||||||
|
VehicleStatus,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
export interface CreateVehicleRequest {
|
||||||
|
customerId: string;
|
||||||
|
fleetId?: string;
|
||||||
|
vin?: string;
|
||||||
|
licensePlate: string;
|
||||||
|
economicNumber?: string;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
year: number;
|
||||||
|
color?: string;
|
||||||
|
vehicleType?: VehicleType;
|
||||||
|
currentOdometer?: number;
|
||||||
|
photoUrl?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateVehicleRequest {
|
||||||
|
fleetId?: string | null;
|
||||||
|
vin?: string;
|
||||||
|
licensePlate?: string;
|
||||||
|
economicNumber?: string;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
year?: number;
|
||||||
|
color?: string;
|
||||||
|
vehicleType?: VehicleType;
|
||||||
|
currentOdometer?: number;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
status?: VehicleStatus;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vehiclesApi = {
|
||||||
|
list: (filters?: VehicleFilters) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.vehicleType) params.append('vehicleType', filters.vehicleType);
|
||||||
|
if (filters?.customerId) params.append('customerId', filters.customerId);
|
||||||
|
if (filters?.status) params.append('status', filters.status);
|
||||||
|
if (filters?.search) params.append('search', filters.search);
|
||||||
|
if (filters?.page) params.append('page', String(filters.page));
|
||||||
|
if (filters?.pageSize) params.append('limit', String(filters.pageSize));
|
||||||
|
|
||||||
|
return api.get<ApiResponse<PaginatedResult<Vehicle>>>(`/vehicles?${params.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
api.get<ApiResponse<Vehicle>>(`/vehicles/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreateVehicleRequest) =>
|
||||||
|
api.post<ApiResponse<Vehicle>>('/vehicles', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateVehicleRequest) =>
|
||||||
|
api.patch<ApiResponse<Vehicle>>(`/vehicles/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
api.delete<ApiResponse<null>>(`/vehicles/${id}`),
|
||||||
|
|
||||||
|
// Get service history for a vehicle
|
||||||
|
getServiceHistory: (vehicleId: string) =>
|
||||||
|
api.get<ApiResponse<{ orders: unknown[] }>>(`/vehicles/${vehicleId}/service-history`),
|
||||||
|
};
|
||||||
56
src/store/authStore.ts
Normal file
56
src/store/authStore.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { User, AuthState } from '../types';
|
||||||
|
|
||||||
|
interface AuthStore extends AuthState {
|
||||||
|
// Actions
|
||||||
|
login: (user: User, token: string, refreshToken: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (user: Partial<User>) => void;
|
||||||
|
setToken: (token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
// Initial state
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login: (user, token, refreshToken) =>
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
isAuthenticated: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
logout: () =>
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateUser: (userData) =>
|
||||||
|
set((state) => ({
|
||||||
|
user: state.user ? { ...state.user, ...userData } : null,
|
||||||
|
})),
|
||||||
|
|
||||||
|
setToken: (token) => set({ token }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'mecanicas-auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
token: state.token,
|
||||||
|
refreshToken: state.refreshToken,
|
||||||
|
user: state.user,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
70
src/store/tallerStore.ts
Normal file
70
src/store/tallerStore.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface Taller {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legal_name: string;
|
||||||
|
rfc: string;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
logo_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkBay {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
bay_type: 'general' | 'diesel' | 'heavy_duty';
|
||||||
|
status: 'available' | 'occupied' | 'maintenance';
|
||||||
|
current_order_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TallerState {
|
||||||
|
currentTaller: Taller | null;
|
||||||
|
selectedBay: WorkBay | null;
|
||||||
|
workBays: WorkBay[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TallerStore extends TallerState {
|
||||||
|
setTaller: (taller: Taller) => void;
|
||||||
|
setSelectedBay: (bay: WorkBay | null) => void;
|
||||||
|
setWorkBays: (bays: WorkBay[]) => void;
|
||||||
|
updateBayStatus: (bayId: string, status: WorkBay['status']) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TallerState = {
|
||||||
|
currentTaller: null,
|
||||||
|
selectedBay: null,
|
||||||
|
workBays: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTallerStore = create<TallerStore>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setTaller: (taller) => set({ currentTaller: taller }),
|
||||||
|
|
||||||
|
setSelectedBay: (bay) => set({ selectedBay: bay }),
|
||||||
|
|
||||||
|
setWorkBays: (bays) => set({ workBays: bays }),
|
||||||
|
|
||||||
|
updateBayStatus: (bayId, status) =>
|
||||||
|
set((state) => ({
|
||||||
|
workBays: state.workBays.map((bay) =>
|
||||||
|
bay.id === bayId ? { ...bay, status } : bay
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}));
|
||||||
66
src/store/toastStore.ts
Normal file
66
src/store/toastStore.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
toasts: Toast[];
|
||||||
|
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
clearToasts: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
addToast: (toast) => {
|
||||||
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
const newToast: Toast = {
|
||||||
|
...toast,
|
||||||
|
id,
|
||||||
|
duration: toast.duration ?? 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
toasts: [...state.toasts, newToast],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
|
if (newToast.duration && newToast.duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}));
|
||||||
|
}, newToast.duration);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeToast: (id) => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearToasts: () => {
|
||||||
|
set({ toasts: [] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper functions for convenience
|
||||||
|
export const toast = {
|
||||||
|
success: (title: string, message?: string) =>
|
||||||
|
useToastStore.getState().addToast({ type: 'success', title, message }),
|
||||||
|
error: (title: string, message?: string) =>
|
||||||
|
useToastStore.getState().addToast({ type: 'error', title, message }),
|
||||||
|
warning: (title: string, message?: string) =>
|
||||||
|
useToastStore.getState().addToast({ type: 'warning', title, message }),
|
||||||
|
info: (title: string, message?: string) =>
|
||||||
|
useToastStore.getState().addToast({ type: 'info', title, message }),
|
||||||
|
};
|
||||||
248
src/types/index.ts
Normal file
248
src/types/index.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// TIPOS BASE - ERP MECANICAS DIESEL
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Tipos de paginacion
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de filtros
|
||||||
|
export interface BaseFilters extends PaginationParams {
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de auditoria
|
||||||
|
export interface AuditFields {
|
||||||
|
created_at: string;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
deleted_by?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipo base para entidades
|
||||||
|
export interface BaseEntity extends AuditFields {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de usuario y auth
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
role: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de API response
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
success: false;
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status de ordenes de servicio
|
||||||
|
export type ServiceOrderStatus =
|
||||||
|
| 'received'
|
||||||
|
| 'diagnosing'
|
||||||
|
| 'quoted'
|
||||||
|
| 'approved'
|
||||||
|
| 'in_repair'
|
||||||
|
| 'waiting_parts'
|
||||||
|
| 'ready'
|
||||||
|
| 'delivered'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
// Tipos de diagnostico
|
||||||
|
export type DiagnosticType =
|
||||||
|
| 'obd_scanner'
|
||||||
|
| 'injector_bench'
|
||||||
|
| 'pump_bench'
|
||||||
|
| 'measurements';
|
||||||
|
|
||||||
|
// Tipos de movimiento de inventario
|
||||||
|
export type MovementType =
|
||||||
|
| 'purchase'
|
||||||
|
| 'sale'
|
||||||
|
| 'transfer'
|
||||||
|
| 'adjustment'
|
||||||
|
| 'return'
|
||||||
|
| 'production';
|
||||||
|
|
||||||
|
// Status de cotizacion
|
||||||
|
export type QuoteStatus =
|
||||||
|
| 'draft'
|
||||||
|
| 'sent'
|
||||||
|
| 'viewed'
|
||||||
|
| 'approved'
|
||||||
|
| 'rejected'
|
||||||
|
| 'expired'
|
||||||
|
| 'converted';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ENTIDADES DE DOMINIO
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Service Order
|
||||||
|
export type ServiceOrderPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||||
|
|
||||||
|
export interface ServiceOrder {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
customerId: string;
|
||||||
|
vehicleId: string;
|
||||||
|
quoteId?: string;
|
||||||
|
assignedTo?: string;
|
||||||
|
bayId?: string;
|
||||||
|
status: ServiceOrderStatus;
|
||||||
|
priority: ServiceOrderPriority;
|
||||||
|
receivedAt: string;
|
||||||
|
promisedAt?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
deliveredAt?: string;
|
||||||
|
odometerIn?: number;
|
||||||
|
odometerOut?: number;
|
||||||
|
customerSymptoms?: string;
|
||||||
|
laborTotal: number;
|
||||||
|
partsTotal: number;
|
||||||
|
discountAmount: number;
|
||||||
|
discountPercent: number;
|
||||||
|
tax: number;
|
||||||
|
grandTotal: number;
|
||||||
|
internalNotes?: string;
|
||||||
|
customerNotes?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
// Relations
|
||||||
|
vehicle?: Vehicle;
|
||||||
|
assignedUser?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vehicle
|
||||||
|
export type VehicleType = 'truck' | 'trailer' | 'bus' | 'pickup' | 'other';
|
||||||
|
export type VehicleStatus = 'active' | 'inactive' | 'sold';
|
||||||
|
|
||||||
|
export interface Vehicle {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
customerId: string;
|
||||||
|
fleetId?: string;
|
||||||
|
vin?: string;
|
||||||
|
licensePlate: string;
|
||||||
|
economicNumber?: string;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
year: number;
|
||||||
|
color?: string;
|
||||||
|
vehicleType: VehicleType;
|
||||||
|
currentOdometer?: number;
|
||||||
|
odometerUpdatedAt?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
status: VehicleStatus;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part (Refaccion)
|
||||||
|
export interface Part {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
brand?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
compatibleEngines?: string[];
|
||||||
|
cost?: number;
|
||||||
|
price: number;
|
||||||
|
currentStock: number;
|
||||||
|
reservedStock: number;
|
||||||
|
minStock: number;
|
||||||
|
maxStock?: number;
|
||||||
|
reorderPoint?: number;
|
||||||
|
locationId?: string;
|
||||||
|
unit: string;
|
||||||
|
barcode?: string;
|
||||||
|
preferredSupplierId?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User (para gestión de usuarios)
|
||||||
|
export interface UserDetail extends User {
|
||||||
|
tenantId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
emailVerified: boolean;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'admin' | 'jefe_taller' | 'mecanico' | 'recepcion' | 'almacen';
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
export interface ServiceOrderFilters extends BaseFilters {
|
||||||
|
status?: ServiceOrderStatus;
|
||||||
|
priority?: ServiceOrderPriority;
|
||||||
|
assignedTo?: string;
|
||||||
|
vehicleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleFilters extends BaseFilters {
|
||||||
|
vehicleType?: VehicleType;
|
||||||
|
customerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartFilters extends BaseFilters {
|
||||||
|
categoryId?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
lowStock?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserFilters {
|
||||||
|
role?: UserRole;
|
||||||
|
isActive?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
39
tailwind.config.js
Normal file
39
tailwind.config.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
800: '#1e40af',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
950: '#172554',
|
||||||
|
},
|
||||||
|
diesel: {
|
||||||
|
50: '#fef3c7',
|
||||||
|
100: '#fde68a',
|
||||||
|
200: '#fcd34d',
|
||||||
|
300: '#fbbf24',
|
||||||
|
400: '#f59e0b',
|
||||||
|
500: '#d97706',
|
||||||
|
600: '#b45309',
|
||||||
|
700: '#92400e',
|
||||||
|
800: '#78350f',
|
||||||
|
900: '#451a03',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user