Allow users to reorder planned stops using drag-and-drop

Integrate @dnd-kit for implementing drag-and-drop functionality to reorder stops within the mobile planning view.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
This commit is contained in:
marco370 2025-10-25 08:52:36 +00:00
parent b132082ffc
commit efa056dd98
4 changed files with 168 additions and 23 deletions

View File

@ -19,6 +19,10 @@ externalPort = 80
localPort = 33035 localPort = 33035
externalPort = 3001 externalPort = 3001
[[ports]]
localPort = 35023
externalPort = 6000
[[ports]] [[ports]]
localPort = 41295 localPort = 41295
externalPort = 5173 externalPort = 5173

View File

@ -6,8 +6,25 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy } from "lucide-react"; import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy, GripVertical } from "lucide-react";
import { format, parseISO, isValid, addDays } from "date-fns"; import { format, parseISO, isValid, addDays } from "date-fns";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -61,6 +78,50 @@ type MobileSite = {
longitude: string | null; longitude: string | null;
}; };
// Componente per tappa draggable
function SortableStop({ site, index, onRemove }: { site: MobileSite; index: number; onRemove: () => void }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: site.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20 cursor-move"
data-testid={`route-stop-${index}`}
>
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
<Badge className="bg-green-600">
{index + 1}
</Badge>
<span className="text-sm font-medium flex-1">{site.name}</span>
<Button
size="sm"
variant="ghost"
onClick={onRemove}
className="h-6 w-6 p-0"
data-testid={`button-remove-stop-${index}`}
>
</Button>
</div>
);
}
type AvailableGuard = { type AvailableGuard = {
id: string; id: string;
firstName: string; firstName: string;
@ -111,6 +172,27 @@ export default function PlanningMobile() {
// Ref per scroll alla sezione sequenze pattuglia // Ref per scroll alla sezione sequenze pattuglia
const patrolSequencesRef = useRef<HTMLDivElement>(null); const patrolSequencesRef = useRef<HTMLDivElement>(null);
// Sensors per drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Handler per riordinare le tappe con drag-and-drop
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setPatrolRoute((items) => {
const oldIndex = items.findIndex(item => item.id === active.id);
const newIndex = items.findIndex(item => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
// Query siti mobile per location // Query siti mobile per location
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({ const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
queryKey: ["/api/planning-mobile/sites", selectedLocation], queryKey: ["/api/planning-mobile/sites", selectedLocation],
@ -562,32 +644,32 @@ export default function PlanningMobile() {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })} {patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
<br />
<span className="text-xs">💡 Trascina le tappe per riordinarle</span>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex gap-2 flex-wrap"> <DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={patrolRoute.map(site => site.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{patrolRoute.map((site, index) => ( {patrolRoute.map((site, index) => (
<div <SortableStop
key={site.id} key={site.id}
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20" site={site}
data-testid={`route-stop-${index}`} index={index}
> onRemove={() => handleRemoveFromRoute(site.id)}
<Badge className="bg-green-600"> />
{index + 1}
</Badge>
<span className="text-sm font-medium">{site.name}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveFromRoute(site.id)}
className="h-6 w-6 p-0"
data-testid={`button-remove-stop-${index}`}
>
</Button>
</div>
))} ))}
</div> </div>
</SortableContext>
</DndContext>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button <Button
onClick={handleSavePatrolRoute} onClick={handleSavePatrolRoute}

56
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",
@ -417,6 +420,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": { "node_modules/@drizzle-team/brocli": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",

View File

@ -11,6 +11,9 @@
"db:push": "drizzle-kit push" "db:push": "drizzle-kit push"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",