[SPRINT-2] feat: Implement Dashboard MVP with KPI metrics

- Add DonutChart component for visual statistics
- Create dashboard.service.ts for API integration
- Update Dashboard page with real metrics display
- Enhance ServiceOrderDetail with timeline and items modal
- Export new components and services

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 07:05:16 -06:00
parent 85b9491361
commit b3e2bf01aa
6 changed files with 755 additions and 36 deletions

View File

@ -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 (
<div className="flex flex-col items-center gap-4">
<div className="relative" style={{ width: size, height: size }}>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="#f3f4f6"
strokeWidth={strokeWidth}
/>
{/* Data segments */}
{segments.map((segment, index) => (
<circle
key={index}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={segment.color}
strokeWidth={strokeWidth}
strokeDasharray={segment.strokeDasharray}
strokeDashoffset={segment.strokeDashoffset}
strokeLinecap="round"
className="transition-all duration-500"
/>
))}
</svg>
{/* Center text */}
{(centerLabel || centerValue !== undefined) && (
<div className="absolute inset-0 flex flex-col items-center justify-center">
{centerValue !== undefined && (
<span className="text-3xl font-bold text-gray-900">{centerValue}</span>
)}
{centerLabel && (
<span className="text-sm text-gray-500">{centerLabel}</span>
)}
</div>
)}
</div>
{/* Legend */}
{showLegend && (
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{segments.map((segment, index) => (
<div key={index} className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: segment.color }}
/>
<span className="text-sm text-gray-600">
{segment.label}
<span className="ml-1 font-medium text-gray-900">
({segment.value})
</span>
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -22,3 +22,5 @@ export {
SkeletonCard,
} from './LoadingSpinner';
export type { SpinnerSize, SpinnerVariant } from './LoadingSpinner';
export { DonutChart } from './DonutChart';

View File

@ -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<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' },
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string; chartColor: string }> = {
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<string, number>, 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 (
<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 className="flex items-center justify-between">
<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>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Calendar className="h-4 w-4" />
{new Date().toLocaleDateString('es-MX', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</div>
</div>
{/* Stats */}
@ -104,14 +160,14 @@ export function Dashboard() {
))}
</div>
{/* Revenue stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Revenue + Technicians stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<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" />
<DollarSign className="h-6 w-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-500">Ingresos Totales</p>
<p className="text-sm text-gray-500">Ingresos del Mes</p>
{statsLoading ? (
<div className="h-8 w-24 animate-pulse rounded bg-gray-200" />
) : (
@ -121,9 +177,10 @@ export function Dashboard() {
)}
</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" />
<FileText className="h-6 w-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-500">Ticket Promedio</p>
@ -136,12 +193,52 @@ export function Dashboard() {
)}
</div>
</div>
<div className="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
<div className="rounded-lg bg-cyan-500 p-3">
<Users className="h-6 w-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-500">Tecnicos Disponibles</p>
<p className="text-2xl font-bold text-gray-900">{totalTechnicians}</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
<div className={`rounded-lg ${urgentOrders > 0 || overdueOrders > 0 ? 'bg-red-500' : 'bg-gray-400'} p-3`}>
<AlertCircle className="h-6 w-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-500">Urgentes / Atrasadas</p>
<p className={`text-2xl font-bold ${urgentOrders > 0 || overdueOrders > 0 ? 'text-red-600' : 'text-gray-900'}`}>
{urgentOrders} / {overdueOrders}
</p>
</div>
</div>
</div>
{/* Content grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Recent orders */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Orders by status donut chart */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Ordenes por Estado</h2>
{donutData.length > 0 ? (
<DonutChart
data={donutData}
size={180}
strokeWidth={25}
centerValue={totalActiveOrders}
centerLabel="activas"
/>
) : (
<div className="flex h-48 items-center justify-center text-gray-500">
<p>Sin ordenes activas</p>
</div>
)}
</div>
{/* Recent orders */}
<div className="rounded-lg bg-white p-6 shadow-sm lg:col-span-2">
<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">
@ -190,7 +287,10 @@ export function Dashboard() {
</div>
)}
</div>
</div>
{/* Quick actions and alerts */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Quick actions */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
@ -258,11 +358,11 @@ export function Dashboard() {
</div>
{/* Today's schedule */}
<div className="rounded-lg bg-white p-6 shadow-sm lg:col-span-2">
<div className="rounded-lg bg-white p-6 shadow-sm">
<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="grid grid-cols-1 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" />
@ -275,6 +375,7 @@ export function Dashboard() {
)}
<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" />
@ -287,6 +388,7 @@ export function Dashboard() {
)}
<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" />

View File

@ -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<ServiceOrderStatus, { label: string; color: string; bgColor: string }> = {
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<ServiceOrderStatus, { next: ServiceOrderStatus; label: string; icon: typeof Play }[]> = {
received: [
@ -69,10 +87,43 @@ const STATUS_ACTIONS: Record<ServiceOrderStatus, { next: ServiceOrderStatus; lab
cancelled: [],
};
// Add item form schema
const addItemSchema = z.object({
itemType: z.enum(['service', 'part']),
partId: z.string().optional(),
description: z.string().min(1, 'Descripcion requerida'),
quantity: z.number().min(1, 'Cantidad minima 1'),
unitPrice: z.number().min(0, 'Precio requerido'),
actualHours: z.number().optional(),
});
type AddItemForm = z.infer<typeof addItemSchema>;
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<Part | null>(null);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<AddItemForm>({
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<ServiceOrderItem>) => 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 (
<div className="space-y-6">
{/* Header */}
@ -181,6 +286,54 @@ export function ServiceOrderDetailPage() {
)}
</div>
{/* Status Timeline */}
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Progreso de la Orden</h3>
<div className="flex items-center justify-between">
{STATUS_FLOW.map((status, index) => {
const isCompleted = index < currentStatusIndex;
const isCurrent = index === currentStatusIndex;
const config = STATUS_CONFIG[status];
return (
<div key={status} className="flex flex-1 items-center">
<div className="flex flex-col items-center">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 ${
isCompleted
? 'border-green-500 bg-green-500 text-white'
: isCurrent
? 'border-diesel-500 bg-diesel-500 text-white'
: 'border-gray-300 bg-white text-gray-400'
}`}
>
{isCompleted ? (
<CheckCircle2 className="h-5 w-5" />
) : (
<span className="text-sm font-medium">{index + 1}</span>
)}
</div>
<span
className={`mt-2 text-xs font-medium ${
isCompleted || isCurrent ? 'text-gray-900' : 'text-gray-400'
}`}
>
{config.label}
</span>
</div>
{index < STATUS_FLOW.length - 1 && (
<div
className={`h-1 flex-1 mx-2 ${
index < currentStatusIndex ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
)}
</div>
);
})}
</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">
@ -225,9 +378,12 @@ export function ServiceOrderDetailPage() {
<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
onClick={() => setShowAddItemModal(true)}
className="flex items-center gap-1 text-sm text-diesel-600 hover:text-diesel-700"
>
<Plus className="h-4 w-4" />
Agregar Item
</button>
</div>
@ -245,15 +401,27 @@ export function ServiceOrderDetailPage() {
<div className="divide-y divide-gray-100">
{laborItems.map((item) => (
<div key={item.id} className="flex items-center justify-between py-3">
<div>
<div className="flex-1">
<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 className="flex items-center gap-4">
<p className="font-medium text-gray-900">
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
<button
onClick={() => {
if (confirm('Eliminar este item?')) {
removeItemMutation.mutate(item.id);
}
}}
className="text-gray-400 hover:text-red-500"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
@ -270,15 +438,27 @@ export function ServiceOrderDetailPage() {
<div className="divide-y divide-gray-100">
{partItems.map((item) => (
<div key={item.id} className="flex items-center justify-between py-3">
<div>
<div className="flex-1">
<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 className="flex items-center gap-4">
<p className="font-medium text-gray-900">
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
<button
onClick={() => {
if (confirm('Eliminar este item?')) {
removeItemMutation.mutate(item.id);
}
}}
className="text-gray-400 hover:text-red-500"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
@ -380,7 +560,7 @@ export function ServiceOrderDetailPage() {
</div>
</div>
{/* Timeline placeholder */}
{/* Timeline/History */}
<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" />
@ -397,10 +577,207 @@ export function ServiceOrderDetailPage() {
})}
</span>
</div>
{order.updated_at && order.updated_at !== order.created_at && (
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<span>Ultima actualizacion</span>
<span className="ml-auto">
{new Date(order.updated_at).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Add Item Modal */}
{showAddItemModal && (
<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">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Agregar Item</h2>
<button
onClick={() => {
setShowAddItemModal(false);
reset();
setSelectedPart(null);
setPartSearch('');
}}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit(onAddItem)} className="space-y-4">
{/* Item Type */}
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">Tipo de Item</label>
<div className="flex gap-2">
<label
className={`flex-1 cursor-pointer rounded-lg border p-3 text-center ${
itemType === 'service'
? 'border-diesel-500 bg-diesel-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
{...register('itemType')}
value="service"
className="sr-only"
/>
<Wrench className="mx-auto h-5 w-5 mb-1" />
<span className="text-sm">Servicio</span>
</label>
<label
className={`flex-1 cursor-pointer rounded-lg border p-3 text-center ${
itemType === 'part'
? 'border-diesel-500 bg-diesel-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
{...register('itemType')}
value="part"
className="sr-only"
/>
<Package className="mx-auto h-5 w-5 mb-1" />
<span className="text-sm">Refaccion</span>
</label>
</div>
</div>
{/* Part Search (only for parts) */}
{itemType === 'part' && (
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Buscar Refaccion
</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"
value={partSearch}
onChange={(e) => 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"
/>
</div>
{searchParts.length > 0 && !selectedPart && (
<div className="mt-1 max-h-40 overflow-y-auto rounded-lg border border-gray-200 bg-white">
{searchParts.map((part) => (
<button
key={part.id}
type="button"
onClick={() => handleSelectPart(part)}
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-gray-50"
>
<div>
<p className="text-sm font-medium text-gray-900">{part.name}</p>
<p className="text-xs text-gray-500">SKU: {part.sku}</p>
</div>
<p className="text-sm font-medium text-gray-900">
${part.price.toLocaleString('es-MX')}
</p>
</button>
))}
</div>
)}
</div>
)}
{/* Description */}
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">Descripcion</label>
<input
{...register('description')}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
placeholder={itemType === 'service' ? 'Ej: Cambio de aceite' : 'Nombre de la refaccion'}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description.message}</p>
)}
</div>
{/* Quantity and Price */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">Cantidad</label>
<input
type="number"
{...register('quantity', { valueAsNumber: true })}
min="1"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
/>
{errors.quantity && (
<p className="mt-1 text-sm text-red-600">{errors.quantity.message}</p>
)}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Precio Unitario
</label>
<input
type="number"
step="0.01"
{...register('unitPrice', { 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.unitPrice && (
<p className="mt-1 text-sm text-red-600">{errors.unitPrice.message}</p>
)}
</div>
</div>
{/* Hours (only for service) */}
{itemType === 'service' && (
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Horas Estimadas
</label>
<input
type="number"
step="0.5"
{...register('actualHours', { valueAsNumber: true })}
placeholder="Ej: 2.5"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
/>
</div>
)}
{/* Submit */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowAddItemModal(false);
reset();
setSelectedPart(null);
setPartSearch('');
}}
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={addItemMutation.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"
>
{addItemMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Agregar
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -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<ApiResponse<DashboardMetrics>>('/dashboard/metrics'),
// Get order stats for today/week/month
getOrderStats: (period: 'today' | 'week' | 'month' = 'today') =>
api.get<ApiResponse<OrderMetrics>>(`/dashboard/orders?period=${period}`),
// Get revenue stats
getRevenueStats: (period: 'today' | 'week' | 'month' = 'month') =>
api.get<ApiResponse<RevenueMetrics>>(`/dashboard/revenue?period=${period}`),
// Get technician availability
getTechnicians: () =>
api.get<ApiResponse<TechnicianAvailability[]>>('/dashboard/technicians'),
// Get recent orders
getRecentOrders: (limit: number = 5) =>
api.get<ApiResponse<RecentOrder[]>>(`/dashboard/recent-orders?limit=${limit}`),
// Get orders by status for chart
getOrdersByStatus: () =>
api.get<ApiResponse<OrderStatusCount[]>>('/dashboard/orders-by-status'),
// Get inventory alerts
getInventoryAlerts: () =>
api.get<ApiResponse<InventoryMetrics>>('/dashboard/inventory-alerts'),
};

View File

@ -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';