mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 11:15:43 +02:00
1013 lines
52 KiB
TypeScript
1013 lines
52 KiB
TypeScript
"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,
|
|
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
|
ArrowUpRight, Zap, StickyNote, Globe,
|
|
} from "lucide-react"
|
|
|
|
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
|
call: Phone,
|
|
follow_up: Clock,
|
|
website_creation: Globe,
|
|
}
|
|
|
|
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; role: string }[]>([])
|
|
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
|
|
const [formTitle, setFormTitle] = useState("")
|
|
const [formDescription, setFormDescription] = useState("")
|
|
const [formType, setFormType] = useState<EventType>("website_creation")
|
|
const [formDate, setFormDate] = useState("")
|
|
const [formTime, setFormTime] = useState("")
|
|
const [formDuration, setFormDuration] = useState("30")
|
|
const [formParticipantId, setFormParticipantId] = useState("")
|
|
const [formDeveloperId, setFormDeveloperId] = useState("")
|
|
const [formLeadId, setFormLeadId] = useState("")
|
|
const [formClientName, setFormClientName] = useState("")
|
|
const [formClientEmail, setFormClientEmail] = useState("")
|
|
const [formClientPhone, setFormClientPhone] = 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(() => {})
|
|
fetch("/api/leads?limit=200")
|
|
.then((r) => r.json())
|
|
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
|
|
.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 && 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("website_creation")
|
|
setFormDuration("30")
|
|
setFormParticipantId("")
|
|
setFormDeveloperId("")
|
|
setFormLeadId("")
|
|
setFormClientName("")
|
|
setFormClientEmail("")
|
|
setFormClientPhone("")
|
|
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(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
|
|
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
|
|
setFormParticipantId(event.participantId || "")
|
|
setFormDeveloperId(event.developerId || "")
|
|
setFormLeadId(event.leadId || "")
|
|
setFormClientName(event.clientName || "")
|
|
setFormClientEmail(event.clientEmail || "")
|
|
setFormClientPhone(event.clientPhone || "")
|
|
setDialogOpen(true)
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!formTitle.trim() || !formDate || !formTime) {
|
|
toast.error("Title, date, and time are required")
|
|
return
|
|
}
|
|
|
|
setFormSaving(true)
|
|
try {
|
|
const body: Record<string, unknown> = {
|
|
title: formTitle.trim(),
|
|
description: formDescription.trim() || null,
|
|
eventType: formType,
|
|
}
|
|
|
|
let startTime: string | undefined
|
|
if (formType !== "website_creation") {
|
|
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()
|
|
}
|
|
body.startTime = startTime
|
|
body.endTime = endTime
|
|
body.durationMinutes = formDuration ? parseInt(formDuration) : null
|
|
} else if (formDate) {
|
|
startTime = new Date(`${formDate}T00:00:00`).toISOString()
|
|
body.startTime = startTime
|
|
}
|
|
|
|
if (formClientName) body.clientName = formClientName
|
|
if (formClientEmail) body.clientEmail = formClientEmail
|
|
if (formClientPhone) body.clientPhone = formClientPhone
|
|
if (formParticipantId) body.participantId = formParticipantId
|
|
if (formDeveloperId) body.developerId = formDeveloperId
|
|
if (formLeadId) body.leadId = formLeadId
|
|
|
|
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, developer: e.developer, lead: e.lead } : e))
|
|
toast.success("Event updated")
|
|
} else {
|
|
setEvents((prev) => [...prev, data.event])
|
|
if (startTime) {
|
|
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, developer: e.developer, 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>
|
|
{event.lead && (
|
|
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
|
|
{event.lead.contactName}
|
|
</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>
|
|
{/* Unscheduled projects bar */}
|
|
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
|
|
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
|
|
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
|
|
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
|
|
</span>
|
|
</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>
|
|
{event.startTime ? (
|
|
<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>
|
|
) : (
|
|
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
|
|
<Globe className="h-3 w-3" />
|
|
Website project — no schedule
|
|
</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">
|
|
{event.developerId === user?.id ? (
|
|
<>
|
|
<span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
|
|
{event.creator?.name.charAt(0) || "?"}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
|
Assigned by {event.creator?.name || "Unknown"}
|
|
</span>
|
|
</>
|
|
) : event.lead ? (
|
|
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
|
{event.lead.contactName}{event.lead.companyName ? ` — ${event.lead.companyName}` : ""}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create/Edit Dialog */}
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
|
|
<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={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
|
<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>
|
|
{formType === "website_creation" && (
|
|
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
|
|
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
|
|
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
|
|
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
|
|
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" 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="website_creation">Website Creation</SelectItem>
|
|
<SelectItem value="call">Call</SelectItem>
|
|
<SelectItem value="follow_up">Follow Up</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={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
|
<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="developer" className="text-xs font-semibold">Assign Developer</Label>
|
|
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
|
|
<SelectTrigger id="developer" className="h-10">
|
|
<SelectValue placeholder="Select a developer…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">None</SelectItem>
|
|
{users.map((u) => (
|
|
<SelectItem key={u.id} value={u.id}>
|
|
{u.name} {u.role ? `(${u.role})` : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
|
|
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
|
|
<SelectTrigger id="lead" className="h-10">
|
|
<SelectValue placeholder="Select a client lead…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">None</SelectItem>
|
|
{leads.map((l) => (
|
|
<SelectItem key={l.id} value={l.id}>
|
|
{l.contactName}{l.companyName ? ` — ${l.companyName}` : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
|
|
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." 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] max-h-[90vh] overflow-y-auto">
|
|
<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 */}
|
|
{detailEvent.startTime ? (
|
|
<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>
|
|
) : (
|
|
<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">Schedule</p>
|
|
<p className="text-sm text-muted-foreground/50 italic">No scheduled time — website creation project</p>
|
|
</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">Project Details</p>
|
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Client details */}
|
|
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
|
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[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" />
|
|
Client Details
|
|
</p>
|
|
<div className="space-y-1">
|
|
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
|
|
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
|
|
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
|
|
</div>
|
|
</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>
|
|
|
|
{/* Developer */}
|
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[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.developerId === user?.id ? "You (Developer)" : "Developer"}
|
|
</p>
|
|
{detailEvent.developer ? (
|
|
<p className="text-sm font-bold flex items-center gap-2">
|
|
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
|
|
{detailEvent.developer.name.charAt(0).toUpperCase()}
|
|
</span>
|
|
<span className="truncate">{detailEvent.developer.name}</span>
|
|
{detailEvent.developer.role && (
|
|
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
|
|
)}
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
|
|
)}
|
|
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
|
|
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
|
|
Assigned by {detailEvent.creator?.name || "Unknown"}
|
|
</p>
|
|
)}
|
|
</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" && (
|
|
<>
|
|
{detailEvent.developerId === user?.id ? (
|
|
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
|
|
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
|
|
</Button>
|
|
) : (
|
|
<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>
|
|
)
|
|
}
|