[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,
|
SkeletonCard,
|
||||||
} from './LoadingSpinner';
|
} from './LoadingSpinner';
|
||||||
export type { SpinnerSize, SpinnerVariant } from './LoadingSpinner';
|
export type { SpinnerSize, SpinnerVariant } from './LoadingSpinner';
|
||||||
|
|
||||||
|
export { DonutChart } from './DonutChart';
|
||||||
|
|||||||
@ -10,23 +10,28 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Users,
|
Users,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
AlertCircle,
|
||||||
|
Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||||
import { partsApi } from '../services/api/parts';
|
import { partsApi } from '../services/api/parts';
|
||||||
import { customersApi } from '../services/api/customers';
|
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 { ServiceOrder, DashboardStats } from '../services/api/serviceOrders';
|
||||||
import type { ServiceOrderStatus, Part } from '../types';
|
import type { ServiceOrderStatus, Part } from '../types';
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string }> = {
|
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string; chartColor: string }> = {
|
||||||
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800' },
|
received: { label: 'Recibido', color: 'bg-blue-100 text-blue-800', chartColor: '#3b82f6' },
|
||||||
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800' },
|
diagnosing: { label: 'Diagnosticando', color: 'bg-purple-100 text-purple-800', chartColor: '#8b5cf6' },
|
||||||
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800' },
|
quoted: { label: 'Cotizado', color: 'bg-yellow-100 text-yellow-800', chartColor: '#eab308' },
|
||||||
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800' },
|
approved: { label: 'Aprobado', color: 'bg-green-100 text-green-800', chartColor: '#22c55e' },
|
||||||
in_repair: { label: 'En Reparacion', color: 'bg-orange-100 text-orange-800' },
|
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' },
|
waiting_parts: { label: 'Esperando Partes', color: 'bg-red-100 text-red-800', chartColor: '#ef4444' },
|
||||||
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800' },
|
ready: { label: 'Listo', color: 'bg-emerald-100 text-emerald-800', chartColor: '#10b981' },
|
||||||
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800' },
|
delivered: { label: 'Entregado', color: 'bg-gray-100 text-gray-800', chartColor: '#6b7280' },
|
||||||
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500' },
|
cancelled: { label: 'Cancelado', color: 'bg-gray-100 text-gray-500', chartColor: '#9ca3af' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
@ -42,6 +47,12 @@ export function Dashboard() {
|
|||||||
queryFn: () => serviceOrdersApi.list({ page: 1, pageSize: 5 }),
|
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
|
// Fetch low stock parts
|
||||||
const { data: lowStockData } = useQuery({
|
const { data: lowStockData } = useQuery({
|
||||||
queryKey: ['low-stock-parts'],
|
queryKey: ['low-stock-parts'],
|
||||||
@ -54,6 +65,12 @@ export function Dashboard() {
|
|||||||
queryFn: () => customersApi.list({ pageSize: 1 }),
|
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 || {
|
const stats: DashboardStats = statsData?.data || {
|
||||||
totalOrders: 0,
|
totalOrders: 0,
|
||||||
pendingOrders: 0,
|
pendingOrders: 0,
|
||||||
@ -64,8 +81,36 @@ export function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const recentOrders = ordersData?.data?.data || [];
|
const recentOrders = ordersData?.data?.data || [];
|
||||||
|
const allOrders = allOrdersData?.data?.data || [];
|
||||||
const lowStockParts: Part[] = lowStockData?.data || [];
|
const lowStockParts: Part[] = lowStockData?.data || [];
|
||||||
const totalCustomers = customersData?.data?.total || 0;
|
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 = [
|
const statCards = [
|
||||||
{ name: 'Ordenes Activas', value: stats.pendingOrders + stats.inProgressOrders, icon: Wrench, color: 'bg-blue-500' },
|
{ name: 'Ordenes Activas', value: stats.pendingOrders + stats.inProgressOrders, icon: Wrench, color: 'bg-blue-500' },
|
||||||
@ -77,9 +122,20 @@ export function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Resumen de operaciones del taller</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@ -104,14 +160,14 @@ export function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue stats */}
|
{/* Revenue + Technicians stats */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<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="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
|
||||||
<div className="rounded-lg bg-emerald-500 p-3">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Ingresos Totales</p>
|
<p className="text-sm text-gray-500">Ingresos del Mes</p>
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
<div className="h-8 w-24 animate-pulse rounded bg-gray-200" />
|
<div className="h-8 w-24 animate-pulse rounded bg-gray-200" />
|
||||||
) : (
|
) : (
|
||||||
@ -121,9 +177,10 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
|
<div className="flex items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
|
||||||
<div className="rounded-lg bg-indigo-500 p-3">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Ticket Promedio</p>
|
<p className="text-sm text-gray-500">Ticket Promedio</p>
|
||||||
@ -136,12 +193,52 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Content grid */}
|
{/* Content grid */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Recent orders */}
|
{/* Orders by status donut chart */}
|
||||||
<div className="rounded-lg bg-white p-6 shadow-sm">
|
<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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Ordenes Recientes</h2>
|
<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">
|
<Link to="/orders" className="text-sm text-diesel-600 hover:text-diesel-700">
|
||||||
@ -190,7 +287,10 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions and alerts */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<div className="rounded-lg bg-white p-6 shadow-sm">
|
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
@ -258,11 +358,11 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Today's schedule */}
|
{/* 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">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Resumen del Dia
|
Resumen del Dia
|
||||||
</h2>
|
</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="rounded-lg border border-gray-200 p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Clock className="h-5 w-5 text-yellow-500" />
|
<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>
|
<p className="text-sm text-gray-500">ordenes por iniciar</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-200 p-4">
|
<div className="rounded-lg border border-gray-200 p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Wrench className="h-5 w-5 text-blue-500" />
|
<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>
|
<p className="text-sm text-gray-500">ordenes en reparacion</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-200 p-4">
|
<div className="rounded-lg border border-gray-200 p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<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 { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Truck,
|
Truck,
|
||||||
@ -13,13 +17,16 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
FileText,
|
FileText,
|
||||||
Edit2,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
||||||
|
import { partsApi } from '../services/api/parts';
|
||||||
import type { ServiceOrder, ServiceOrderItem } from '../services/api/serviceOrders';
|
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 }> = {
|
const STATUS_CONFIG: Record<ServiceOrderStatus, { label: string; color: string; bgColor: string }> = {
|
||||||
received: { label: 'Recibido', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
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' },
|
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
|
// Status transitions
|
||||||
const STATUS_ACTIONS: Record<ServiceOrderStatus, { next: ServiceOrderStatus; label: string; icon: typeof Play }[]> = {
|
const STATUS_ACTIONS: Record<ServiceOrderStatus, { next: ServiceOrderStatus; label: string; icon: typeof Play }[]> = {
|
||||||
received: [
|
received: [
|
||||||
@ -69,10 +87,43 @@ const STATUS_ACTIONS: Record<ServiceOrderStatus, { next: ServiceOrderStatus; lab
|
|||||||
cancelled: [],
|
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() {
|
export function ServiceOrderDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
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
|
// Fetch order details
|
||||||
const { data: orderData, isLoading, error } = useQuery({
|
const { data: orderData, isLoading, error } = useQuery({
|
||||||
@ -88,6 +139,13 @@ export function ServiceOrderDetailPage() {
|
|||||||
enabled: !!id,
|
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
|
// Status change mutation
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
mutationFn: ({ status, notes }: { status: ServiceOrderStatus; notes?: string }) =>
|
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 order: ServiceOrder | undefined = orderData?.data;
|
||||||
const items: ServiceOrderItem[] = itemsData?.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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@ -128,6 +230,9 @@ export function ServiceOrderDetailPage() {
|
|||||||
const laborItems = items.filter(i => i.item_type === 'service');
|
const laborItems = items.filter(i => i.item_type === 'service');
|
||||||
const partItems = items.filter(i => i.item_type === 'part');
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -181,6 +286,54 @@ export function ServiceOrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<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" />
|
<Package className="h-5 w-5 text-diesel-600" />
|
||||||
Trabajos y Refacciones
|
Trabajos y Refacciones
|
||||||
</h3>
|
</h3>
|
||||||
<button className="flex items-center gap-1 text-sm text-diesel-600 hover:text-diesel-700">
|
<button
|
||||||
<Edit2 className="h-4 w-4" />
|
onClick={() => setShowAddItemModal(true)}
|
||||||
Editar
|
className="flex items-center gap-1 text-sm text-diesel-600 hover:text-diesel-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Agregar Item
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -245,15 +401,27 @@ export function ServiceOrderDetailPage() {
|
|||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{laborItems.map((item) => (
|
{laborItems.map((item) => (
|
||||||
<div key={item.id} className="flex items-center justify-between py-3">
|
<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="font-medium text-gray-900">{item.description}</p>
|
||||||
{item.actual_hours && (
|
{item.actual_hours && (
|
||||||
<p className="text-sm text-gray-500">{item.actual_hours} hrs</p>
|
<p className="text-sm text-gray-500">{item.actual_hours} hrs</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-gray-900">
|
<div className="flex items-center gap-4">
|
||||||
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
<p className="font-medium text-gray-900">
|
||||||
</p>
|
${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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -270,15 +438,27 @@ export function ServiceOrderDetailPage() {
|
|||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{partItems.map((item) => (
|
{partItems.map((item) => (
|
||||||
<div key={item.id} className="flex items-center justify-between py-3">
|
<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="font-medium text-gray-900">{item.description}</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{item.quantity} x ${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
{item.quantity} x ${item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-gray-900">
|
<div className="flex items-center gap-4">
|
||||||
${item.subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
<p className="font-medium text-gray-900">
|
||||||
</p>
|
${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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -380,7 +560,7 @@ export function ServiceOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline placeholder */}
|
{/* Timeline/History */}
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-6">
|
<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">
|
<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" />
|
<Clock className="h-5 w-5 text-diesel-600" />
|
||||||
@ -397,10 +577,207 @@ export function ServiceOrderDetailPage() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</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 { quotesApi } from './quotes';
|
||||||
export { diagnosticsApi } from './diagnostics';
|
export { diagnosticsApi } from './diagnostics';
|
||||||
export { settingsApi } from './settings';
|
export { settingsApi } from './settings';
|
||||||
|
export { dashboardApi } from './dashboard';
|
||||||
export { api } from './client';
|
export { api } from './client';
|
||||||
export type { LoginRequest, LoginResponse, RegisterRequest } from './auth';
|
export type { LoginRequest, LoginResponse, RegisterRequest } from './auth';
|
||||||
export type { CreateUserRequest, UpdateUserRequest, ResetPasswordRequest } from './users';
|
export type { CreateUserRequest, UpdateUserRequest, ResetPasswordRequest } from './users';
|
||||||
export type { CreateVehicleRequest, UpdateVehicleRequest } from './vehicles';
|
export type { CreateVehicleRequest, UpdateVehicleRequest } from './vehicles';
|
||||||
export type { CreatePartRequest, UpdatePartRequest } from './parts';
|
export type { CreatePartRequest, UpdatePartRequest } from './parts';
|
||||||
export type { Customer, CreateCustomerRequest, UpdateCustomerRequest } from './customers';
|
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