Calender & Linked Added

This commit is contained in:
JCBSComputer
2026-06-26 16:38:18 +02:00
parent ffa595451e
commit 68483e9b60
35 changed files with 5042 additions and 118 deletions
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email
FROM users u
WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`,
[user.id],
)
const users = result.rows.map((r: any) => ({
id: r.id,
name: `${r.first_name} ${r.last_name}`,
email: r.email,
}))
return NextResponse.json({ users })
} catch (error) {
console.error("Event users error:", error)
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { buildIcs } from "@/lib/ics"
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1 AND e.user_id = $2`,
[id, user.id],
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const r = result.rows[0]
const icsContent = buildIcs({
uid: r.id,
title: r.title,
description: r.description || undefined,
startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : undefined,
durationMinutes: r.duration_minutes || undefined,
organizerName: `${r.user_first} ${r.user_last}`,
organizerEmail: r.user_email,
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
attendeeEmail: r.p_email || undefined,
})
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
},
})
} catch (error) {
console.error("ICS GET error:", error)
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventRescheduled } from "@/lib/email"
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const old = existing.rows[0]
const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id
if (!isCreator && !isParticipant) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
if (!isCreator && (
(title !== undefined && title !== old.title) ||
(eventType !== undefined && eventType !== old.event_type) ||
(startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id)
)) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
const result = await query(
`UPDATE scheduled_events SET
title = COALESCE($1, title),
description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time),
end_time = COALESCE($6, end_time),
duration_minutes = COALESCE($7, duration_minutes),
status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id),
updated_at = NOW()
WHERE id = $10
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
title || null,
description ?? null,
participantNotes ?? null,
eventType || null,
startTime || null,
endTime ?? null,
durationMinutes ?? null,
status || null,
participantId ?? null,
id,
],
user.id,
)
const r = result.rows[0]
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status
if (isChanged && r.participant_id && r.participant_id !== user.id) {
sendEventRescheduled({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
participantEmail: old.p_email || null,
title: r.title,
description: r.description || undefined,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time || undefined,
durationMinutes: r.duration_minutes || undefined,
}).catch((err) => console.error("Reschedule email error:", err))
}
const creatorResult = await query(
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1`,
[old.user_id],
)
const creatorInfo = creatorResult.rows[0]
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
participant: participantInfo,
lead: null,
createdAt: r.created_at,
},
})
} catch (error) {
console.error("Events PATCH error:", error)
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const existing = await query(
`SELECT user_id FROM scheduled_events WHERE id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
if (existing.rows[0].user_id !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Events DELETE error:", error)
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const start = searchParams.get("start")
const end = searchParams.get("end")
const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
const params: unknown[] = []
let idx = 1
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
params.push(user.id)
idx++
} else {
sql += ` WHERE 1=1`
}
if (start) {
sql += ` AND e.start_time >= $${idx}`
params.push(start)
idx++
}
if (end) {
sql += ` AND e.start_time <= $${idx}`
params.push(end)
idx++
}
if (status) {
sql += ` AND e.status = $${idx}`
params.push(status)
idx++
}
sql += " ORDER BY e.start_time ASC"
const result = await query(sql, params, user.id)
const events = result.rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
createdAt: r.created_at,
}))
return NextResponse.json({ events })
} catch (error) {
console.error("Events GET error:", error)
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const {
participantId, leadId, conversationId,
title, description, eventType,
startTime, endTime, durationMinutes,
} = await request.json()
if (!title || !startTime) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
}
const result = await query(
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
user.id,
participantId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "meeting",
startTime,
endTime || null,
durationMinutes || null,
],
user.id,
)
const r = result.rows[0]
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
participantId,
"event_scheduled",
`Meeting scheduled: ${title}`,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
participantEmail: participant?.email || null,
title,
description: description || undefined,
eventType: eventType || "meeting",
startTime,
endTime: endTime || undefined,
durationMinutes: durationMinutes || undefined,
}).catch((err) => console.error("Email error:", err))
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
createdAt: r.created_at,
},
}, { status: 201 })
} catch (error) {
console.error("Events POST error:", error)
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
}
}
+861
View File
@@ -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>
)
}
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS scheduled_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_minutes INT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration 013: Row-Level Security for scheduled_events
-- ============================================================================
-- Enables RLS on the scheduled_events table so that users can only see
-- their own events at the database level. This is defense-in-depth:
-- the API already filters by user_id, but RLS ensures data isolation
-- even if there's a bug in the application layer.
--
-- The policy allows all operations when app.current_user_id is NULL
-- (backward-compatible fallback for queries that don't set the variable).
-- When the variable IS set, only rows matching the user's ID are visible.
-- ============================================================================
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1,19 @@
-- ============================================================================
-- Migration 015: Fix RLS for scheduled_events to include participant_id
-- ============================================================================
-- Previously, the RLS policy only checked user_id, which meant participants
-- could not see events at the database level (the app-layer WHERE clause
-- did the filtering, but RLS was defense-in-depth that missed this case).
--
-- This policy also allows app.current_user_id IS NULL for backward compat
-- with queries that don't set the session variable.
-- ============================================================================
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR participant_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1 @@
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
+22
View File
@@ -0,0 +1,22 @@
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other"
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
export interface CalendarEvent {
id: string
userId: string
participantId: string | null
leadId: string | null
conversationId: string | null
title: string
description: string | null
participantNotes: string | null
eventType: EventType
startTime: string
endTime: string | null
durationMinutes: number | null
status: EventStatus
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null
createdAt: string
}
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS scheduled_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_minutes INT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS sent_emails (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
body_html TEXT,
body_text TEXT,
event_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sent_emails_recipient ON sent_emails(recipient);
CREATE INDEX IF NOT EXISTS idx_sent_emails_created ON sent_emails(created_at DESC);
+22
View File
@@ -0,0 +1,22 @@
-- ============================================================================
-- Migration 013: Row-Level Security for scheduled_events
-- ============================================================================
-- Enables RLS on the scheduled_events table so that users can only see
-- their own events at the database level. This is defense-in-depth:
-- the API already filters by user_id, but RLS ensures data isolation
-- even if there's a bug in the application layer.
--
-- The policy allows all operations when app.current_user_id is NULL
-- (backward-compatible fallback for queries that don't set the variable).
-- When the variable IS set, only rows matching the user's ID are visible.
-- ============================================================================
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1,5 @@
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS context_id UUID,
ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id);
@@ -0,0 +1,19 @@
-- ============================================================================
-- Migration 015: Fix RLS for scheduled_events to include participant_id
-- ============================================================================
-- Previously, the RLS policy only checked user_id, which meant participants
-- could not see events at the database level (the app-layer WHERE clause
-- did the filtering, but RLS was defense-in-depth that missed this case).
--
-- This policy also allows app.current_user_id IS NULL for backward compat
-- with queries that don't set the session variable.
-- ============================================================================
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
CREATE POLICY scheduled_events_user_policy ON scheduled_events
FOR ALL
USING (
user_id = current_setting('app.current_user_id', true)::uuid
OR participant_id = current_setting('app.current_user_id', true)::uuid
OR current_setting('app.current_user_id', true) IS NULL
);
@@ -0,0 +1 @@
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
+29
View File
@@ -34,5 +34,34 @@ BEGIN;
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\i 009_settings.sql
\echo '=== Running 010_chat_notifications.sql (Chat Notifications) ==='
\i 010_chat_notifications.sql
\echo '=== Running 010_facebook_accounts.sql (Facebook Accounts) ==='
\i 010_facebook_accounts.sql
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
\i 011_calendar_events.sql
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
\i 012_sent_emails.sql
\echo '=== Running 013_security_upgrade.sql (Security Architecture) ==='
\i 013_security_upgrade.sql
\echo '=== Running 013_rls_calendar.sql (Calendar RLS) ==='
\i 013_rls_calendar.sql
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
\i 014_bug_reports.sql
\echo '=== Running 014_notifications_context.sql (Notifications Context) ==='
\i 014_notifications_context.sql
\echo '=== Running 015_rls_calendar_participant.sql (Calendar RLS Participant Fix) ==='
\i 015_rls_calendar_participant.sql
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql
\echo '=== Migration Complete ==='
COMMIT;
+1670 -97
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -42,11 +42,14 @@
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"devenv": "^1.0.1",
"dotenv": "^17.4.2",
"framer-motion": "^11.15.0",
"jose": "^6.2.3",
"lucide-react": "^0.468.0",
"next": "15.0.4",
"next-themes": "^0.4.4",
"nodemailer": "^9.0.1",
"pg": "^8.21.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -61,12 +64,14 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"concurrently": "^10.0.3",
"eslint": "^9",
"eslint-config-next": "15.0.4",
"maildev": "^2.2.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
+861
View File
@@ -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>
)
}
+112 -1
View File
@@ -17,7 +17,7 @@ import {
import {
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download, Undo2,
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2,
} from "lucide-react"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
@@ -25,6 +25,9 @@ import {
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
import { useTheme } from "next-themes"
import { useUser } from "@/providers/user-provider"
import { toast } from "sonner"
@@ -157,6 +160,13 @@ export default function ChatsPage() {
const [searchingUsers, setSearchingUsers] = useState(false)
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false)
const [scheduleTitle, setScheduleTitle] = useState("")
const [scheduleDate, setScheduleDate] = useState("")
const [scheduleTime, setScheduleTime] = useState("")
const [scheduleDuration, setScheduleDuration] = useState("30")
const [scheduleType, setScheduleType] = useState<string>("meeting")
const [scheduleSaving, setScheduleSaving] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const emojiPickerRef = useRef<HTMLDivElement>(null)
@@ -846,6 +856,9 @@ const formatPreviewContent = (content: string) => {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
<CalendarDays className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
@@ -1228,6 +1241,104 @@ const formatPreviewContent = (content: string) => {
</ScrollArea>
</DialogContent>
</Dialog>
<<<<<<< Updated upstream
=======
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
{/* Schedule from Chat Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Schedule with {conversation ? otherParticipant(conversation).name : "..."}</DialogTitle>
<DialogDescription>Create an event and notify the participant</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="sched-title">Title</Label>
<Input id="sched-title" value={scheduleTitle} onChange={(e) => setScheduleTitle(e.target.value)} placeholder="e.g. Follow-up call" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sched-date">Date</Label>
<Input id="sched-date" type="date" value={scheduleDate} onChange={(e) => setScheduleDate(e.target.value)} />
</div>
<div>
<Label htmlFor="sched-time">Time</Label>
<Input id="sched-time" type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sched-type">Type</Label>
<Select value={scheduleType} onValueChange={setScheduleType}>
<SelectTrigger id="sched-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="sched-duration">Duration</Label>
<Select value={scheduleDuration} onValueChange={setScheduleDuration}>
<SelectTrigger id="sched-duration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">15 min</SelectItem>
<SelectItem value="30">30 min</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
<SelectItem value="90">1.5 hours</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setScheduleDialogOpen(false)}>Cancel</Button>
<Button disabled={scheduleSaving || !scheduleTitle || !scheduleDate || !scheduleTime} onClick={async () => {
if (!conversation || !user) return
setScheduleSaving(true)
try {
const startTime = new Date(`${scheduleDate}T${scheduleTime}:00`).toISOString()
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(scheduleDuration))
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: scheduleTitle,
eventType: scheduleType,
startTime,
endTime: end.toISOString(),
durationMinutes: parseInt(scheduleDuration),
participantId: otherParticipant(conversation).id,
conversationId: conversation.id,
}),
})
if (!res.ok) throw new Error("Failed")
toast.success("Event scheduled! Notification sent.")
setScheduleDialogOpen(false)
setScheduleTitle("")
setScheduleDate("")
setScheduleTime("")
} catch {
toast.error("Failed to schedule event")
} finally {
setScheduleSaving(false)
}
}}>
{scheduleSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Schedule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
>>>>>>> Stashed changes
</div>
)
}
+6
View File
@@ -118,6 +118,12 @@ export default function DashboardPage() {
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
{/* Daily Bugle watermark */}
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE
</span>
</div>
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { useUser } from "@/providers/user-provider"
import { Mail, Clock } from "lucide-react"
export default function EmailsPage() {
const router = useRouter()
const { user } = useUser()
const [emails, setEmails] = useState<any[]>([])
useEffect(() => {
if (!user) { router.push("/login"); return }
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || []))
}, [user, router])
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-4">Email Log</h1>
<p className="text-sm text-muted-foreground mb-6">
Emails are stored locally. To actually deliver them, add <code className="bg-muted px-1 rounded">EMAIL_API_KEY</code> to your .env.
</p>
{emails.length === 0 ? (
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
) : (
<div className="space-y-2">
{emails.map((e: any) => {
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
const withName = withMatch ? withMatch[1].trim() : null
return (
<div key={e.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card">
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{e.subject}</p>
<p className="text-xs text-muted-foreground">To: {e.recipient}</p>
{withName && <p className="text-xs text-muted-foreground/70 mt-0.5">With: {withName}</p>}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="h-3 w-3" />
{new Date(e.createdAt).toLocaleString()}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const isAdmin = user.role === "admin" || user.role === "super_admin"
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const result = await query(
`SELECT id, recipient, subject, body_text, created_at FROM sent_emails ORDER BY created_at DESC LIMIT 50`,
)
return NextResponse.json({ emails: result.rows.map((r: any) => ({
id: r.id,
recipient: r.recipient,
subject: r.subject,
bodyText: r.body_text,
createdAt: r.created_at,
})) })
} catch (error) {
console.error("Emails GET error:", error)
return NextResponse.json({ error: "Failed to load sent emails" }, { status: 500 })
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email
FROM users u
WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`,
[user.id],
)
const users = result.rows.map((r: any) => ({
id: r.id,
name: `${r.first_name} ${r.last_name}`,
email: r.email,
}))
return NextResponse.json({ users })
} catch (error) {
console.error("Event users error:", error)
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { buildIcs } from "@/lib/ics"
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1 AND e.user_id = $2`,
[id, user.id],
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const r = result.rows[0]
const icsContent = buildIcs({
uid: r.id,
title: r.title,
description: r.description || undefined,
startTime: new Date(r.start_time),
endTime: r.end_time ? new Date(r.end_time) : undefined,
durationMinutes: r.duration_minutes || undefined,
organizerName: `${r.user_first} ${r.user_last}`,
organizerEmail: r.user_email,
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
attendeeEmail: r.p_email || undefined,
})
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
},
})
} catch (error) {
console.error("ICS GET error:", error)
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
}
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventRescheduled } from "@/lib/email"
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
WHERE e.id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const old = existing.rows[0]
const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id
if (!isCreator && !isParticipant) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
if (!isCreator && (
(title !== undefined && title !== old.title) ||
(eventType !== undefined && eventType !== old.event_type) ||
(startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id)
)) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
const result = await query(
`UPDATE scheduled_events SET
title = COALESCE($1, title),
description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time),
end_time = COALESCE($6, end_time),
duration_minutes = COALESCE($7, duration_minutes),
status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id),
updated_at = NOW()
WHERE id = $10
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
title || null,
description ?? null,
participantNotes ?? null,
eventType || null,
startTime || null,
endTime ?? null,
durationMinutes ?? null,
status || null,
participantId ?? null,
id,
],
user.id,
)
const r = result.rows[0]
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status
if (isChanged && r.participant_id && r.participant_id !== user.id) {
sendEventRescheduled({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
participantEmail: old.p_email || null,
title: r.title,
description: r.description || undefined,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time || undefined,
durationMinutes: r.duration_minutes || undefined,
}).catch((err) => console.error("Reschedule email error:", err))
}
const creatorResult = await query(
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1`,
[old.user_id],
)
const creatorInfo = creatorResult.rows[0]
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
participant: participantInfo,
lead: null,
createdAt: r.created_at,
},
})
} catch (error) {
console.error("Events PATCH error:", error)
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const existing = await query(
`SELECT user_id FROM scheduled_events WHERE id = $1`,
[id],
user.id,
)
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
if (existing.rows[0].user_id !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Events DELETE error:", error)
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const start = searchParams.get("start")
const end = searchParams.get("end")
const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
const params: unknown[] = []
let idx = 1
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
params.push(user.id)
idx++
} else {
sql += ` WHERE 1=1`
}
if (start) {
sql += ` AND e.start_time >= $${idx}`
params.push(start)
idx++
}
if (end) {
sql += ` AND e.start_time <= $${idx}`
params.push(end)
idx++
}
if (status) {
sql += ` AND e.status = $${idx}`
params.push(status)
idx++
}
sql += " ORDER BY e.start_time ASC"
const result = await query(sql, params, user.id)
const events = result.rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
createdAt: r.created_at,
}))
return NextResponse.json({ events })
} catch (error) {
console.error("Events GET error:", error)
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const {
participantId, leadId, conversationId,
title, description, eventType,
startTime, endTime, durationMinutes,
} = await request.json()
if (!title || !startTime) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
}
const result = await query(
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
[
user.id,
participantId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "meeting",
startTime,
endTime || null,
durationMinutes || null,
],
user.id,
)
const r = result.rows[0]
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
participantId,
"event_scheduled",
`Meeting scheduled: ${title}`,
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
participantEmail: participant?.email || null,
title,
description: description || undefined,
eventType: eventType || "meeting",
startTime,
endTime: endTime || undefined,
durationMinutes: durationMinutes || undefined,
}).catch((err) => console.error("Email error:", err))
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
description: r.description,
participantNotes: r.participant_notes,
eventType: r.event_type,
startTime: r.start_time,
endTime: r.end_time,
durationMinutes: r.duration_minutes,
status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
createdAt: r.created_at,
},
}, { status: 201 })
} catch (error) {
console.error("Events POST error:", error)
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
}
}
+24 -17
View File
@@ -90,18 +90,24 @@
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 220 14% 85%;
--input: 220 14% 85%;
--ring: 120 100% 50%;
--sidebar: 220 14% 74%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 0 100% 40%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 145 65% 50%;
--sidebar-primary: 0 100% 40%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 96%;
--sidebar-accent: 210 40% 96.1%;
--sidebar-accent-foreground: 222.2 84% 4.9%;
--sidebar-border: 220 9% 46%;
--sidebar-ring: 120 100% 50%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 0 100% 40%;
--radius: 0.5rem;
--event-meeting: 217 91% 55%;
--event-call: 160 84% 39%;
--event-chat: 271 81% 53%;
--event-follow_up: 38 92% 48%;
--event-demo: 346 77% 48%;
--event-other: 215 20% 55%;
}
.dark {
@@ -132,6 +138,7 @@
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.ocean {
@@ -152,7 +159,7 @@
--primary: 187 75% 50%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 187 75% 50%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 187 75% 55%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -180,7 +187,7 @@
--primary: 142 76% 44%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 142 76% 44%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 142 76% 50%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -208,7 +215,7 @@
--primary: 24 95% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 24 95% 58%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 24 95% 65%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -236,7 +243,7 @@
--primary: 230 75% 62%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 230 75% 62%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 230 75% 65%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -264,7 +271,7 @@
--primary: 346 77% 58%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 346 77% 58%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 346 77% 60%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -292,7 +299,7 @@
--primary: 38 92% 56%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 38 92% 56%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 38 92% 60%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -320,7 +327,7 @@
--primary: 262 83% 65%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 262 83% 65%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 262 83% 68%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -348,7 +355,7 @@
--primary: 215 20% 60%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 215 20% 60%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 215 20% 65%;
--sidebar-primary-foreground: 0 0% 100%;
@@ -376,7 +383,7 @@
--primary: 351 85% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 351 85% 52%;
--sidebar: 0 0% 11%;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 351 85% 55%;
--sidebar-primary-foreground: 0 0% 100%;
+45 -1
View File
@@ -34,7 +34,51 @@ export function AppShell({ children }: AppShellProps) {
}
return (
<div className="min-h-screen bg-background dark:bg-[#0A0A0A] relative overflow-hidden">
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
style={{
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
backgroundSize: "28px 28px, 14px 14px",
backgroundPosition: "0 0, 7px 7px",
}}
>
{/* Spider-Man top gradient bar */}
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
{/* Corner spider webs */}
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
<SystemMonitor />
<Sidebar
+2
View File
@@ -19,6 +19,7 @@ import {
MessageSquare,
Bot,
Facebook,
Calendar,
} from "lucide-react"
import { COMPANY_NAME } from "@/lib/constants"
import { useUser } from "@/providers/user-provider"
@@ -28,6 +29,7 @@ import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/leads", label: "Leads", icon: Users },
{ href: "/calendar", label: "Calendar", icon: Calendar },
{ href: "/chats", label: "Chats", icon: MessageSquare },
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
{ href: "/users", label: "Users", icon: Building2 },
+4 -1
View File
@@ -16,9 +16,12 @@ pool.on("error", (err) => {
console.error("Unexpected database pool error:", err)
})
export async function query(text: string, params?: unknown[]) {
export async function query(text: string, params?: unknown[], userId?: string) {
const client = await pool.connect()
try {
if (userId) {
await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId])
}
const result = await client.query(text, params)
return result
} finally {
+190
View File
@@ -0,0 +1,190 @@
import nodemailer from "nodemailer"
import { query } from "@/lib/db"
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || ""
const SMTP_HOST = process.env.SMTP_HOST || ""
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587", 10)
const SMTP_USER = process.env.SMTP_USER || ""
const SMTP_PASS = process.env.SMTP_PASS || ""
const EMAIL_FROM = process.env.EMAIL_FROM || "noreply@coastit.co.za"
function createTransporter() {
const hasAuth = !!(SMTP_USER && SMTP_PASS)
const isLocal = SMTP_HOST === "127.0.0.1" || SMTP_HOST === "localhost"
const tlsOptions = hasAuth && !isLocal
? { rejectUnauthorized: true }
: { rejectUnauthorized: false }
return nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
requireTLS: hasAuth && !isLocal,
...(hasAuth ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}),
tls: tlsOptions,
connectionTimeout: 5000,
greetingTimeout: 5000,
})
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
weekday: "long", month: "long", day: "numeric", year: "numeric",
})
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString("en-US", {
hour: "numeric", minute: "2-digit", hour12: true,
})
}
function formatDuration(min: number) {
if (min < 60) return `${min} minutes`
const h = Math.floor(min / 60)
const m = min % 60
return m > 0 ? `${h}h ${m}m` : `${h} hour${h > 1 ? "s" : ""}`
}
export interface EventConfirmationInput {
creatorName: string
creatorEmail: string
participantName: string | null
participantEmail: string | null
title: string
description?: string
eventType: string
startTime: string
endTime?: string
durationMinutes?: number
}
function buildEmail(event: EventConfirmationInput, variant: "scheduled" | "rescheduled" = "scheduled") {
const dateStr = formatDate(event.startTime)
const timeStr = formatTime(event.startTime)
const durStr = event.durationMinutes ? formatDuration(event.durationMinutes) : ""
const typeLabel = event.eventType.charAt(0).toUpperCase() + event.eventType.slice(1)
const heading = variant === "rescheduled" ? "Event Rescheduled" : "Event Scheduled"
const badge = variant === "rescheduled"
? '<div style="background:#fef3c7;color:#92400e;font-size:12px;padding:6px 12px;border-radius:6px;margin-bottom:16px;display:inline-block">This event has been rescheduled</div>'
: ""
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f4f5;padding:40px 20px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)">
<div style="background:linear-gradient(135deg,#CC0000,#0033CC);padding:24px 32px">
<h1 style="color:#fff;margin:0;font-size:20px">${heading}</h1>
</div>
<div style="padding:32px">
${badge}
<p style="font-size:15px;margin:0 0 16px"><strong>${event.title}</strong></p>
<table style="width:100%;font-size:13px;border-collapse:collapse">
<tr><td style="padding:6px 0;color:#666;width:90px">Type</td><td style="padding:6px 0">${typeLabel}</td></tr>
<tr><td style="padding:6px 0;color:#666">Date</td><td style="padding:6px 0">${dateStr}</td></tr>
<tr><td style="padding:6px 0;color:#666">Time</td><td style="padding:6px 0">${timeStr}</td></tr>
${durStr ? `<tr><td style="padding:6px 0;color:#666">Duration</td><td style="padding:6px 0">${durStr}</td></tr>` : ""}
${event.description ? `<tr><td style="padding:6px 0;color:#666;vertical-align:top">Notes</td><td style="padding:6px 0">${event.description}</td></tr>` : ""}
<tr><td style="padding:6px 0;color:#666">Created by</td><td style="padding:6px 0">${event.creatorName}</td></tr>
${event.participantName ? `<tr><td style="padding:6px 0;color:#666">With</td><td style="padding:6px 0">${event.participantName}</td></tr>` : ""}
</table>
<p style="font-size:11px;color:#999;margin-top:24px;padding-top:16px;border-top:1px solid #eee">Automated confirmation from CRM.</p>
</div>
</div>
</body></html>`
const text = `${variant === "rescheduled" ? "[RESCHEDULED] " : ""}Event: ${event.title}
Type: ${typeLabel}
Date: ${dateStr}
Time: ${timeStr}
${durStr ? `Duration: ${durStr}` : ""}
${event.description ? `Notes: ${event.description}` : ""}
Created by: ${event.creatorName}
${event.participantName ? `With: ${event.participantName}` : ""}`
const prefix = variant === "rescheduled" ? "🔄 Rescheduled: " : ""
const subject = `${prefix}${event.title}${dateStr}`
return { html, text, subject, dateStr }
}
export async function sendEventConfirmation(event: EventConfirmationInput) {
const { html, text, subject } = buildEmail(event)
const recipients: string[] = []
if (event.creatorEmail) recipients.push(event.creatorEmail)
if (event.participantEmail && !recipients.includes(event.participantEmail)) recipients.push(event.participantEmail)
if (ADMIN_EMAIL && !recipients.includes(ADMIN_EMAIL)) recipients.push(ADMIN_EMAIL)
// 1. Always store in database
for (const to of recipients) {
await query(
`INSERT INTO sent_emails (recipient, subject, body_html, body_text)
VALUES ($1, $2, $3, $4)`,
[to, subject, html, text],
).catch(() => {})
}
if (recipients.length === 0) return
// 2. Send via SMTP if configured
if (SMTP_HOST) {
const transporter = createTransporter()
await transporter.sendMail({
from: EMAIL_FROM,
to: recipients.join(", "),
subject,
text,
html,
}).catch(() => {})
return
}
// 3. Fallback: log to console with instructions
console.log("=".repeat(60))
console.log(`📧 Email ready — ${subject}`)
console.log(` To: ${recipients.join(", ")}`)
console.log(` Body:\n${text}`)
console.log("=".repeat(60))
console.log("💡 To send real emails:")
console.log(" Fill in SMTP_HOST in .env (maildev works at localhost:1025 with no auth)")
console.log(" Or set SMTP_HOST/SMTP_USER/SMTP_PASS for authenticated relays")
}
export async function sendEventRescheduled(event: EventConfirmationInput) {
const { html, text, subject } = buildEmail(event, "rescheduled")
const recipients: string[] = []
if (event.creatorEmail) recipients.push(event.creatorEmail)
if (event.participantEmail && !recipients.includes(event.participantEmail)) recipients.push(event.participantEmail)
if (ADMIN_EMAIL && !recipients.includes(ADMIN_EMAIL)) recipients.push(ADMIN_EMAIL)
for (const to of recipients) {
await query(
`INSERT INTO sent_emails (recipient, subject, body_html, body_text)
VALUES ($1, $2, $3, $4)`,
[to, subject, html, text],
).catch(() => {})
}
if (recipients.length === 0) return
if (SMTP_HOST) {
const transporter = createTransporter()
await transporter.sendMail({
from: EMAIL_FROM,
to: recipients.join(", "),
subject,
text,
html,
}).catch(() => {})
return
}
console.log("=".repeat(60))
console.log(`🔄 Reschedule email ready — ${subject}`)
console.log(` To: ${recipients.join(", ")}`)
console.log(` Body:\n${text}`)
console.log("=".repeat(60))
}
+80
View File
@@ -0,0 +1,80 @@
export interface IcsEvent {
uid: string
title: string
description?: string
location?: string
startTime: Date
endTime?: Date
durationMinutes?: number
organizerName?: string
organizerEmail?: string
attendeeName?: string
attendeeEmail?: string
}
function formatIcsDate(d: Date): string {
return d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
}
function escapeIcsText(text: string): string {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n")
}
export function buildIcs(event: IcsEvent): string {
const now = formatIcsDate(new Date())
const dtStart = formatIcsDate(event.startTime)
let dtEnd: string
if (event.endTime) {
dtEnd = formatIcsDate(event.endTime)
} else if (event.durationMinutes) {
const end = new Date(event.startTime.getTime() + event.durationMinutes * 60000)
dtEnd = formatIcsDate(end)
} else {
const end = new Date(event.startTime.getTime() + 60 * 60000)
dtEnd = formatIcsDate(end)
}
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CRM//Calendar//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${event.uid}`,
`DTSTAMP:${now}`,
`DTSTART:${dtStart}`,
`DTEND:${dtEnd}`,
`SUMMARY:${escapeIcsText(event.title)}`,
]
if (event.description) {
lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`)
}
if (event.organizerEmail) {
const cn = event.organizerName ? `;CN=${escapeIcsText(event.organizerName)}` : ""
lines.push(`ORGANIZER${cn}:mailto:${event.organizerEmail}`)
}
if (event.attendeeEmail) {
const cn = event.attendeeName ? `;CN=${escapeIcsText(event.attendeeName)}` : ""
lines.push(`ATTENDEE${cn};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION:mailto:${event.attendeeEmail}`)
}
lines.push("STATUS:CONFIRMED")
lines.push("BEGIN:VALARM")
lines.push("TRIGGER:-PT15M")
lines.push("ACTION:DISPLAY")
lines.push(`DESCRIPTION:Reminder: ${escapeIcsText(event.title)}`)
lines.push("END:VALARM")
lines.push("END:VEVENT")
lines.push("END:VCALENDAR")
return lines.join("\r\n")
}
+25 -1
View File
@@ -77,7 +77,8 @@ export type NotificationType =
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added";
| "note_added"
| "event_scheduled";
export interface Notification {
id: string;
@@ -107,3 +108,26 @@ export interface Conversation {
unread: number;
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
description: string | null;
participantNotes: string | null;
eventType: EventType;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
createdAt: string;
}