erp-mecanicas-diesel-fronte.../src/pages/ServiceOrdersKanban.tsx
rckrdmrd abff318db4 Migración desde erp-mecanicas-diesel/frontend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:11:27 -06:00

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>
);
}