[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:
parent
85b9491361
commit
b3e2bf01aa
131
src/components/ui/DonutChart.tsx
Normal file
131
src/components/ui/DonutChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -22,3 +22,5 @@ export {
|
||||
SkeletonCard,
|
||||
} from './LoadingSpinner';
|
||||
export type { SpinnerSize, SpinnerVariant } from './LoadingSpinner';
|
||||
|
||||
export { DonutChart } from './DonutChart';
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
97
src/services/api/dashboard.ts
Normal file
97
src/services/api/dashboard.ts
Normal 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'),
|
||||
};
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user