feat: add Kanban-based pipeline board and client management UI for CRM module
This commit is contained in:
@@ -44,19 +44,6 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { format, isPast, isToday } from "date-fns";
|
import { format, isPast, isToday } from "date-fns";
|
||||||
import { tr } from "date-fns/locale";
|
import { tr } from "date-fns/locale";
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
DragOverlay,
|
|
||||||
DragStartEvent,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
closestCorners,
|
|
||||||
useDroppable,
|
|
||||||
useDraggable,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -117,35 +104,24 @@ export function ClientsClient({
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
const [activeDragClient, setActiveDragClient] = useState<ClientListItem | null>(null);
|
const [draggedClientId, setDraggedClientId] = useState<string | null>(null);
|
||||||
const [localClients, setLocalClients] = useState(clients);
|
const [localClients, setLocalClients] = useState(clients);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalClients(clients);
|
setLocalClients(clients);
|
||||||
}, [clients]);
|
}, [clients]);
|
||||||
|
|
||||||
const sensors = useSensors(
|
function handleDragStart(event: React.DragEvent<HTMLDivElement>, clientId: string) {
|
||||||
useSensor(PointerSensor, {
|
setDraggedClientId(clientId);
|
||||||
activationConstraint: {
|
event.dataTransfer.effectAllowed = "move";
|
||||||
distance: 5,
|
event.dataTransfer.setData("text/plain", clientId);
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
|
||||||
const { active } = event;
|
|
||||||
const client = localClients.find(c => c.id === active.id);
|
|
||||||
if (client) setActiveDragClient(client);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDragEnd(event: DragEndEvent) {
|
async function handleDrop(newStage: string) {
|
||||||
const { active, over } = event;
|
if (!draggedClientId) return;
|
||||||
setActiveDragClient(null);
|
|
||||||
|
|
||||||
if (!over) return;
|
const clientId = draggedClientId;
|
||||||
|
setDraggedClientId(null);
|
||||||
const clientId = active.id as string;
|
|
||||||
const newStage = over.id as string;
|
|
||||||
|
|
||||||
const client = localClients.find(c => c.id === clientId);
|
const client = localClients.find(c => c.id === clientId);
|
||||||
if (!client || client.pipeline_stage === newStage) return;
|
if (!client || client.pipeline_stage === newStage) return;
|
||||||
@@ -155,7 +131,7 @@ export function ClientsClient({
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateClientPipelineStage(clientId, newStage);
|
await updateClientPipelineStage(clientId, newStage as any);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLocalClients(clients);
|
setLocalClients(clients);
|
||||||
}
|
}
|
||||||
@@ -241,41 +217,35 @@ export function ClientsClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="pipeline" className="mt-0">
|
<TabsContent value="pipeline" className="mt-0">
|
||||||
<DndContext
|
<div className="flex gap-4 overflow-x-auto pb-4 snap-x">
|
||||||
sensors={sensors}
|
{pipelineStages.map(stage => {
|
||||||
collisionDetection={closestCorners}
|
const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived');
|
||||||
onDragStart={handleDragStart}
|
return (
|
||||||
onDragEnd={handleDragEnd}
|
<DroppableColumn
|
||||||
>
|
key={stage.id}
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4 snap-x">
|
title={stage.label}
|
||||||
{pipelineStages.map(stage => {
|
count={stageClients.length}
|
||||||
const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived');
|
color={stage.color.split(' ')[1]}
|
||||||
return (
|
onDrop={() => handleDrop(stage.id)}
|
||||||
<DroppableColumn
|
>
|
||||||
key={stage.id}
|
{stageClients.map(client => (
|
||||||
id={stage.id}
|
<DraggableClientCard
|
||||||
title={stage.label}
|
key={client.id}
|
||||||
count={stageClients.length}
|
client={client}
|
||||||
color={stage.color.split(' ')[1]}
|
draggedClientId={draggedClientId}
|
||||||
>
|
onDragStart={handleDragStart}
|
||||||
{stageClients.map(client => (
|
onDragEnd={() => setDraggedClientId(null)}
|
||||||
<DraggableClientCard key={client.id} client={client} />
|
/>
|
||||||
))}
|
))}
|
||||||
{stageClients.length === 0 && (
|
{stageClients.length === 0 && (
|
||||||
<div className="h-24 flex items-center justify-center border-2 border-dashed border-border rounded-md text-xs text-muted-foreground">
|
<div className="h-24 flex items-center justify-center border-2 border-dashed border-border rounded-md text-xs text-muted-foreground">
|
||||||
Boş
|
Boş
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DroppableColumn>
|
</DroppableColumn>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<DragOverlay>
|
|
||||||
{activeDragClient ? (
|
|
||||||
<DraggableClientCard client={activeDragClient} isOverlay />
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="list" className="mt-0">
|
<TabsContent value="list" className="mt-0">
|
||||||
@@ -304,15 +274,17 @@ export function ClientsClient({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DroppableColumn({ id, title, count, color, children }: { id: string, title: string, count: number, color: string, children: React.ReactNode }) {
|
function DroppableColumn({ title, count, color, onDrop, children }: { title: string, count: number, color: string, onDrop: () => void, children: React.ReactNode }) {
|
||||||
const { isOver, setNodeRef } = useDroppable({ id });
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 w-[340px] px-2 flex flex-col h-[calc(100vh-320px)] min-h-[500px] transition-colors rounded-lg",
|
"flex-shrink-0 w-[340px] px-2 flex flex-col h-[calc(100vh-320px)] min-h-[500px] transition-colors rounded-lg"
|
||||||
isOver ? "bg-muted/30" : ""
|
|
||||||
)}
|
)}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
}}
|
||||||
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3 px-1">
|
<div className="flex items-center justify-between mb-3 px-1">
|
||||||
<h3 className="font-semibold text-sm text-foreground flex items-center gap-2">
|
<h3 className="font-semibold text-sm text-foreground flex items-center gap-2">
|
||||||
@@ -328,20 +300,26 @@ function DroppableColumn({ id, title, count, color, children }: { id: string, ti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableClientCard({ client, isOverlay }: { client: ClientListItem, isOverlay?: boolean }) {
|
function DraggableClientCard({
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
client,
|
||||||
id: client.id,
|
draggedClientId,
|
||||||
data: client,
|
onDragStart,
|
||||||
});
|
onDragEnd
|
||||||
|
}: {
|
||||||
const style = {
|
client: ClientListItem,
|
||||||
transform: CSS.Translate.toString(transform),
|
draggedClientId: string | null,
|
||||||
opacity: isDragging && !isOverlay ? 0.3 : 1,
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void,
|
||||||
zIndex: isDragging ? 999 : "auto",
|
onDragEnd: () => void
|
||||||
};
|
}) {
|
||||||
|
const isDragging = draggedClientId === client.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className={cn("touch-none cursor-grab active:cursor-grabbing", isOverlay && "rotate-2 scale-105 shadow-xl")}>
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, client.id)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
className={cn("touch-none", isDragging ? "cursor-grabbing opacity-50 ring-2 ring-primary/20 transition" : "cursor-grab transition active:cursor-grabbing")}
|
||||||
|
>
|
||||||
<Card className="hover:border-primary/50 transition-colors">
|
<Card className="hover:border-primary/50 transition-colors">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user