mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 03:05:43 +02:00
Calender & Linked Added
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user