Calender & Linked Added
This commit is contained in:
@@ -0,0 +1,861 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { CalendarEvent, EventType } from "@/types"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
||||
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||
ArrowUpRight, Zap, StickyNote,
|
||||
} from "lucide-react"
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
||||
meeting: Users,
|
||||
call: Phone,
|
||||
chat: Video,
|
||||
follow_up: Clock,
|
||||
demo: Calendar,
|
||||
other: Calendar,
|
||||
}
|
||||
|
||||
function eventVars(type: EventType) {
|
||||
return `--event-${type}`
|
||||
}
|
||||
|
||||
function eventBgStyle(type: EventType): React.CSSProperties {
|
||||
const v = eventVars(type)
|
||||
return {
|
||||
backgroundColor: `hsl(var(${v}) / 0.1)`,
|
||||
color: `hsl(var(${v}))`,
|
||||
borderColor: `hsl(var(${v}) / 0.25)`,
|
||||
}
|
||||
}
|
||||
|
||||
function eventDotStyle(type: EventType): React.CSSProperties {
|
||||
return { backgroundColor: `hsl(var(--event-${type}))` }
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number) {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
function getFirstDayOfMonth(year: number, month: number) {
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter()
|
||||
const { user } = useUser()
|
||||
const { addNotification } = useNotifications()
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [today] = useState(new Date())
|
||||
const [currentMonth, setCurrentMonth] = useState(today.getMonth())
|
||||
const [currentYear, setCurrentYear] = useState(today.getFullYear())
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
||||
|
||||
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
|
||||
const [formTitle, setFormTitle] = useState("")
|
||||
const [formDescription, setFormDescription] = useState("")
|
||||
const [formType, setFormType] = useState<EventType>("meeting")
|
||||
const [formDate, setFormDate] = useState("")
|
||||
const [formTime, setFormTime] = useState("")
|
||||
const [formDuration, setFormDuration] = useState("30")
|
||||
const [formParticipantId, setFormParticipantId] = useState("")
|
||||
const [formSaving, setFormSaving] = useState(false)
|
||||
const [editNotes, setEditNotes] = useState(false)
|
||||
const [notesText, setNotesText] = useState("")
|
||||
const [notesSaving, setNotesSaving] = useState(false)
|
||||
|
||||
const fetchMonthEvents = useCallback(async () => {
|
||||
const start = new Date(currentYear, currentMonth, 1).toISOString()
|
||||
const end = new Date(currentYear, currentMonth + 1, 0, 23, 59, 59).toISOString()
|
||||
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setEvents(data.events || [])
|
||||
}
|
||||
}, [currentYear, currentMonth])
|
||||
|
||||
const fetchUpcomingEvents = useCallback(async () => {
|
||||
const start = new Date().toISOString()
|
||||
const end = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&status=scheduled`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUpcomingEvents((data.events || []).slice(0, 10))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) { router.push("/login"); return }
|
||||
fetch("/api/event-users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
}, [user, router])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchMonthEvents().catch(() => toast.error("Failed to load events")).finally(() => setLoading(false))
|
||||
}, [fetchMonthEvents])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUpcomingEvents().catch(() => {})
|
||||
}, [fetchUpcomingEvents])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchMonthEvents().catch(() => {})
|
||||
fetchUpcomingEvents().catch(() => {})
|
||||
}, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchMonthEvents, fetchUpcomingEvents])
|
||||
|
||||
const daysInMonth = getDaysInMonth(currentYear, currentMonth)
|
||||
const firstDay = getFirstDayOfMonth(currentYear, currentMonth)
|
||||
|
||||
const prevMonth = () => {
|
||||
if (currentMonth === 0) { setCurrentMonth(11); setCurrentYear(currentYear - 1) }
|
||||
else setCurrentMonth(currentMonth - 1)
|
||||
}
|
||||
|
||||
const nextMonth = () => {
|
||||
if (currentMonth === 11) { setCurrentMonth(0); setCurrentYear(currentYear + 1) }
|
||||
else setCurrentMonth(currentMonth + 1)
|
||||
}
|
||||
|
||||
const getEventsForDay = useCallback((day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||
return events.filter((e) => e.startTime.startsWith(dateStr))
|
||||
}, [events, currentYear, currentMonth])
|
||||
|
||||
const isToday = (day: number) => {
|
||||
return today.getDate() === day && today.getMonth() === currentMonth && today.getFullYear() === currentYear
|
||||
}
|
||||
|
||||
const openCreateDialog = (day?: number) => {
|
||||
setEditingEvent(null)
|
||||
setFormTitle("")
|
||||
setFormDescription("")
|
||||
setFormType("meeting")
|
||||
setFormDuration("30")
|
||||
setFormParticipantId("")
|
||||
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
|
||||
setFormDate(d.toISOString().split("T")[0])
|
||||
setFormTime("10:00")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (event: CalendarEvent) => {
|
||||
setEditingEvent(event)
|
||||
setFormTitle(event.title)
|
||||
setFormDescription(event.description || "")
|
||||
setFormType(event.eventType)
|
||||
setFormDuration(String(event.durationMinutes || 30))
|
||||
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
|
||||
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
|
||||
setFormParticipantId(event.participantId || "")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formTitle.trim() || !formDate || !formTime) {
|
||||
toast.error("Title, date, and time are required")
|
||||
return
|
||||
}
|
||||
|
||||
setFormSaving(true)
|
||||
try {
|
||||
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
||||
let endTime = null
|
||||
if (formDuration) {
|
||||
const end = new Date(startTime)
|
||||
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
||||
endTime = end.toISOString()
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: formTitle.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
eventType: formType,
|
||||
startTime,
|
||||
endTime,
|
||||
durationMinutes: formDuration ? parseInt(formDuration) : null,
|
||||
}
|
||||
if (formParticipantId) body.participantId = formParticipantId
|
||||
|
||||
let res
|
||||
if (editingEvent) {
|
||||
res = await fetch(`/api/events/${editingEvent.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} else {
|
||||
res = await fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
|
||||
const data = await res.json()
|
||||
if (editingEvent) {
|
||||
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
toast.success("Event updated")
|
||||
} else {
|
||||
setEvents((prev) => [...prev, data.event])
|
||||
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||
toast.success("Event created")
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
} catch {
|
||||
toast.error("Failed to save event")
|
||||
} finally {
|
||||
setFormSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/events/${event.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to update")
|
||||
const data = await res.json()
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
toast.success(`Event ${newStatus}`)
|
||||
setDetailEvent(null)
|
||||
} catch {
|
||||
toast.error("Failed to update event")
|
||||
}
|
||||
}
|
||||
|
||||
const saveNotes = async (event: CalendarEvent) => {
|
||||
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
|
||||
setNotesSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/events/${event.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ participantNotes: notesText || null }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
const data = await res.json()
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
if (detailEvent && detailEvent.id === event.id) {
|
||||
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
|
||||
}
|
||||
toast.success("Notes saved")
|
||||
setEditNotes(false)
|
||||
} catch {
|
||||
toast.error("Failed to save notes")
|
||||
} finally {
|
||||
setNotesSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEvent = async (event: CalendarEvent) => {
|
||||
try {
|
||||
const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" })
|
||||
if (!res.ok) throw new Error("Failed to delete")
|
||||
setEvents((prev) => prev.filter((e) => e.id !== event.id))
|
||||
toast.success("Event deleted")
|
||||
setDetailEvent(null)
|
||||
setDialogOpen(false)
|
||||
} catch {
|
||||
toast.error("Failed to delete event")
|
||||
}
|
||||
}
|
||||
|
||||
const calendarDays: (number | null)[] = []
|
||||
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-b from-background via-background to-muted/20">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0 bg-gradient-to-r from-background via-accent/5 to-background backdrop-blur-sm shadow-[0_1px_3px_-1px_hsl(var(--border))]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 text-primary shadow-sm ring-1 ring-primary/15">
|
||||
<Calendar className="h-4 w-4" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold tracking-tight">Calendar</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-0.5 rounded-lg border bg-card/60 shadow-sm">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={prevMonth}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-bold min-w-[130px] text-center select-none tracking-tight px-1">
|
||||
{MONTHS[currentMonth]} {currentYear}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={nextMonth}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-xs h-7 px-2 hover:bg-accent/40 hover:scale-105 active:scale-95 transition-all" onClick={() => { setCurrentMonth(today.getMonth()); setCurrentYear(today.getFullYear()) }}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => openCreateDialog()} size="sm" className="shadow-sm hover:shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all bg-gradient-to-r from-primary to-primary/90 h-9">
|
||||
<Plus className="h-4 w-4 mr-1.5" /> New Event
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body - flex row with independent scroll */}
|
||||
<div className="flex-1 flex overflow-hidden p-4 lg:p-6 gap-4 lg:gap-6">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary/60" />
|
||||
<div className="absolute inset-0 animate-pulse rounded-full bg-primary/5" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Loading calendar...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Left: Calendar Grid */}
|
||||
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-hidden">
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary/5 border border-primary/10 shadow-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
<span className="text-sm font-bold text-primary">{events.filter(e => e.status === "scheduled").length}</span>
|
||||
<span className="text-[11px] text-primary/60 font-medium">scheduled</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-emerald-500/5 border border-emerald-500/10 shadow-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{events.filter(e => e.status === "completed").length}</span>
|
||||
<span className="text-[11px] text-emerald-600/60 dark:text-emerald-400/60 font-medium">completed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-muted/30 border shadow-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
<span className="text-sm font-bold text-foreground/70">{events.length}</span>
|
||||
<span className="text-[11px] text-muted-foreground/50 font-medium">total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid wrapper - scrollable */}
|
||||
<div className="flex-1 overflow-auto rounded-xl border bg-card shadow-sm">
|
||||
<div className="min-w-[600px]">
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 sticky top-0 z-10 bg-gradient-to-r from-muted/30 via-background to-muted/30 border-b border-border/40">
|
||||
{DAYS.map((d, i) => (
|
||||
<div key={d} className={cn(
|
||||
"px-3 py-2.5 text-[11px] font-semibold text-muted-foreground/50 text-center tracking-widest uppercase",
|
||||
i === 0 && "text-red-500/40 dark:text-red-400/40",
|
||||
i === 6 && "text-red-500/40 dark:text-red-400/40",
|
||||
)}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((day, i) => {
|
||||
if (day === null) return <div key={`empty-${i}`} className="min-h-[110px] border-r border-b border-border/40 bg-gradient-to-b from-muted/[0.01] to-transparent" />
|
||||
const dayEvents = getEventsForDay(day)
|
||||
const todayMark = isToday(day)
|
||||
const hasEvents = dayEvents.length > 0
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={cn(
|
||||
"min-h-[110px] p-1.5 border-r border-b border-border/40 cursor-pointer transition-all duration-150 relative group",
|
||||
"hover:bg-accent/20 hover:shadow-[inset_0_0_0_1px_hsl(var(--border))]",
|
||||
todayMark && "bg-gradient-to-b from-primary/[0.04] to-transparent",
|
||||
)}
|
||||
onClick={() => { setSelectedDate(new Date(currentYear, currentMonth, day)); openCreateDialog(day) }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1 px-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center h-6 w-6 text-xs font-medium rounded-full transition-all duration-200",
|
||||
todayMark && "bg-primary text-primary-foreground text-[11px] font-bold shadow-md ring-2 ring-primary/30 scale-110",
|
||||
!todayMark && "text-muted-foreground/70 group-hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
{hasEvents && (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-all">
|
||||
{dayEvents.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{dayEvents.slice(0, 3).map((event) => {
|
||||
const Icon = EVENT_ICONS[event.eventType]
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={(e) => { e.stopPropagation(); setDetailEvent(event) }}
|
||||
className={cn(
|
||||
"w-full text-left text-[10px] leading-tight flex items-center gap-1 rounded transition-all duration-150",
|
||||
"hover:shadow-sm hover:scale-[1.02] active:scale-[0.97] group/pill",
|
||||
event.status !== "scheduled" && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<div className="w-0.5 h-5 rounded-full shrink-0" style={eventDotStyle(event.eventType)} />
|
||||
<div style={eventBgStyle(event.eventType)}
|
||||
className="flex-1 flex items-center gap-1 px-1.5 py-0.5 rounded-r min-w-0"
|
||||
>
|
||||
<Icon className="h-2 w-2 shrink-0 opacity-50" />
|
||||
<span className="truncate font-medium">{event.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{dayEvents.length > 3 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation() }}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground pl-0.5 font-semibold transition-colors"
|
||||
>
|
||||
+{dayEvents.length - 3} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Upcoming sidebar */}
|
||||
<div className="w-72 lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
|
||||
<ArrowUpRight className="h-4 w-4 text-primary/70" />
|
||||
Upcoming
|
||||
</h2>
|
||||
{upcomingEvents.length > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] font-semibold px-2 py-0.5 ring-1 ring-border/50">
|
||||
{upcomingEvents.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Card className="p-6 border-dashed bg-card/50">
|
||||
<div className="flex flex-col items-center gap-2.5 text-center">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 ring-1 ring-border/20">
|
||||
<Calendar className="h-6 w-6 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">No upcoming events</p>
|
||||
<p className="text-[11px] text-muted-foreground/40 mt-0.5">Schedule one to get started</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => openCreateDialog()} className="hover:scale-105 active:scale-95 transition-all shadow-sm">
|
||||
<Plus className="h-3 w-3 mr-1.5" /> Schedule event
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{upcomingEvents.map((event) => {
|
||||
const Icon = EVENT_ICONS[event.eventType]
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => setDetailEvent(event)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg border bg-card/80 hover:bg-card transition-all duration-150 overflow-hidden",
|
||||
"hover:shadow-md hover:border-accent-foreground/10 active:scale-[0.98]",
|
||||
)}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="w-0.5 shrink-0" style={eventDotStyle(event.eventType)} />
|
||||
<div className="flex-1 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className={cn(
|
||||
"p-1.5 rounded shrink-0 ring-1 ring-border/20",
|
||||
event.userId === user?.id ? "bg-primary/5" : "bg-muted",
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
event.userId === user?.id ? "text-primary/60" : "text-muted-foreground/40",
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<p className="text-sm font-semibold truncate">{event.title}</p>
|
||||
<span className={cn(
|
||||
"text-[9px] font-semibold px-1.5 py-0.5 rounded capitalize shrink-0",
|
||||
event.status === "scheduled" && "bg-primary/10 text-primary",
|
||||
event.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
event.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
{event.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(event.startTime)}
|
||||
<span className="text-muted-foreground/20">·</span>
|
||||
{formatTime(event.startTime)}
|
||||
</div>
|
||||
{event.durationMinutes && (
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
|
||||
<Zap className="h-2.5 w-2.5" />
|
||||
{event.durationMinutes} min
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className={cn(
|
||||
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
|
||||
event.userId === user?.id
|
||||
? "bg-primary/10 text-primary ring-primary/20"
|
||||
: "bg-muted text-muted-foreground/60 ring-border",
|
||||
)}>
|
||||
{event.userId === user?.id
|
||||
? (event.participant?.name.charAt(0) || "?")
|
||||
: (event.creator?.name.charAt(0) || "?")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||
{event.userId === user?.id
|
||||
? (event.participant?.name || "No participant")
|
||||
: (event.creator?.name || "Unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"p-3 rounded-xl border shadow-sm",
|
||||
editingEvent
|
||||
? "bg-gradient-to-br from-muted to-muted/50 text-muted-foreground border-border"
|
||||
: "bg-gradient-to-br from-primary/15 to-primary/5 text-primary border-primary/20",
|
||||
)}>
|
||||
{editingEvent ? <Clock className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-bold">{editingEvent ? "Edit Event" : "Create Event"}</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
||||
{editingEvent ? "Update the event details below" : "Fill in the details for your new event"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-xs font-semibold">Title</Label>
|
||||
<Input id="title" value={formTitle} onChange={(e) => setFormTitle(e.target.value)} placeholder="e.g. Product demo with client" className="h-10" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
|
||||
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
|
||||
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
|
||||
<Select value={formType} onValueChange={(v: EventType) => setFormType(v)}>
|
||||
<SelectTrigger id="type" className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="meeting">Meeting</SelectItem>
|
||||
<SelectItem value="call">Call</SelectItem>
|
||||
<SelectItem value="chat">Video Chat</SelectItem>
|
||||
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||
<SelectItem value="demo">Demo</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
|
||||
<Select value={formDuration} onValueChange={setFormDuration}>
|
||||
<SelectTrigger id="duration" className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="15">15 min</SelectItem>
|
||||
<SelectItem value="30">30 min</SelectItem>
|
||||
<SelectItem value="45">45 min</SelectItem>
|
||||
<SelectItem value="60">1 hour</SelectItem>
|
||||
<SelectItem value="90">1.5 hours</SelectItem>
|
||||
<SelectItem value="120">2 hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="participant" className="text-xs font-semibold">Participant</Label>
|
||||
<Select value={formParticipantId || "__none__"} onValueChange={(v) => setFormParticipantId(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger id="participant" className="h-10">
|
||||
<SelectValue placeholder="Select a participant…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name} ({u.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
|
||||
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex items-center justify-between border-t pt-4">
|
||||
<div>
|
||||
{editingEvent && (
|
||||
<Button variant="destructive" size="sm" onClick={() => deleteEvent(editingEvent)} className="shadow-sm">
|
||||
Delete event
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={formSaving} className="shadow-sm">
|
||||
{formSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
{editingEvent ? "Update Event" : "Create Event"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Event Detail Dialog */}
|
||||
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
||||
{detailEvent && (
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
||||
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-5 w-5" /> : <Calendar className="h-5 w-5" /> })()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-lg font-bold truncate">{detailEvent.title}</DialogTitle>
|
||||
<span className={cn(
|
||||
"inline-flex text-[10px] font-semibold px-2 py-0.5 rounded-full capitalize mt-0.5 ring-1",
|
||||
detailEvent.status === "scheduled" && "bg-primary/10 text-primary ring-primary/20",
|
||||
detailEvent.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20",
|
||||
detailEvent.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400 ring-red-500/20",
|
||||
)}>
|
||||
{detailEvent.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Date / Time */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Date
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Time
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration + Type */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{detailEvent.durationMinutes && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Duration
|
||||
</div>
|
||||
<p className="text-sm font-bold">{detailEvent.durationMinutes} min</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 capitalize">
|
||||
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-3.5 w-3.5" /> : <Calendar className="h-3.5 w-3.5" /> })()}
|
||||
Type
|
||||
</div>
|
||||
<p className="text-sm font-bold capitalize">{detailEvent.eventType.replace("_", " ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{detailEvent.description && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked lead */}
|
||||
{detailEvent.lead && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Linked Lead</p>
|
||||
<p className="text-sm font-bold">{detailEvent.lead.contactName} — {detailEvent.lead.companyName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizer & Participant - cross-linked */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
{detailEvent.userId === user?.id ? "You (Organizer)" : "Organizer"}
|
||||
</p>
|
||||
<p className="text-sm font-bold flex items-center gap-2">
|
||||
<span className="h-6 w-6 rounded-full bg-muted text-muted-foreground text-[10px] flex items-center justify-center font-bold ring-1 ring-border">
|
||||
{detailEvent.creator?.name.charAt(0).toUpperCase() || "?"}
|
||||
</span>
|
||||
<span className="truncate">{detailEvent.creator?.name || "Unknown"}</span>
|
||||
{detailEvent.creator?.role && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.creator.role})</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-primary/5 to-primary/[0.02] border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
{detailEvent.participantId === user?.id ? "You (Participant)" : "Participant"}
|
||||
</p>
|
||||
{detailEvent.participant ? (
|
||||
<p className="text-sm font-bold flex items-center gap-2">
|
||||
<span className="h-6 w-6 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-bold ring-1 ring-primary/20">
|
||||
{detailEvent.participant.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{detailEvent.participant.name}</span>
|
||||
{detailEvent.participant.role && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.participant.role})</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/50 italic">No participant</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participant Notes (editable by both parties) */}
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<StickyNote className="h-3 w-3" />
|
||||
Notes
|
||||
</p>
|
||||
{!editNotes && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] font-semibold hover:bg-accent/40" onClick={() => { setNotesText(detailEvent.participantNotes || ""); setEditNotes(true) }}>
|
||||
{detailEvent.participantNotes ? "Edit" : "Add note"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editNotes ? (
|
||||
<div className="space-y-2.5">
|
||||
<Textarea value={notesText} onChange={(e) => setNotesText(e.target.value)} rows={3} className="resize-none text-sm" placeholder="Add your notes about this event…" />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditNotes(false)} disabled={notesSaving}>Cancel</Button>
|
||||
<Button size="sm" onClick={() => saveNotes(detailEvent)} disabled={notesSaving} className="shadow-sm">
|
||||
{notesSaving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : detailEvent.participantNotes ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed font-medium">{detailEvent.participantNotes}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/40 italic font-medium">No notes yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
||||
{detailEvent.status === "scheduled" && (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
|
||||
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setDetailEvent(null); openEditDialog(detailEvent) }}>
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{detailEvent.status === "completed" && (
|
||||
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "scheduled")}>
|
||||
<RotateCcw className="h-4 w-4 mr-1.5" /> Reopen
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user