diff --git a/src/components/ui/DonutChart.tsx b/src/components/ui/DonutChart.tsx new file mode 100644 index 0000000..0587e12 --- /dev/null +++ b/src/components/ui/DonutChart.tsx @@ -0,0 +1,131 @@ +import { useMemo } from 'react'; + +interface DonutChartItem { + label: string; + value: number; + color: string; +} + +interface DonutChartProps { + data: DonutChartItem[]; + size?: number; + strokeWidth?: number; + showLegend?: boolean; + centerLabel?: string; + centerValue?: number; +} + +export function DonutChart({ + data, + size = 200, + strokeWidth = 30, + showLegend = true, + centerLabel, + centerValue, +}: DonutChartProps) { + const total = data.reduce((sum, item) => sum + item.value, 0); + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const center = size / 2; + + // Calculate stroke dash arrays and offsets using useMemo to avoid reassignment + const segments = useMemo(() => { + const result: Array<{ + label: string; + value: number; + color: string; + percentage: number; + strokeDasharray: string; + strokeDashoffset: number; + }> = []; + + let accumulatedOffset = 0; + + for (const item of data) { + const percentage = total > 0 ? item.value / total : 0; + const strokeDasharray = `${percentage * circumference} ${circumference}`; + const strokeDashoffset = -accumulatedOffset; + + result.push({ + ...item, + percentage, + strokeDasharray, + strokeDashoffset, + }); + + accumulatedOffset += percentage * circumference; + } + + return result; + }, [data, total, circumference]); + + return ( +
+
+ + {/* Background circle */} + + {/* Data segments */} + {segments.map((segment, index) => ( + + ))} + + {/* Center text */} + {(centerLabel || centerValue !== undefined) && ( +
+ {centerValue !== undefined && ( + {centerValue} + )} + {centerLabel && ( + {centerLabel} + )} +
+ )} +
+ + {/* Legend */} + {showLegend && ( +
+ {segments.map((segment, index) => ( +
+
+ + {segment.label} + + ({segment.value}) + + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 0ea1f5c..6b453af 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -22,3 +22,5 @@ export { SkeletonCard, } from './LoadingSpinner'; export type { SpinnerSize, SpinnerVariant } from './LoadingSpinner'; + +export { DonutChart } from './DonutChart'; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index aa1022e..ff4716d 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -10,23 +10,28 @@ import { AlertTriangle, Users, TrendingUp, + DollarSign, + AlertCircle, + Calendar, } from 'lucide-react'; import { serviceOrdersApi } from '../services/api/serviceOrders'; import { partsApi } from '../services/api/parts'; import { customersApi } from '../services/api/customers'; +import { usersApi } from '../services/api/users'; +import { DonutChart } from '../components/ui'; import type { ServiceOrder, DashboardStats } from '../services/api/serviceOrders'; import type { ServiceOrderStatus, Part } from '../types'; -const STATUS_CONFIG: Record = { - 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' }, +const STATUS_CONFIG: Record = { + received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800', chartColor: '#3b82f6' }, + diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800', chartColor: '#8b5cf6' }, + quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800', chartColor: '#eab308' }, + approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800', chartColor: '#22c55e' }, + in_repair: { label: 'En Reparacion', color: 'bg-orange-100 text-orange-800', chartColor: '#f97316' }, + waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800', chartColor: '#ef4444' }, + ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800', chartColor: '#10b981' }, + delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800', chartColor: '#6b7280' }, + cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500', chartColor: '#9ca3af' }, }; export function Dashboard() { @@ -42,6 +47,12 @@ export function Dashboard() { queryFn: () => serviceOrdersApi.list({ page: 1, pageSize: 5 }), }); + // Fetch all orders for status chart + const { data: allOrdersData } = useQuery({ + queryKey: ['all-orders-status'], + queryFn: () => serviceOrdersApi.list({ pageSize: 1000 }), + }); + // Fetch low stock parts const { data: lowStockData } = useQuery({ queryKey: ['low-stock-parts'], @@ -54,6 +65,12 @@ export function Dashboard() { queryFn: () => customersApi.list({ pageSize: 1 }), }); + // Fetch technicians (mechanics) + const { data: techniciansData } = useQuery({ + queryKey: ['technicians'], + queryFn: () => usersApi.list({ role: 'mecanico', isActive: true }), + }); + const stats: DashboardStats = statsData?.data || { totalOrders: 0, pendingOrders: 0, @@ -64,8 +81,36 @@ export function Dashboard() { }; const recentOrders = ordersData?.data?.data || []; + const allOrders = allOrdersData?.data?.data || []; const lowStockParts: Part[] = lowStockData?.data || []; const totalCustomers = customersData?.data?.total || 0; + const totalTechnicians = techniciansData?.data?.total || 0; + + // Calculate orders by status for donut chart + const ordersByStatus = allOrders.reduce((acc: Record, order: ServiceOrder) => { + const status = order.status as ServiceOrderStatus; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {}); + + // Prepare donut chart data (only active statuses) + const activeStatuses: ServiceOrderStatus[] = ['received', 'diagnosing', 'quoted', 'approved', 'in_repair', 'waiting_parts', 'ready']; + const donutData = activeStatuses + .filter((status) => ordersByStatus[status] > 0) + .map((status) => ({ + label: STATUS_CONFIG[status].label, + value: ordersByStatus[status] || 0, + color: STATUS_CONFIG[status].chartColor, + })); + + const totalActiveOrders = activeStatuses.reduce((sum, status) => sum + (ordersByStatus[status] || 0), 0); + + // Calculate urgent/overdue orders + const urgentOrders = allOrders.filter((o: ServiceOrder) => o.priority === 'urgent' && o.status !== 'delivered' && o.status !== 'cancelled').length; + const overdueOrders = allOrders.filter((o: ServiceOrder) => { + if (o.status === 'delivered' || o.status === 'cancelled' || !o.promised_at) return false; + return new Date(o.promised_at) < new Date(); + }).length; const statCards = [ { name: 'Ordenes Activas', value: stats.pendingOrders + stats.inProgressOrders, icon: Wrench, color: 'bg-blue-500' }, @@ -77,9 +122,20 @@ export function Dashboard() { return (
{/* Page header */} -
-

Dashboard

-

Resumen de operaciones del taller

+
+
+

Dashboard

+

Resumen de operaciones del taller

+
+
+ + {new Date().toLocaleDateString('es-MX', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + })} +
{/* Stats */} @@ -104,14 +160,14 @@ export function Dashboard() { ))}
- {/* Revenue stats */} -
+ {/* Revenue + Technicians stats */} +
- +
-

Ingresos Totales

+

Ingresos del Mes

{statsLoading ? (
) : ( @@ -121,9 +177,10 @@ export function Dashboard() { )}
+
- +

Ticket Promedio

@@ -136,12 +193,52 @@ export function Dashboard() { )}
+ +
+
+ +
+
+

Tecnicos Disponibles

+

{totalTechnicians}

+
+
+ +
+
0 || overdueOrders > 0 ? 'bg-red-500' : 'bg-gray-400'} p-3`}> + +
+
+

Urgentes / Atrasadas

+

0 || overdueOrders > 0 ? 'text-red-600' : 'text-gray-900'}`}> + {urgentOrders} / {overdueOrders} +

+
+
{/* Content grid */} -
- {/* Recent orders */} +
+ {/* Orders by status donut chart */}
+

Ordenes por Estado

+ {donutData.length > 0 ? ( + + ) : ( +
+

Sin ordenes activas

+
+ )} +
+ + {/* Recent orders */} +

Ordenes Recientes

@@ -190,7 +287,10 @@ export function Dashboard() {
)}
+
+ {/* Quick actions and alerts */} +
{/* Quick actions */}

@@ -258,11 +358,11 @@ export function Dashboard() {

{/* Today's schedule */} -
+

Resumen del Dia

-
+
@@ -275,6 +375,7 @@ export function Dashboard() { )}

ordenes por iniciar

+
@@ -287,6 +388,7 @@ export function Dashboard() { )}

ordenes en reparacion

+
diff --git a/src/pages/ServiceOrderDetail.tsx b/src/pages/ServiceOrderDetail.tsx index 332d22e..845c5b5 100644 --- a/src/pages/ServiceOrderDetail.tsx +++ b/src/pages/ServiceOrderDetail.tsx @@ -1,5 +1,9 @@ +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 { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { ArrowLeft, Truck, @@ -13,13 +17,16 @@ import { Play, Pause, FileText, - Edit2, Loader2, DollarSign, + Plus, + X, + Search, } from 'lucide-react'; import { serviceOrdersApi } from '../services/api/serviceOrders'; +import { partsApi } from '../services/api/parts'; import type { ServiceOrder, ServiceOrderItem } from '../services/api/serviceOrders'; -import type { ServiceOrderStatus } from '../types'; +import type { ServiceOrderStatus, Part } from '../types'; const STATUS_CONFIG: Record = { received: { label: 'Recibido', color: 'text-blue-700', bgColor: 'bg-blue-100' }, @@ -40,6 +47,17 @@ const PRIORITY_CONFIG = { urgent: { label: 'Urgente', color: 'text-red-600' }, }; +// Status flow for timeline +const STATUS_FLOW: ServiceOrderStatus[] = [ + 'received', + 'diagnosing', + 'quoted', + 'approved', + 'in_repair', + 'ready', + 'delivered', +]; + // Status transitions const STATUS_ACTIONS: Record = { received: [ @@ -69,10 +87,43 @@ const STATUS_ACTIONS: Record; + export function ServiceOrderDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const [showAddItemModal, setShowAddItemModal] = useState(false); + const [partSearch, setPartSearch] = useState(''); + const [selectedPart, setSelectedPart] = useState(null); + + const { + register, + handleSubmit, + setValue, + watch, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(addItemSchema), + defaultValues: { + itemType: 'service', + quantity: 1, + unitPrice: 0, + }, + }); + + const itemType = watch('itemType'); // Fetch order details const { data: orderData, isLoading, error } = useQuery({ @@ -88,6 +139,13 @@ export function ServiceOrderDetailPage() { enabled: !!id, }); + // Search parts + const { data: partsData } = useQuery({ + queryKey: ['parts-search', partSearch], + queryFn: () => partsApi.search(partSearch), + enabled: partSearch.length >= 2 && itemType === 'part', + }); + // Status change mutation const statusMutation = useMutation({ mutationFn: ({ status, notes }: { status: ServiceOrderStatus; notes?: string }) => @@ -98,8 +156,52 @@ export function ServiceOrderDetailPage() { }, }); + // Add item mutation + const addItemMutation = useMutation({ + mutationFn: (item: Partial) => serviceOrdersApi.addItem(id!, item), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['service-order-items', id] }); + queryClient.invalidateQueries({ queryKey: ['service-order', id] }); + setShowAddItemModal(false); + reset(); + setSelectedPart(null); + setPartSearch(''); + }, + }); + + // Remove item mutation + const removeItemMutation = useMutation({ + mutationFn: (itemId: string) => serviceOrdersApi.removeItem(id!, itemId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['service-order-items', id] }); + queryClient.invalidateQueries({ queryKey: ['service-order', id] }); + }, + }); + const order: ServiceOrder | undefined = orderData?.data; const items: ServiceOrderItem[] = itemsData?.data || []; + const searchParts: Part[] = partsData?.data || []; + + const onAddItem = (data: AddItemForm) => { + addItemMutation.mutate({ + item_type: data.itemType, + part_id: data.partId || null, + description: data.description, + quantity: data.quantity, + unit_price: data.unitPrice, + actual_hours: data.actualHours || null, + discount: 0, + subtotal: data.quantity * data.unitPrice, + }); + }; + + const handleSelectPart = (part: Part) => { + setSelectedPart(part); + setValue('partId', part.id); + setValue('description', part.name); + setValue('unitPrice', part.price); + setPartSearch(part.name); + }; if (isLoading) { return ( @@ -128,6 +230,9 @@ export function ServiceOrderDetailPage() { const laborItems = items.filter(i => i.item_type === 'service'); const partItems = items.filter(i => i.item_type === 'part'); + // Calculate current status index for timeline + const currentStatusIndex = STATUS_FLOW.indexOf(order.status as ServiceOrderStatus); + return (
{/* Header */} @@ -181,6 +286,54 @@ export function ServiceOrderDetailPage() { )}
+ {/* Status Timeline */} +
+

Progreso de la Orden

+
+ {STATUS_FLOW.map((status, index) => { + const isCompleted = index < currentStatusIndex; + const isCurrent = index === currentStatusIndex; + const config = STATUS_CONFIG[status]; + + return ( +
+
+
+ {isCompleted ? ( + + ) : ( + {index + 1} + )} +
+ + {config.label} + +
+ {index < STATUS_FLOW.length - 1 && ( +
+ )} +
+ ); + })} +
+
+
{/* Main Content */}
@@ -225,9 +378,12 @@ export function ServiceOrderDetailPage() { Trabajos y Refacciones -
@@ -245,15 +401,27 @@ export function ServiceOrderDetailPage() {
{laborItems.map((item) => (
-
+

{item.description}

{item.actual_hours && (

{item.actual_hours} hrs

)}
-

- ${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })} -

+
+

+ ${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })} +

+ +
))}
@@ -270,15 +438,27 @@ export function ServiceOrderDetailPage() {
{partItems.map((item) => (
-
+

{item.description}

{item.quantity} x ${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}

-

- ${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })} -

+
+

+ ${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })} +

+ +
))}
@@ -380,7 +560,7 @@ export function ServiceOrderDetailPage() {
- {/* Timeline placeholder */} + {/* Timeline/History */}

@@ -397,10 +577,207 @@ export function ServiceOrderDetailPage() { })}

+ {order.updated_at && order.updated_at !== order.created_at && ( +
+
+ Ultima actualizacion + + {new Date(order.updated_at).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + })} + +
+ )}
+ + {/* Add Item Modal */} + {showAddItemModal && ( +
+
+
+

Agregar Item

+ +
+ +
+ {/* Item Type */} +
+ +
+ + +
+
+ + {/* Part Search (only for parts) */} + {itemType === 'part' && ( +
+ +
+ + setPartSearch(e.target.value)} + placeholder="Buscar por nombre o SKU..." + className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none" + /> +
+ {searchParts.length > 0 && !selectedPart && ( +
+ {searchParts.map((part) => ( + + ))} +
+ )} +
+ )} + + {/* Description */} +
+ + + {errors.description && ( +

{errors.description.message}

+ )} +
+ + {/* Quantity and Price */} +
+
+ + + {errors.quantity && ( +

{errors.quantity.message}

+ )} +
+
+ + + {errors.unitPrice && ( +

{errors.unitPrice.message}

+ )} +
+
+ + {/* Hours (only for service) */} + {itemType === 'service' && ( +
+ + +
+ )} + + {/* Submit */} +
+ + +
+
+
+
+ )}
); } diff --git a/src/services/api/dashboard.ts b/src/services/api/dashboard.ts new file mode 100644 index 0000000..7431ebf --- /dev/null +++ b/src/services/api/dashboard.ts @@ -0,0 +1,97 @@ +import { api } from './client'; +import type { ApiResponse } from '../../types'; + +// Dashboard metrics types +export interface DashboardMetrics { + orders: OrderMetrics; + revenue: RevenueMetrics; + technicians: TechnicianMetrics; + inventory: InventoryMetrics; +} + +export interface OrderMetrics { + totalToday: number; + totalWeek: number; + totalMonth: number; + byStatus: OrderStatusCount[]; + urgent: number; + overdue: number; +} + +export interface OrderStatusCount { + status: string; + count: number; + label: string; +} + +export interface RevenueMetrics { + today: number; + week: number; + month: number; + averageTicket: number; + laborRevenue: number; + partsRevenue: number; +} + +export interface TechnicianMetrics { + total: number; + available: number; + busy: number; + utilization: number; +} + +export interface InventoryMetrics { + totalParts: number; + lowStock: number; + outOfStock: number; + totalValue: number; +} + +export interface RecentOrder { + id: string; + orderNumber: string; + customerName: string; + vehicleInfo: string; + status: string; + priority: string; + receivedAt: string; + grandTotal: number; +} + +export interface TechnicianAvailability { + id: string; + name: string; + status: 'available' | 'busy' | 'break' | 'off'; + currentOrder?: string; + completedToday: number; +} + +export const dashboardApi = { + // Get complete dashboard metrics + getMetrics: () => + api.get>('/dashboard/metrics'), + + // Get order stats for today/week/month + getOrderStats: (period: 'today' | 'week' | 'month' = 'today') => + api.get>(`/dashboard/orders?period=${period}`), + + // Get revenue stats + getRevenueStats: (period: 'today' | 'week' | 'month' = 'month') => + api.get>(`/dashboard/revenue?period=${period}`), + + // Get technician availability + getTechnicians: () => + api.get>('/dashboard/technicians'), + + // Get recent orders + getRecentOrders: (limit: number = 5) => + api.get>(`/dashboard/recent-orders?limit=${limit}`), + + // Get orders by status for chart + getOrdersByStatus: () => + api.get>('/dashboard/orders-by-status'), + + // Get inventory alerts + getInventoryAlerts: () => + api.get>('/dashboard/inventory-alerts'), +}; diff --git a/src/services/api/index.ts b/src/services/api/index.ts index cec3029..bb4e6ed 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -12,9 +12,19 @@ export { customersApi } from './customers'; export { quotesApi } from './quotes'; export { diagnosticsApi } from './diagnostics'; export { settingsApi } from './settings'; +export { dashboardApi } from './dashboard'; 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'; +export type { + DashboardMetrics, + OrderMetrics, + RevenueMetrics, + TechnicianMetrics, + InventoryMetrics, + TechnicianAvailability, + RecentOrder, +} from './dashboard';