From 68483e9b605fc3ef4f96cfad13bc7a62f5080ea2 Mon Sep 17 00:00:00 2001 From: JCBSComputer Date: Fri, 26 Jun 2026 16:38:18 +0200 Subject: [PATCH] Calender & Linked Added --- Web_Calendar/api/event-users/route.ts | 29 + Web_Calendar/api/events/[id]/ics/route.ts | 54 + Web_Calendar/api/events/[id]/route.ts | 167 ++ Web_Calendar/api/events/route.ts | 188 ++ Web_Calendar/calendar/page.tsx | 861 ++++++++ Web_Calendar/database/011_calendar_events.sql | 24 + Web_Calendar/database/013_rls_calendar.sql | 22 + .../database/015_rls_calendar_participant.sql | 19 + .../database/016_participant_notes.sql | 1 + Web_Calendar/types.ts | 22 + database/migrations/011_calendar_events.sql | 24 + database/migrations/012_sent_emails.sql | 12 + database/migrations/013_rls_calendar.sql | 22 + .../migrations/014_notifications_context.sql | 5 + .../015_rls_calendar_participant.sql | 19 + database/migrations/016_participant_notes.sql | 1 + database/migrations/run_all.sql | 29 + package-lock.json | 1767 ++++++++++++++++- package.json | 5 + src/app/(dashboard)/calendar/page.tsx | 861 ++++++++ src/app/(dashboard)/chats/page.tsx | 113 +- src/app/(dashboard)/dashboard/page.tsx | 6 + src/app/(dashboard)/emails/page.tsx | 53 + src/app/api/emails/route.ts | 28 + src/app/api/event-users/route.ts | 29 + src/app/api/events/[id]/ics/route.ts | 54 + src/app/api/events/[id]/route.ts | 167 ++ src/app/api/events/route.ts | 188 ++ src/app/globals.css | 41 +- src/components/layout/app-shell.tsx | 46 +- src/components/layout/sidebar.tsx | 2 + src/lib/db.ts | 5 +- src/lib/email.ts | 190 ++ src/lib/ics.ts | 80 + src/types/index.ts | 26 +- 35 files changed, 5042 insertions(+), 118 deletions(-) create mode 100644 Web_Calendar/api/event-users/route.ts create mode 100644 Web_Calendar/api/events/[id]/ics/route.ts create mode 100644 Web_Calendar/api/events/[id]/route.ts create mode 100644 Web_Calendar/api/events/route.ts create mode 100644 Web_Calendar/calendar/page.tsx create mode 100644 Web_Calendar/database/011_calendar_events.sql create mode 100644 Web_Calendar/database/013_rls_calendar.sql create mode 100644 Web_Calendar/database/015_rls_calendar_participant.sql create mode 100644 Web_Calendar/database/016_participant_notes.sql create mode 100644 Web_Calendar/types.ts create mode 100644 database/migrations/011_calendar_events.sql create mode 100644 database/migrations/012_sent_emails.sql create mode 100644 database/migrations/013_rls_calendar.sql create mode 100644 database/migrations/014_notifications_context.sql create mode 100644 database/migrations/015_rls_calendar_participant.sql create mode 100644 database/migrations/016_participant_notes.sql create mode 100644 src/app/(dashboard)/calendar/page.tsx create mode 100644 src/app/(dashboard)/emails/page.tsx create mode 100644 src/app/api/emails/route.ts create mode 100644 src/app/api/event-users/route.ts create mode 100644 src/app/api/events/[id]/ics/route.ts create mode 100644 src/app/api/events/[id]/route.ts create mode 100644 src/app/api/events/route.ts create mode 100644 src/lib/email.ts create mode 100644 src/lib/ics.ts diff --git a/Web_Calendar/api/event-users/route.ts b/Web_Calendar/api/event-users/route.ts new file mode 100644 index 0000000..88a0e00 --- /dev/null +++ b/Web_Calendar/api/event-users/route.ts @@ -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 }) + } +} diff --git a/Web_Calendar/api/events/[id]/ics/route.ts b/Web_Calendar/api/events/[id]/ics/route.ts new file mode 100644 index 0000000..3b99bbe --- /dev/null +++ b/Web_Calendar/api/events/[id]/ics/route.ts @@ -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 }) + } +} diff --git a/Web_Calendar/api/events/[id]/route.ts b/Web_Calendar/api/events/[id]/route.ts new file mode 100644 index 0000000..93f0472 --- /dev/null +++ b/Web_Calendar/api/events/[id]/route.ts @@ -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 }) + } +} diff --git a/Web_Calendar/api/events/route.ts b/Web_Calendar/api/events/route.ts new file mode 100644 index 0000000..391c522 --- /dev/null +++ b/Web_Calendar/api/events/route.ts @@ -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 }) + } +} diff --git a/Web_Calendar/calendar/page.tsx b/Web_Calendar/calendar/page.tsx new file mode 100644 index 0000000..c1e8783 --- /dev/null +++ b/Web_Calendar/calendar/page.tsx @@ -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 = { + 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([]) + const [upcomingEvents, setUpcomingEvents] = useState([]) + 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(null) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingEvent, setEditingEvent] = useState(null) + const [detailEvent, setDetailEvent] = useState(null) + + const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([]) + const [formTitle, setFormTitle] = useState("") + const [formDescription, setFormDescription] = useState("") + const [formType, setFormType] = useState("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 = { + 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 ( +
+ {/* Header */} +
+
+
+
+ +
+

Calendar

+
+
+ + + {MONTHS[currentMonth]} {currentYear} + + +
+ +
+ +
+ + {/* Body - flex row with independent scroll */} +
+ {loading ? ( +
+
+
+ +
+
+

Loading calendar...

+
+
+ ) : ( + <> + {/* Left: Calendar Grid */} +
+ {/* Stats bar */} +
+
+
+ {events.filter(e => e.status === "scheduled").length} + scheduled +
+
+
+ {events.filter(e => e.status === "completed").length} + completed +
+
+
+ {events.length} + total +
+
+ + {/* Calendar grid wrapper - scrollable */} +
+
+ {/* Day headers */} +
+ {DAYS.map((d, i) => ( +
+ {d} +
+ ))} +
+ + {/* Day cells */} +
+ {calendarDays.map((day, i) => { + if (day === null) return
+ const dayEvents = getEventsForDay(day) + const todayMark = isToday(day) + const hasEvents = dayEvents.length > 0 + return ( +
{ setSelectedDate(new Date(currentYear, currentMonth, day)); openCreateDialog(day) }} + > +
+ + {day} + + {hasEvents && ( + + {dayEvents.length} + + )} +
+ +
+ {dayEvents.slice(0, 3).map((event) => { + const Icon = EVENT_ICONS[event.eventType] + return ( + + ) + })} + {dayEvents.length > 3 && ( + + )} +
+
+ ) + })} +
+
+
+
+ + {/* Right: Upcoming sidebar */} +
+
+

+ + Upcoming +

+ {upcomingEvents.length > 0 && ( + + {upcomingEvents.length} + + )} +
+ + {upcomingEvents.length === 0 ? ( + +
+
+ +
+
+

No upcoming events

+

Schedule one to get started

+
+ +
+
+ ) : ( +
+ {upcomingEvents.map((event) => { + const Icon = EVENT_ICONS[event.eventType] + return ( + + ) + })} +
+ )} +
+ + )} +
+ + {/* Create/Edit Dialog */} + + + +
+
+ {editingEvent ? : } +
+
+ {editingEvent ? "Edit Event" : "Create Event"} +

+ {editingEvent ? "Update the event details below" : "Fill in the details for your new event"} +

+
+
+
+
+
+ + setFormTitle(e.target.value)} placeholder="e.g. Product demo with client" className="h-10" /> +
+
+
+ + setFormDate(e.target.value)} className="h-10" /> +
+
+ + setFormTime(e.target.value)} className="h-10" /> +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +