From fd2d4ff97616c260f5d859f0cdf1492751e0a74a Mon Sep 17 00:00:00 2001 From: caitlin Date: Thu, 18 Jun 2026 12:15:44 +0200 Subject: [PATCH 01/11] I fixed the filter for all the leads --- package.json | 2 +- src/app/(dashboard)/dashboard/page.tsx | 45 +++++---- src/app/(dashboard)/leads/page.tsx | 10 +- .../dashboard/leads-per-month-chart.tsx | 12 +-- src/components/leads/leads-table-toolbar.tsx | 24 +++-- src/data/dashboard.ts | 88 ++++++++--------- src/lib/date-utils.ts | 95 +++++++++++++++++++ src/types/index.ts | 3 +- 8 files changed, 203 insertions(+), 76 deletions(-) create mode 100644 src/lib/date-utils.ts diff --git a/package.json b/package.json index 7fb48c3..60ce11a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3006", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index bda9349..6b36c95 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,12 +1,13 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import { PageHeader } from "@/components/shared/page-header" import { StatCard } from "@/components/dashboard/stat-card" +import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton" import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table" import { LeadStatusChart } from "@/components/dashboard/lead-status-chart" import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart" -import { dashboardStats } from "@/data/dashboard" +import { getDashboardStats } from "@/data/dashboard" import { Users, UserPlus, @@ -23,19 +24,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { DashboardStats } from "@/types" export default function DashboardPage() { - const stats = dashboardStats const [period, setPeriod] = useState("6months") + const [stats, setStats] = useState(null) - const statCards = [ - { title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: "All time" }, - { title: "Open Leads", value: stats.openLeads, icon: UserPlus, variant: "blue" as const, description: "Needs attention" }, - { title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, variant: "amber" as const, description: "In conversation" }, - { title: "Pending", value: stats.pendingLeads, icon: Clock, variant: "purple" as const, description: "Awaiting decision" }, - { title: "Closed", value: stats.closedLeads, icon: CheckCircle2, variant: "emerald" as const, description: "Won" }, - { title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, variant: "emerald" as const, description: `${stats.closedLeads} of ${stats.totalLeads} leads` }, - ] + useEffect(() => { + setStats(getDashboardStats(period)) + }, [period]) + + const statCards = stats + ? [ + { title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: stats.periodLabel }, + { title: "Open Leads", value: stats.openLeads, icon: UserPlus, variant: "blue" as const, description: "Needs attention" }, + { title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, variant: "amber" as const, description: "In conversation" }, + { title: "Pending", value: stats.pendingLeads, icon: Clock, variant: "purple" as const, description: "Awaiting decision" }, + { title: "Closed", value: stats.closedLeads, icon: CheckCircle2, variant: "emerald" as const, description: "Won" }, + { title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, variant: "emerald" as const, description: `${stats.closedLeads} of ${stats.totalLeads} leads` }, + ] + : [] return (
@@ -55,17 +63,20 @@ export default function DashboardPage() {
- {statCards.map((card, i) => ( - - ))} + {stats + ? statCards.map((card, i) => ( + + )) + : Array.from({ length: 6 }).map((_, i) => ) + }
- - + +
- +
) } diff --git a/src/app/(dashboard)/leads/page.tsx b/src/app/(dashboard)/leads/page.tsx index 94114be..578043c 100644 --- a/src/app/(dashboard)/leads/page.tsx +++ b/src/app/(dashboard)/leads/page.tsx @@ -6,15 +6,21 @@ import { LeadsTable } from "@/components/leads/leads-table" import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar" import { LeadFormDialog } from "@/components/leads/lead-form-dialog" import { leads } from "@/data/leads" +import { filterLeadsByPeriod } from "@/lib/date-utils" export default function LeadsPage() { const [search, setSearch] = useState("") const [statusFilter, setStatusFilter] = useState("all") + const [periodFilter, setPeriodFilter] = useState("all") const [createOpen, setCreateOpen] = useState(false) const filteredLeads = useMemo(() => { let result = leads + if (periodFilter && periodFilter !== "all") { + result = filterLeadsByPeriod(result, periodFilter) + } + if (search) { const q = search.toLowerCase() result = result.filter( @@ -31,7 +37,7 @@ export default function LeadsPage() { } return result - }, [search, statusFilter]) + }, [search, statusFilter, periodFilter]) return (
@@ -47,6 +53,8 @@ export default function LeadsPage() { onSearchChange={setSearch} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} + periodFilter={periodFilter} + onPeriodFilterChange={setPeriodFilter} onCreateClick={() => setCreateOpen(true)} />
diff --git a/src/components/dashboard/leads-per-month-chart.tsx b/src/components/dashboard/leads-per-month-chart.tsx index 1be8046..b245d36 100644 --- a/src/components/dashboard/leads-per-month-chart.tsx +++ b/src/components/dashboard/leads-per-month-chart.tsx @@ -4,14 +4,14 @@ import { useState, useMemo } from "react" import { motion } from "framer-motion" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -interface MonthlyData { - month: string +interface IntervalData { + label: string leads: number closed: number } interface LeadsPerMonthChartProps { - data: MonthlyData[] + data: IntervalData[] } const BAR_GRADIENT_TOP = "#3B6AB8" @@ -138,7 +138,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) { return ( setHovered(i)} onMouseLeave={() => setHovered(null)} style={{ cursor: "pointer" }} @@ -188,7 +188,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) { fontWeight={isActive ? 600 : 400} textAnchor="middle" > - {d.month} + {d.label} ) @@ -216,7 +216,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) { }} >
- {activeDatum.month} + {activeDatum.label}
diff --git a/src/components/leads/leads-table-toolbar.tsx b/src/components/leads/leads-table-toolbar.tsx index 605386b..4ff4416 100644 --- a/src/components/leads/leads-table-toolbar.tsx +++ b/src/components/leads/leads-table-toolbar.tsx @@ -1,6 +1,5 @@ "use client" -import { useState } from "react" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { @@ -10,14 +9,15 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { Search, Plus, SlidersHorizontal } from "lucide-react" -import { LeadStatus } from "@/types" +import { Search, Plus } from "lucide-react" interface LeadsTableToolbarProps { search: string onSearchChange: (value: string) => void statusFilter: string onStatusFilterChange: (value: string) => void + periodFilter: string + onPeriodFilterChange: (value: string) => void onCreateClick: () => void } @@ -26,6 +26,8 @@ export function LeadsTableToolbar({ onSearchChange, statusFilter, onStatusFilterChange, + periodFilter, + onPeriodFilterChange, onCreateClick, }: LeadsTableToolbarProps) { return ( @@ -40,6 +42,18 @@ export function LeadsTableToolbar({ />
+ - +
+
+
+ {fmt(displayDuration)} +
+ ) +} + export default function ChatsPage() { const { theme } = useTheme() const { user } = useUser() @@ -42,14 +102,52 @@ export default function ChatsPage() { const [isResizing, setIsResizing] = useState(false) const [reportDialogOpen, setReportDialogOpen] = useState(false) const [reportReason, setReportReason] = useState("") + const [forwardDialogOpen, setForwardDialogOpen] = useState(false) + const [forwardMessage, setForwardMessage] = useState(null) + const [forwardSearch, setForwardSearch] = useState("") + const [searchQuery, setSearchQuery] = useState("") + const [isRecording, setIsRecording] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const [recordingDuration, setRecordingDuration] = useState(0) + const recordingDurationRef = useRef(0) + const [voiceMessages, setVoiceMessages] = useState>(new Map()) + const [messageAttachments, setMessageAttachments] = useState>(new Map()) + const [replyingTo, setReplyingTo] = useState(null) + const [editingMessage, setEditingMessage] = useState(null) + const [replyMap, setReplyMap] = useState>(new Map()) + const [messageStatus, setMessageStatus] = useState>(() => { + const map = new Map() + conversationsData.forEach((conv) => + conv.messages.forEach((msg) => { + if (msg.senderId === "user1") map.set(msg.id, "read") + }) + ) + return map + }) const fileInputRef = useRef(null) + const textareaRef = useRef(null) const emojiPickerRef = useRef(null) + const messagesEndRef = useRef(null) + const mediaRecorderRef = useRef(null) + const recordingChunksRef = useRef([]) const resizeStartRef = useRef({ x: 0, width: 0 }) + const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim()) + + const autoResizeTextarea = useCallback(() => { + const ta = textareaRef.current + if (!ta) return + ta.style.height = "auto" + ta.style.height = `${ta.scrollHeight}px` + }, []) + const [conversations, setConversations] = useState(conversationsData) const conversation = conversations.find((c) => c.id === activeChat) const otherParticipant = (conv: typeof conversationsData[0]) => conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0] + const filteredConversations = searchQuery.trim() + ? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase())) + : conversations useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -83,9 +181,14 @@ export default function ChatsPage() { } }, [isResizing]) + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [conversations]) + const handleEmojiSelect = (emoji: { native: string }) => { setMessageInput((prev) => prev + emoji.native) setShowEmojiPicker(false) + setTimeout(() => autoResizeTextarea(), 0) } const handleFileSelect = (e: React.ChangeEvent) => { @@ -98,16 +201,15 @@ export default function ChatsPage() { setAttachments((prev) => prev.filter((_, i) => i !== index)) } - const handleSend = (e: React.FormEvent) => { - e.preventDefault() - if (!messageInput.trim() && attachments.length === 0) return + const addMessageToChat = (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, attachments?: { name: string; type: string; url: string }[]) => { + const id = crypto.randomUUID() const newMessage = { - id: crypto.randomUUID(), + id, conversationId: activeChat!, senderId: "user1", senderName: "Sarah Chen", senderAvatar: "SC", - content: messageInput.trim(), + content, timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), } setConversations((prev) => @@ -116,17 +218,165 @@ export default function ChatsPage() { ? { ...conv, messages: [...conv.messages, newMessage], - lastMessage: newMessage.content, + lastMessage: voice ? "๐ŸŽค Voice note" : (replyTo ? `โ†ช ${content}` : content), lastMessageTime: "Just now", unread: 0, } : conv ) ) + setMessageStatus((prev) => { + const next = new Map(prev) + next.set(id, "sent") + return next + }) + setTimeout(() => { + setMessageStatus((prev) => { + const next = new Map(prev) + if (next.get(id) === "sent") next.set(id, "delivered") + return next + }) + }, 1000) + if (voice) { + setVoiceMessages((prev) => { + const next = new Map(prev) + next.set(id, voice) + return next + }) + } + if (replyTo) { + setReplyMap((prev) => { + const next = new Map(prev) + next.set(id, { repliedToSender: replyTo.senderName, repliedToContent: replyTo.content }) + return next + }) + } + if (attachments && attachments.length > 0) { + setMessageAttachments((prev) => { + const next = new Map(prev) + next.set(id, attachments) + return next + }) + } + } + + const handleSend = (e: React.FormEvent) => { + e.preventDefault() + if (!messageInput.trim() && attachments.length === 0) return + if (editingMessage) { + setConversations((prev) => + prev.map((conv) => ({ + ...conv, + messages: conv.messages.map((m) => + m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m + ), + lastMessage: conv.id === activeChat && conv.messages.some((m) => m.id === editingMessage.id) + ? messageInput.trim() : conv.lastMessage, + })) + ) + setEditingMessage(null) + setMessageInput("") + setTimeout(() => autoResizeTextarea(), 0) + return + } + const fileAttachments = attachments.length > 0 + ? attachments.map((f) => ({ name: f.name, type: f.type, url: URL.createObjectURL(f) })) + : undefined + if (replyingTo) { + const replySender = replyingTo.senderId === "user1" ? user.name : otherParticipant(conversation!).name + addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments) + setReplyingTo(null) + } else { + addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments) + } setMessageInput("") + setTimeout(() => autoResizeTextarea(), 0) setAttachments([]) } + const handleDeleteMessage = (messageId: string) => { + setConversations((prev) => + prev.map((conv) => ({ + ...conv, + messages: conv.messages.filter((m) => m.id !== messageId), + lastMessage: conv.messages.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage, + })) + ) + toast.success("Message deleted") + } + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" }) + recordingChunksRef.current = [] + recorder.ondataavailable = (e) => { + if (e.data.size > 0) recordingChunksRef.current.push(e.data) + } + recorder.onstop = () => { + const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" }) + const url = URL.createObjectURL(blob) + addMessageToChat("", { url, duration: recordingDurationRef.current }) + stream.getTracks().forEach((t) => t.stop()) + setRecordingDuration(0) + } + mediaRecorderRef.current = recorder + recorder.start() + setIsRecording(true) + setIsPaused(false) + } catch { + toast.error("Microphone access denied") + } + } + + const togglePauseRecording = () => { + const recorder = mediaRecorderRef.current + if (!recorder || recorder.state === "inactive") return + if (recorder.state === "recording") { + recorder.pause() + setIsPaused(true) + } else { + recorder.resume() + setIsPaused(false) + } + } + + const stopRecording = () => { + mediaRecorderRef.current?.stop() + setIsRecording(false) + setIsPaused(false) + } + + const discardRecording = () => { + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { + mediaRecorderRef.current.ondataavailable = null + mediaRecorderRef.current.onstop = null + mediaRecorderRef.current.stop() + mediaRecorderRef.current.stream?.getTracks().forEach((t) => t.stop()) + } + setIsRecording(false) + setIsPaused(false) + setRecordingDuration(0) + recordingDurationRef.current = 0 + } + + useEffect(() => { + if (!isRecording) return + recordingDurationRef.current = 0 + setRecordingDuration(0) + const interval = setInterval(() => { + recordingDurationRef.current += 1 + setRecordingDuration(recordingDurationRef.current) + }, 1000) + return () => clearInterval(interval) + }, [isRecording]) + + const formatDuration = (secs: number) => { + const m = Math.floor(secs / 60) + const s = secs % 60 + return `${m}:${s.toString().padStart(2, "0")}` + } + const formatFileSize = (bytes: number) => { if (bytes < 1024) return bytes + " B" if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB" @@ -146,11 +396,11 @@ export default function ChatsPage() {

Chats

- + setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
- {conversations.map((conv) => { + {filteredConversations.map((conv) => { const person = otherParticipant(conv) const isActive = conv.id === activeChat return ( @@ -180,6 +430,9 @@ export default function ChatsPage() { ) })} + {filteredConversations.length === 0 && searchQuery && ( +
No conversations found
+ )}
@@ -206,16 +459,10 @@ export default function ChatsPage() {
- - @@ -234,10 +481,7 @@ export default function ChatsPage() { Block - toast.info("Conversation deleted")} - > + toast.info("Conversation deleted")}> Delete @@ -246,12 +490,13 @@ export default function ChatsPage() {
{/* Messages */} - -
+
+
{conversation.messages.map((msg) => { const isMe = msg.senderId === "user1" + const voiceUrl = voiceMessages.get(msg.id) return ( -
+
{isMe ? : null} @@ -261,92 +506,208 @@ export default function ChatsPage() {
- {msg.content} + {!isMe && ( + + + + + + setReplyingTo(msg)}> + Reply + + { setForwardMessage(msg); setForwardDialogOpen(true) }}> + Forward + + + + )} + {isMe && ( + + + + + + { setEditingMessage(msg); setMessageInput(msg.content) }}> + Edit + + setReplyingTo(msg)}> + Reply + + { setForwardMessage(msg); setForwardDialogOpen(true) }}> + Forward + + + handleDeleteMessage(msg.id)}> + Delete + + + + )} + {(() => { + const replyInfo = replyMap.get(msg.id) + return ( + <> + {replyInfo && ( +
+

{replyInfo.repliedToSender}

+

{replyInfo.repliedToContent}

+
+ )} + {voiceUrl ? ( +
+ + {msg.content && ( +

{msg.content}

+ )} +
+ ) : ( + <> + {(() => { + const files = messageAttachments.get(msg.id) + return files && files.length > 0 ? ( +
+ {files.map((file, i) => + file.type.startsWith("image/") ? ( +
+ {file.name} window.open(file.url, "_blank")} /> + + + +
+ ) : ( + + + {file.name} + + + ) + )} +
+ ) : null + })()} + {isOnlyEmoji(msg.content) ? ( + {msg.content.trim()} + ) : ( + msg.content + )} + + )} + + ) + })()}
- {msg.timestamp} + + {msg.timestamp} + {isMe && ( + (() => { + const status = messageStatus.get(msg.id) + if (status === "read") return + if (status === "delivered") return + return + })() + )} +
) })} +
- +
{/* Input */}
{attachments.length > 0 && (
{attachments.map((file, i) => ( -
+
{isImageFile(file) ? ( ) : ( - + )} {file.name} {formatFileSize(file.size)} -
))}
)} + {/* Reply bar */} + {replyingTo && ( +
+
+

+ Replying to {replyingTo.senderId === "user1" ? "yourself" : otherParticipant(conversation).name} +

+

{replyingTo.content}

+
+ +
+ )} + {editingMessage && ( +
+ + Editing message + +
+ )}
- -
- setMessageInput(e.target.value)} - placeholder="Type a message..." - className="h-9 w-full pr-9" - /> -
- - {showEmojiPicker && ( -
- + {isRecording ? ( +
+ + {formatDuration(recordingDuration)} +
+ + + +
+ ) : ( + <> +