From ea3d6f6045b08d936d80f8c9478ae14ef6e54502 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Fri, 1 May 2026 03:26:31 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20leads=20module=20=E2=80=94=20kanban=20b?= =?UTF-8?q?oard,=20aktiviteler,=20takvim=20entegrasyonu,=20m=C3=BC=C5=9Fte?= =?UTF-8?q?riye=20d=C3=B6n=C3=BC=C5=9Ft=C3=BCr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/lead-activities-fetcher.ts | 24 ++ .../leads/components/lead-card.tsx | 139 ++++++++ .../leads/components/lead-detail-sheet.tsx | 269 ++++++++++++++++ .../leads/components/lead-form-sheet.tsx | 199 ++++++++++++ .../leads/components/leads-board.tsx | 255 +++++++++++++++ src/app/(dashboard)/leads/components/types.ts | 72 +++++ src/app/(dashboard)/leads/page.tsx | 76 +++++ src/components/app-sidebar.tsx | 6 + src/lib/appwrite/lead-actions.ts | 299 ++++++++++++++++++ src/lib/appwrite/lead-activity-actions.ts | 145 +++++++++ src/lib/appwrite/lead-queries.ts | 51 +++ src/lib/appwrite/schema.ts | 36 +++ src/lib/validation/leads.ts | 19 ++ 13 files changed, 1590 insertions(+) create mode 100644 src/app/(dashboard)/leads/components/lead-activities-fetcher.ts create mode 100644 src/app/(dashboard)/leads/components/lead-card.tsx create mode 100644 src/app/(dashboard)/leads/components/lead-detail-sheet.tsx create mode 100644 src/app/(dashboard)/leads/components/lead-form-sheet.tsx create mode 100644 src/app/(dashboard)/leads/components/leads-board.tsx create mode 100644 src/app/(dashboard)/leads/components/types.ts create mode 100644 src/app/(dashboard)/leads/page.tsx create mode 100644 src/lib/appwrite/lead-actions.ts create mode 100644 src/lib/appwrite/lead-activity-actions.ts create mode 100644 src/lib/appwrite/lead-queries.ts create mode 100644 src/lib/validation/leads.ts diff --git a/src/app/(dashboard)/leads/components/lead-activities-fetcher.ts b/src/app/(dashboard)/leads/components/lead-activities-fetcher.ts new file mode 100644 index 0000000..5a92a07 --- /dev/null +++ b/src/app/(dashboard)/leads/components/lead-activities-fetcher.ts @@ -0,0 +1,24 @@ +"use server"; + +import { listLeadActivities } from "@/lib/appwrite/lead-queries"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import type { ActivityRow } from "./types"; + +export async function listLeadActivitiesForClient(leadId: string): Promise { + try { + const ctx = await requireTenant(); + const rows = await listLeadActivities(ctx.tenantId, leadId); + return rows.map((a) => ({ + id: a.$id, + leadId: a.leadId, + type: a.type, + content: a.content, + calendarEventId: a.calendarEventId ?? null, + occurredAt: a.occurredAt ?? null, + createdAt: a.$createdAt, + createdByName: "", + })); + } catch { + return []; + } +} diff --git a/src/app/(dashboard)/leads/components/lead-card.tsx b/src/app/(dashboard)/leads/components/lead-card.tsx new file mode 100644 index 0000000..55e496c --- /dev/null +++ b/src/app/(dashboard)/leads/components/lead-card.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Calendar, GripVertical, MoreHorizontal, Pencil, Phone, Trash2, UserCircle } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { formatCurrency, formatDate } from "@/lib/format"; + +import { LEAD_SOURCE_LABEL, LEAD_STATUS_CONFIG, type LeadRow } from "./types"; + +type Props = { + lead: LeadRow; + onEdit: (lead: LeadRow) => void; + onDetail: (lead: LeadRow) => void; + onDelete: (lead: LeadRow) => void; + isOverlay?: boolean; +}; + +export function LeadCard({ lead, onEdit, onDetail, onDelete, isOverlay }: Props) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: lead.id, + data: { type: "lead", status: lead.status }, + }); + + const style = { transform: CSS.Transform.toString(transform), transition }; + const cfg = LEAD_STATUS_CONFIG[lead.status]; + const isOverdue = lead.nextFollowUpAt && new Date(lead.nextFollowUpAt) < new Date(); + + return ( +
onDetail(lead)} + > +
+ + +
+
+
+

{lead.name}

+ {lead.contactName && ( +

{lead.contactName}

+ )} +
+ + + + + + { e.stopPropagation(); onDetail(lead); }}> + + Detay & Aktiviteler + + { e.stopPropagation(); onEdit(lead); }}> + + Düzenle + + + { e.stopPropagation(); onDelete(lead); }}> + + Sil + + + +
+ +
+ + {LEAD_SOURCE_LABEL[lead.source]} + + + {lead.estimatedValue != null && lead.estimatedValue > 0 && ( + + {formatCurrency(lead.estimatedValue, lead.currency)} + + )} +
+ +
+ {lead.phone && ( + + + {lead.phone} + + )} + {lead.nextFollowUpAt && ( + + + {formatDate(lead.nextFollowUpAt)} + {isOverdue && " — gecikti"} + + )} +
+ + {lead.assigneeName && ( +
+ + {lead.assigneeName} +
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/leads/components/lead-detail-sheet.tsx b/src/app/(dashboard)/leads/components/lead-detail-sheet.tsx new file mode 100644 index 0000000..a683e43 --- /dev/null +++ b/src/app/(dashboard)/leads/components/lead-detail-sheet.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useActionState, useEffect, useState, useTransition } from "react"; +import { + Calendar, CheckCircle2, ChevronDown, Loader2, Mail, MessageSquarePlus, + Phone, TrendingUp, UserCheck, X, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + Sheet, SheetContent, SheetHeader, SheetTitle, +} from "@/components/ui/sheet"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { formatCurrency, formatDateTime } from "@/lib/format"; +import { + addLeadActivityAction, + scheduleFollowUpAction, +} from "@/lib/appwrite/lead-activity-actions"; +import { convertLeadToCustomerAction } from "@/lib/appwrite/lead-actions"; + +import { + ACTIVITY_TYPE_CONFIG, + LEAD_SOURCE_LABEL, + LEAD_STATUS_CONFIG, + type ActivityRow, + type LeadActivityType, + type LeadRow, +} from "./types"; + +type Props = { + open: boolean; + onOpenChange: (v: boolean) => void; + lead: LeadRow | null; + activities: ActivityRow[]; + onEdit: (lead: LeadRow) => void; +}; + +const ACTIVITY_TYPES: LeadActivityType[] = ["note", "call", "meeting", "email"]; + +export function LeadDetailSheet({ open, onOpenChange, lead, activities, onEdit }: Props) { + const [activityState, activityAction, activityPending] = useActionState( + addLeadActivityAction, { ok: false }, + ); + const [followUpState, followUpAction, followUpPending] = useActionState( + scheduleFollowUpAction, { ok: false }, + ); + const [convertBusy, startConvert] = useTransition(); + const [tab, setTab] = useState<"activities" | "followup">("activities"); + const [activityType, setActivityType] = useState("note"); + + useEffect(() => { + if (activityState.ok) toast.success("Aktivite kaydedildi."); + else if (activityState.error) toast.error(activityState.error); + }, [activityState]); + + useEffect(() => { + if (followUpState.ok) { toast.success("Takip takvime eklendi."); setTab("activities"); } + else if (followUpState.error) toast.error(followUpState.error); + }, [followUpState]); + + const handleConvert = () => { + if (!lead) return; + startConvert(async () => { + const fd = new FormData(); + fd.set("leadId", lead.id); + const result = await convertLeadToCustomerAction(fd); + if (result.ok) { + toast.success("Müşteriye dönüştürüldü."); + onOpenChange(false); + } else { + toast.error(result.error ?? "Dönüştürme başarısız."); + } + }); + }; + + if (!lead) return null; + const cfg = LEAD_STATUS_CONFIG[lead.status]; + + return ( + + + +
+
+ {lead.name} + {lead.contactName && ( +

{lead.contactName}

+ )} +
+ + {cfg.label} + +
+
+ +
+ {/* Info strip */} +
+ {lead.phone && ( + + {lead.phone} + + )} + {lead.email && ( + + {lead.email} + + )} + {lead.estimatedValue != null && lead.estimatedValue > 0 && ( + + + {formatCurrency(lead.estimatedValue, lead.currency)} + + )} + {LEAD_SOURCE_LABEL[lead.source]} +
+ + {/* Tab bar */} +
+ {(["activities", "followup"] as const).map((t) => ( + + ))} +
+ +
+ {tab === "activities" && ( +
+ {/* Add activity form */} +
+ +
+ + ekle +
+