321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
Plus,
|
|
Loader2,
|
|
AlertCircle,
|
|
Truck,
|
|
Clock,
|
|
User,
|
|
List,
|
|
ChevronRight,
|
|
AlertTriangle,
|
|
} from 'lucide-react';
|
|
import { serviceOrdersApi } from '../services/api/serviceOrders';
|
|
import type { ServiceOrder } from '../services/api/serviceOrders';
|
|
import type { ServiceOrderStatus } from '../types';
|
|
|
|
// Kanban columns configuration
|
|
const KANBAN_COLUMNS: { status: ServiceOrderStatus; label: string; color: string; bgColor: string }[] = [
|
|
{ status: 'received', label: 'Recibidos', color: 'border-blue-500', bgColor: 'bg-blue-50' },
|
|
{ status: 'diagnosing', label: 'Diagnosticando', color: 'border-purple-500', bgColor: 'bg-purple-50' },
|
|
{ status: 'quoted', label: 'Cotizados', color: 'border-yellow-500', bgColor: 'bg-yellow-50' },
|
|
{ status: 'approved', label: 'Aprobados', color: 'border-green-500', bgColor: 'bg-green-50' },
|
|
{ status: 'in_repair', label: 'En Reparacion', color: 'border-orange-500', bgColor: 'bg-orange-50' },
|
|
{ status: 'waiting_parts', label: 'Esperando Partes', color: 'border-red-500', bgColor: 'bg-red-50' },
|
|
{ status: 'ready', label: 'Listos', color: 'border-emerald-500', bgColor: 'bg-emerald-50' },
|
|
];
|
|
|
|
const PRIORITY_CONFIG = {
|
|
low: { label: 'Baja', color: 'text-gray-500', dot: 'bg-gray-400' },
|
|
medium: { label: 'Media', color: 'text-blue-500', dot: 'bg-blue-400' },
|
|
high: { label: 'Alta', color: 'text-orange-500', dot: 'bg-orange-400' },
|
|
urgent: { label: 'Urgente', color: 'text-red-500', dot: 'bg-red-400' },
|
|
};
|
|
|
|
interface KanbanCardProps {
|
|
order: ServiceOrder;
|
|
onMoveToNext: (orderId: string, currentStatus: ServiceOrderStatus) => void;
|
|
isMoving: boolean;
|
|
}
|
|
|
|
function KanbanCard({ order, onMoveToNext, isMoving }: KanbanCardProps) {
|
|
const priorityConfig = PRIORITY_CONFIG[order.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG.medium;
|
|
const isUrgent = order.priority === 'urgent' || order.priority === 'high';
|
|
|
|
return (
|
|
<div
|
|
className={`group rounded-lg border bg-white p-3 shadow-sm transition-all hover:shadow-md ${
|
|
isUrgent ? 'border-l-4 border-l-red-500' : 'border-gray-200'
|
|
}`}
|
|
>
|
|
<div className="mb-2 flex items-start justify-between">
|
|
<Link
|
|
to={`/orders/${order.id}`}
|
|
className="font-medium text-gray-900 hover:text-diesel-600"
|
|
>
|
|
{order.order_number}
|
|
</Link>
|
|
<span className={`flex items-center gap-1 text-xs ${priorityConfig.color}`}>
|
|
<span className={`h-2 w-2 rounded-full ${priorityConfig.dot}`} />
|
|
{priorityConfig.label}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600">
|
|
<Truck className="h-4 w-4" />
|
|
<span className="truncate">{order.vehicle_info}</span>
|
|
</div>
|
|
|
|
<div className="mb-2 flex items-center gap-2 text-sm text-gray-500">
|
|
<User className="h-4 w-4" />
|
|
<span className="truncate">{order.customer_name}</span>
|
|
</div>
|
|
|
|
{order.promised_at && (
|
|
<div className="mb-2 flex items-center gap-2 text-xs text-gray-400">
|
|
<Clock className="h-3 w-3" />
|
|
<span>
|
|
Prometido: {new Date(order.promised_at).toLocaleDateString('es-MX', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
})}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{order.symptoms && (
|
|
<p className="mb-2 text-xs text-gray-500 line-clamp-2">{order.symptoms}</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
|
<span className="text-xs text-gray-400">
|
|
{new Date(order.received_at).toLocaleDateString('es-MX', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
})}
|
|
</span>
|
|
<button
|
|
onClick={() => onMoveToNext(order.id, order.status as ServiceOrderStatus)}
|
|
disabled={isMoving}
|
|
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-diesel-600 opacity-0 transition-opacity hover:bg-diesel-50 group-hover:opacity-100 disabled:opacity-50"
|
|
>
|
|
{isMoving ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<>
|
|
Avanzar
|
|
<ChevronRight className="h-3 w-3" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface KanbanColumnProps {
|
|
label: string;
|
|
color: string;
|
|
bgColor: string;
|
|
orders: ServiceOrder[];
|
|
onMoveToNext: (orderId: string, currentStatus: ServiceOrderStatus) => void;
|
|
movingOrderId: string | null;
|
|
}
|
|
|
|
function KanbanColumn({ label, color, bgColor, orders, onMoveToNext, movingOrderId }: KanbanColumnProps) {
|
|
const urgentCount = orders.filter(o => o.priority === 'urgent' || o.priority === 'high').length;
|
|
|
|
return (
|
|
<div className="flex w-72 flex-shrink-0 flex-col rounded-lg border border-gray-200 bg-gray-50">
|
|
<div className={`flex items-center justify-between border-b-2 ${color} p-3 ${bgColor}`}>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-gray-900">{label}</h3>
|
|
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-600">
|
|
{orders.length}
|
|
</span>
|
|
</div>
|
|
{urgentCount > 0 && (
|
|
<span className="flex items-center gap-1 text-xs text-red-600">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
{urgentCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col gap-2 overflow-y-auto p-2" style={{ maxHeight: 'calc(100vh - 280px)' }}>
|
|
{orders.length === 0 ? (
|
|
<div className="flex h-24 items-center justify-center text-sm text-gray-400">
|
|
Sin ordenes
|
|
</div>
|
|
) : (
|
|
orders.map((order) => (
|
|
<KanbanCard
|
|
key={order.id}
|
|
order={order}
|
|
onMoveToNext={onMoveToNext}
|
|
isMoving={movingOrderId === order.id}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Status transitions for quick move
|
|
const STATUS_NEXT: Record<ServiceOrderStatus, ServiceOrderStatus | null> = {
|
|
received: 'diagnosing',
|
|
diagnosing: 'quoted',
|
|
quoted: 'approved',
|
|
approved: 'in_repair',
|
|
in_repair: 'ready',
|
|
waiting_parts: 'in_repair',
|
|
ready: 'delivered',
|
|
delivered: null,
|
|
cancelled: null,
|
|
};
|
|
|
|
export function ServiceOrdersKanbanPage() {
|
|
const queryClient = useQueryClient();
|
|
const [movingOrderId, setMovingOrderId] = useState<string | null>(null);
|
|
|
|
// Fetch all orders for kanban (exclude delivered and cancelled)
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['service-orders-kanban'],
|
|
queryFn: () => serviceOrdersApi.list({ pageSize: 200 }), // Get all active orders
|
|
});
|
|
|
|
const allOrders: ServiceOrder[] = data?.data?.data || [];
|
|
|
|
// Filter out delivered and cancelled
|
|
const activeOrders = allOrders.filter(
|
|
o => o.status !== 'delivered' && o.status !== 'cancelled'
|
|
);
|
|
|
|
// Group orders by status
|
|
const ordersByStatus = KANBAN_COLUMNS.reduce((acc, col) => {
|
|
acc[col.status] = activeOrders
|
|
.filter(o => o.status === col.status)
|
|
.sort((a, b) => {
|
|
// Sort by priority (urgent first) then by date
|
|
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
|
|
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 2;
|
|
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 2;
|
|
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
return new Date(a.received_at).getTime() - new Date(b.received_at).getTime();
|
|
});
|
|
return acc;
|
|
}, {} as Record<ServiceOrderStatus, ServiceOrder[]>);
|
|
|
|
// Status change mutation
|
|
const statusMutation = useMutation({
|
|
mutationFn: ({ orderId, status }: { orderId: string; status: ServiceOrderStatus }) =>
|
|
serviceOrdersApi.changeStatus(orderId, status),
|
|
onMutate: ({ orderId }) => {
|
|
setMovingOrderId(orderId);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['service-orders-kanban'] });
|
|
queryClient.invalidateQueries({ queryKey: ['service-orders'] });
|
|
},
|
|
onSettled: () => {
|
|
setMovingOrderId(null);
|
|
},
|
|
});
|
|
|
|
const handleMoveToNext = (orderId: string, currentStatus: ServiceOrderStatus) => {
|
|
const nextStatus = STATUS_NEXT[currentStatus];
|
|
if (nextStatus) {
|
|
statusMutation.mutate({ orderId, status: nextStatus });
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
|
<AlertCircle className="mb-2 h-8 w-8" />
|
|
<p>Error al cargar ordenes</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Tablero de Ordenes</h1>
|
|
<p className="text-sm text-gray-500">
|
|
{activeOrders.length} ordenes activas
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
to="/orders"
|
|
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
>
|
|
<List className="h-4 w-4" />
|
|
Vista Lista
|
|
</Link>
|
|
<Link
|
|
to="/orders/new"
|
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Nueva Orden
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Summary */}
|
|
<div className="grid grid-cols-4 gap-4 lg:grid-cols-7">
|
|
{KANBAN_COLUMNS.map((col) => {
|
|
const count = ordersByStatus[col.status]?.length || 0;
|
|
const urgentCount = ordersByStatus[col.status]?.filter(
|
|
o => o.priority === 'urgent' || o.priority === 'high'
|
|
).length || 0;
|
|
return (
|
|
<div
|
|
key={col.status}
|
|
className={`rounded-lg border-l-4 ${col.color} bg-white p-3 shadow-sm`}
|
|
>
|
|
<p className="text-xs font-medium text-gray-500">{col.label}</p>
|
|
<div className="flex items-baseline gap-2">
|
|
<p className="text-2xl font-bold text-gray-900">{count}</p>
|
|
{urgentCount > 0 && (
|
|
<span className="text-xs text-red-500">({urgentCount} urgentes)</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Kanban Board */}
|
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
{KANBAN_COLUMNS.map((col) => (
|
|
<KanbanColumn
|
|
key={col.status}
|
|
label={col.label}
|
|
color={col.color}
|
|
bgColor={col.bgColor}
|
|
orders={ordersByStatus[col.status] || []}
|
|
onMoveToNext={handleMoveToNext}
|
|
movingOrderId={movingOrderId}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|