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