This commit is contained in:
2026-06-29 10:42:58 +02:00
30 changed files with 735 additions and 329 deletions
+225 -74
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone, ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react" } from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = { const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone, call: Phone,
chat: Video,
follow_up: Clock, follow_up: Clock,
demo: Calendar, website_creation: Globe,
other: Calendar,
} }
function eventVars(type: EventType) { function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null) const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null) const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([]) const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("") const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("") const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting") const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("") const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("") const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30") const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("") const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false) const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false) const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("") const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users || [])) .then((data) => setUsers(data.users || []))
.catch(() => {}) .catch(() => {})
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
}, [user, router]) }, [user, router])
useEffect(() => { useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => { const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr)) return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth]) }, [events, currentYear, currentMonth])
const isToday = (day: number) => { const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null) setEditingEvent(null)
setFormTitle("") setFormTitle("")
setFormDescription("") setFormDescription("")
setFormType("meeting") setFormType("website_creation")
setFormDuration("30") setFormDuration("30")
setFormParticipantId("") setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date() const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0]) setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00") setFormTime("10:00")
@@ -184,9 +196,14 @@ export default function CalendarPage() {
setFormDescription(event.description || "") setFormDescription(event.description || "")
setFormType(event.eventType) setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30)) setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0]) setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })) setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "") setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true) setDialogOpen(true)
} }
@@ -198,23 +215,35 @@ export default function CalendarPage() {
setFormSaving(true) setFormSaving(true)
try { 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> = { const body: Record<string, unknown> = {
title: formTitle.trim(), title: formTitle.trim(),
description: formDescription.trim() || null, description: formDescription.trim() || null,
eventType: formType, eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
} }
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res let res
if (editingEvent) { if (editingEvent) {
@@ -235,11 +264,13 @@ export default function CalendarPage() {
const data = await res.json() const data = await res.json()
if (editingEvent) { if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated") toast.success("Event updated")
} else { } else {
setEvents((prev) => [...prev, data.event]) setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar") if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created") toast.success("Event created")
} }
@@ -260,7 +291,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to update") if (!res.ok) throw new Error("Failed to update")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`) toast.success(`Event ${newStatus}`)
setDetailEvent(null) setDetailEvent(null)
} catch { } catch {
@@ -446,6 +477,11 @@ export default function CalendarPage() {
> >
<Icon className="h-2 w-2 shrink-0 opacity-50" /> <Icon className="h-2 w-2 shrink-0 opacity-50" />
<span className="truncate font-medium">{event.title}</span> <span className="truncate font-medium">{event.title}</span>
{event.lead && (
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
{event.lead.contactName}
</span>
)}
</div> </div>
</button> </button>
) )
@@ -464,6 +500,15 @@ export default function CalendarPage() {
})} })}
</div> </div>
</div> </div>
{/* Unscheduled projects bar */}
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
</span>
</div>
)}
</div> </div>
</div> </div>
@@ -534,12 +579,19 @@ export default function CalendarPage() {
{event.status} {event.status}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium"> {event.startTime ? (
<Clock className="h-3 w-3" /> <div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
{formatDate(event.startTime)} <Clock className="h-3 w-3" />
<span className="text-muted-foreground/20">·</span> {formatDate(event.startTime)}
{formatTime(event.startTime)} <span className="text-muted-foreground/20">·</span>
</div> {formatTime(event.startTime)}
</div>
) : (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
<Globe className="h-3 w-3" />
Website project no schedule
</div>
)}
{event.durationMinutes && ( {event.durationMinutes && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium"> <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" /> <Zap className="h-2.5 w-2.5" />
@@ -547,21 +599,20 @@ export default function CalendarPage() {
</div> </div>
)} )}
<div className="flex items-center gap-1 mt-1"> <div className="flex items-center gap-1 mt-1">
<span className={cn( {event.developerId === user?.id ? (
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1", <>
event.userId === user?.id <span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
? "bg-primary/10 text-primary ring-primary/20" {event.creator?.name.charAt(0) || "?"}
: "bg-muted text-muted-foreground/60 ring-border", </span>
)}> <span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id Assigned by {event.creator?.name || "Unknown"}
? (event.participant?.name.charAt(0) || "?") </span>
: (event.creator?.name.charAt(0) || "?")} </>
</span> ) : event.lead ? (
<span className="text-[10px] text-muted-foreground/60 font-medium"> <span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id {event.lead.contactName}{event.lead.companyName ? `${event.lead.companyName}` : ""}
? (event.participant?.name || "No participant") </span>
: (event.creator?.name || "Unknown")} ) : null}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -579,7 +630,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]"> <DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn( <div className={cn(
@@ -608,11 +659,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label> <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" /> <Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div> </div>
<div className="space-y-2"> <div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label> <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" /> <Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div> </div>
</div> </div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label> <Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +689,16 @@ export default function CalendarPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="meeting">Meeting</SelectItem> <SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem> <SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem> <SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label> <Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}> <Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10"> <SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -664,8 +729,40 @@ export default function CalendarPage() {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label> <Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" /> <Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." rows={3} className="resize-none" />
</div> </div>
</div> </div>
<DialogFooter className="flex items-center justify-between border-t pt-4"> <DialogFooter className="flex items-center justify-between border-t pt-4">
@@ -690,7 +787,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */} {/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}> <Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && ( {detailEvent && (
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}> <div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +808,29 @@ export default function CalendarPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Date / Time */} {/* Date / Time */}
<div className="grid grid-cols-2 gap-3"> {detailEvent.startTime ? (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<Calendar className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
Date <Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
</div> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <Clock className="h-3.5 w-3.5" />
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> Time
<Clock className="h-3.5 w-3.5" /> </div>
Time <p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
</div> ) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */} {/* Duration + Type */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -751,11 +855,26 @@ export default function CalendarPage() {
{/* Description */} {/* Description */}
{detailEvent.description && ( {detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <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-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p> <p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div> </div>
)} )}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */} {/* Linked lead */}
{detailEvent.lead && ( {detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +921,32 @@ export default function CalendarPage() {
</div> </div>
</div> </div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */} {/* 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="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"> <div className="flex items-center justify-between mb-1.5">
@@ -836,9 +981,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4"> <DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && ( {detailEvent.status === "scheduled" && (
<> <>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm"> {detailEvent.developerId === user?.id ? (
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete <Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
</Button> <CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}> <Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel <XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button> </Button>
+6 -1
View File
@@ -1,10 +1,11 @@
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other" export type EventType = "call" | "follow_up" | "website_creation"
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled" export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
export interface CalendarEvent { export interface CalendarEvent {
id: string id: string
userId: string userId: string
participantId: string | null participantId: string | null
developerId: string | null
leadId: string | null leadId: string | null
conversationId: string | null conversationId: string | null
title: string title: string
@@ -17,6 +18,10 @@ export interface CalendarEvent {
status: EventStatus status: EventStatus
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null lead: { id: string; companyName: string; contactName: string } | null
clientName: string | null
clientEmail: string | null
clientPhone: string | null
createdAt: string createdAt: string
} }
@@ -0,0 +1,4 @@
ALTER TABLE scheduled_events
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
@@ -0,0 +1,8 @@
ALTER TABLE scheduled_events
DROP CONSTRAINT IF EXISTS chk_event_type,
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
ALTER TABLE scheduled_events
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
@@ -0,0 +1,2 @@
ALTER TABLE scheduled_events
ALTER COLUMN start_time DROP NOT NULL;
+9
View File
@@ -66,5 +66,14 @@ BEGIN;
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ===' \echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql \i 016_participant_notes.sql
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
\i 017_calendar_developer.sql
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
\i 018_website_creation_type.sql
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
\i 019_allow_null_start_time.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
COMMIT; COMMIT;
+33 -3
View File
@@ -1,12 +1,42 @@
// ── Dependency Check ─────────────────────────────────────────────────
// Verifies all packages listed in package.json are installed.
// Auto-runs npm install if any are missing — zero manual setup steps.
import { readFileSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
import { execSync } from "node:child_process"
import { platform } from "node:os"
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, "..")
try {
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
const require = createRequire(import.meta.url)
const missing = Object.keys(allDeps).filter(name => {
try { require.resolve(name, { paths: [root] }); return false } catch { return true }
})
if (missing.length > 0) {
console.log(`\n${missing.length} package(s) missing: ${missing.join(", ")}`)
console.log("Installing...\n")
execSync("npm install --no-audit --no-fund", { cwd: root, stdio: "inherit", timeout: 120000 })
console.log("Dependencies installed\n")
}
} catch {
// If package.json read or resolution fails, skip silently
}
// ── Port Precheck ────────────────────────────────────────────────── // ── Port Precheck ──────────────────────────────────────────────────
// Kills any existing processes on ports 3001, 3006, 3007, 3008. // Kills any existing processes on ports 3001, 3006, 3007, 3008.
// These are the AI server, Next.js frontend, Signaling server, and // These are the AI server, Next.js frontend, Signaling server, and
// Python scraper respectively. // Python scraper respectively.
// Runs before anything else starts to avoid EADDRINUSE errors. // Runs before anything else starts to avoid EADDRINUSE errors.
import { execSync } from "node:child_process"
import { platform } from "node:os"
const PORTS = [3001, 3006, 3007, 3008] const PORTS = [3001, 3006, 3007, 3008]
if (platform() === "win32") { if (platform() === "win32") {
+189 -54
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone, ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react" } from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = { const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone, call: Phone,
chat: Video,
follow_up: Clock, follow_up: Clock,
demo: Calendar, website_creation: Globe,
other: Calendar,
} }
function eventVars(type: EventType) { function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null) const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null) const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([]) const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("") const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("") const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting") const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("") const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("") const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30") const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("") const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false) const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false) const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("") const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users || [])) .then((data) => setUsers(data.users || []))
.catch(() => {}) .catch(() => {})
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
}, [user, router]) }, [user, router])
useEffect(() => { useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => { const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr)) return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth]) }, [events, currentYear, currentMonth])
const isToday = (day: number) => { const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null) setEditingEvent(null)
setFormTitle("") setFormTitle("")
setFormDescription("") setFormDescription("")
setFormType("meeting") setFormType("website_creation")
setFormDuration("30") setFormDuration("30")
setFormParticipantId("") setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date() const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0]) setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00") setFormTime("10:00")
@@ -184,37 +196,58 @@ export default function CalendarPage() {
setFormDescription(event.description || "") setFormDescription(event.description || "")
setFormType(event.eventType) setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30)) setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0]) setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })) setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "") setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true) setDialogOpen(true)
} }
const handleSave = async () => { const handleSave = async () => {
if (!formTitle.trim() || !formDate || !formTime) { if (!formTitle.trim()) {
toast.error("Title, date, and time are required") toast.error("Title is required")
return
}
if (formType !== "website_creation" && (!formDate || !formTime)) {
toast.error("Date and time are required for this event type")
return return
} }
setFormSaving(true) setFormSaving(true)
try { 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> = { const body: Record<string, unknown> = {
title: formTitle.trim(), title: formTitle.trim(),
description: formDescription.trim() || null, description: formDescription.trim() || null,
eventType: formType, eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
} }
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res let res
if (editingEvent) { if (editingEvent) {
@@ -235,11 +268,13 @@ export default function CalendarPage() {
const data = await res.json() const data = await res.json()
if (editingEvent) { if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated") toast.success("Event updated")
} else { } else {
setEvents((prev) => [...prev, data.event]) setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar") if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created") toast.success("Event created")
} }
@@ -260,7 +295,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to update") if (!res.ok) throw new Error("Failed to update")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`) toast.success(`Event ${newStatus}`)
setDetailEvent(null) setDetailEvent(null)
} catch { } catch {
@@ -279,7 +314,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to save") if (!res.ok) throw new Error("Failed to save")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
if (detailEvent && detailEvent.id === event.id) { if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null }) setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
} }
@@ -579,7 +614,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]"> <DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn( <div className={cn(
@@ -608,11 +643,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label> <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" /> <Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div> </div>
<div className="space-y-2"> <div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label> <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" /> <Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div> </div>
</div> </div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label> <Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +673,16 @@ export default function CalendarPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="meeting">Meeting</SelectItem> <SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem> <SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem> <SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label> <Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}> <Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10"> <SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -663,6 +712,38 @@ export default function CalendarPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client (Lead)</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label> <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" /> <Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
@@ -690,7 +771,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */} {/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}> <Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && ( {detailEvent && (
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}> <div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +792,29 @@ export default function CalendarPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Date / Time */} {/* Date / Time */}
<div className="grid grid-cols-2 gap-3"> {detailEvent.startTime ? (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<Calendar className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
Date <Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
</div> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <Clock className="h-3.5 w-3.5" />
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> Time
<Clock className="h-3.5 w-3.5" /> </div>
Time <p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
</div> ) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */} {/* Duration + Type */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -751,11 +839,26 @@ export default function CalendarPage() {
{/* Description */} {/* Description */}
{detailEvent.description && ( {detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <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-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p> <p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div> </div>
)} )}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */} {/* Linked lead */}
{detailEvent.lead && ( {detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +905,32 @@ export default function CalendarPage() {
</div> </div>
</div> </div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */} {/* 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="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"> <div className="flex items-center justify-between mb-1.5">
@@ -836,9 +965,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4"> <DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && ( {detailEvent.status === "scheduled" && (
<> <>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm"> {detailEvent.developerId === user?.id ? (
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete <Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
</Button> <CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}> <Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel <XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button> </Button>
+2 -5
View File
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { import {
Search, Send, Phone, Video, MoreHorizontal, Paperclip, Search, Send, Phone, 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, CalendarDays, Loader2, FolderOpen, Mail, CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react" } from "lucide-react"
@@ -792,7 +792,7 @@ const formatPreviewContent = (content: string) => {
}) })
return ( return (
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden"> <div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* Conversations list - left panel */} {/* Conversations list - left panel */}
<div <div
className="flex flex-col border-r shrink-0 overflow-hidden" className="flex flex-col border-r shrink-0 overflow-hidden"
@@ -923,9 +923,6 @@ const formatPreviewContent = (content: string) => {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
<CalendarDays className="h-4 w-4" /> <CalendarDays className="h-4 w-4" />
</Button> </Button>
+10 -3
View File
@@ -1,5 +1,7 @@
"use client" "use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { AppShell } from "@/components/layout/app-shell" import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider" import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider" import { NotificationProvider } from "@/providers/notification-provider"
@@ -8,8 +10,15 @@ import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser() const { user, loading } = useUser()
const router = useRouter()
if (loading) { useEffect(() => {
if (!user && !loading) {
router.push("/login")
}
}, [user, loading, router])
if (loading || !user) {
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -17,8 +26,6 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
) )
} }
if (!user) return null
return <AppShell>{children}</AppShell> return <AppShell>{children}</AppShell>
} }
+32 -16
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest } from "next/server"
import { import {
comparePassword, comparePassword,
getUserByEmail, getUserByEmail,
@@ -10,8 +10,16 @@ import {
isAccountLocked, isAccountLocked,
createSession, createSession,
setSessionContext, setSessionContext,
SESSION_COOKIE,
} from "@/lib/auth" } from "@/lib/auth"
function jsonResponse(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
})
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { email, username, password } = await request.json() const { email, username, password } = await request.json()
@@ -19,16 +27,16 @@ export async function POST(request: NextRequest) {
const credential = email || username const credential = email || username
if (!credential || !password) { if (!credential || !password) {
return NextResponse.json( return jsonResponse(
{ error: "Email/Username and password are required." }, { error: "Email/Username and password are required." },
{ status: 400 } 400
) )
} }
if (credential.trim().length === 0 || password.trim().length === 0) { if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json( return jsonResponse(
{ error: "Credentials cannot be empty." }, { error: "Credentials cannot be empty." },
{ status: 400 } 400
) )
} }
@@ -58,9 +66,9 @@ export async function POST(request: NextRequest) {
false, false,
"User not found" "User not found"
) )
return NextResponse.json( return jsonResponse(
{ error: "Invalid email/username or password." }, { error: "Invalid email/username or password." },
{ status: 401 } 401
) )
} }
@@ -74,9 +82,9 @@ export async function POST(request: NextRequest) {
false, false,
lockStatus.reason lockStatus.reason
) )
return NextResponse.json( return jsonResponse(
{ error: lockStatus.reason }, { error: lockStatus.reason },
{ status: 423 } 423
) )
} }
@@ -91,9 +99,9 @@ export async function POST(request: NextRequest) {
false, false,
"Invalid password" "Invalid password"
) )
return NextResponse.json( return jsonResponse(
{ error: "Invalid email/username or password." }, { error: "Invalid email/username or password." },
{ status: 401 } 401
) )
} }
@@ -106,17 +114,25 @@ export async function POST(request: NextRequest) {
true true
) )
await createSession(dbUser.id, dbUser.role_name) const token = await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress) await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser) const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 }) const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
return new Response(JSON.stringify({ user }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": cookieStr,
},
})
} catch (error) { } catch (error) {
console.error("Login error:", error) console.error("Login error:", error)
return NextResponse.json( return new Response(
{ error: "Authentication service unavailable." }, JSON.stringify({ error: "Authentication service unavailable." }),
{ status: 503 } { status: 503, headers: { "Content-Type": "application/json" } }
) )
} }
} }
+11 -7
View File
@@ -1,15 +1,19 @@
import { NextResponse } from "next/server" import { SESSION_COOKIE } from "@/lib/auth"
import { destroySession } from "@/lib/auth"
export async function POST() { export async function POST() {
try { try {
await destroySession() return new Response(JSON.stringify({ success: true }), {
return NextResponse.json({ success: true }, { status: 200 }) status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`,
},
})
} catch (error) { } catch (error) {
console.error("Logout error:", error) console.error("Logout error:", error)
return NextResponse.json( return new Response(
{ error: "Logout failed." }, JSON.stringify({ error: "Logout failed." }),
{ status: 500 } { status: 500, headers: { "Content-Type": "application/json" } }
) )
} }
} }
+5 -5
View File
@@ -196,11 +196,11 @@ export async function GET(request: NextRequest) {
trends, trends,
recentLeads: mappedLeads.slice(0, 10), recentLeads: mappedLeads.slice(0, 10),
statusDistribution: [ statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#FFFFFF" }, { name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#dc2626" }, { name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
{ name: "Pending", value: currentCounts.pending, color: "#1e3a8a" }, { name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
{ name: "Closed", value: currentCounts.closed, color: "#60a5fa" }, { name: "Closed", value: currentCounts.closed, color: "#10b981" },
{ name: "Ignored", value: currentCounts.ignored, color: "#000000" }, { name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
], ],
periodLabel: periodLabels[period] ?? "Selected period", periodLabel: periodLabels[period] ?? "Selected period",
} }
+5 -1
View File
@@ -8,8 +8,11 @@ export async function GET() {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query( const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email `SELECT u.id, u.first_name, u.last_name, u.email,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u 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.deleted_at IS NULL AND u.id != $1 WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`, ORDER BY u.first_name ASC`,
[user.id], [user.id],
@@ -19,6 +22,7 @@ export async function GET() {
id: r.id, id: r.id,
name: `${r.first_name} ${r.last_name}`, name: `${r.first_name} ${r.last_name}`,
email: r.email, email: r.email,
role: r.role_display || r.role_name || "",
})) }))
return NextResponse.json({ users }) return NextResponse.json({ users })
+37 -10
View File
@@ -9,11 +9,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json() const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
const existing = await query( const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type, `SELECT e.user_id, e.participant_id, e.developer_id, e.lead_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.start_time, e.end_time, e.duration_minutes, e.status, e.client_name, e.client_email, e.client_phone,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last, 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 p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e FROM scheduled_events e
@@ -31,8 +31,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const old = existing.rows[0] const old = existing.rows[0]
const isCreator = old.user_id === user.id const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id const isParticipant = old.participant_id === user.id
const isDeveloper = old.developer_id === user.id
if (!isCreator && !isParticipant) { if (!isCreator && !isParticipant && !isDeveloper) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
} }
@@ -42,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
(startTime !== undefined && startTime !== old.start_time) || (startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) || (endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) || (durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id) (participantId !== undefined && participantId !== old.participant_id) ||
(developerId !== undefined && developerId !== old.developer_id)
)) { )) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 }) return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
} }
@@ -53,14 +55,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
description = COALESCE($2, description), description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes), participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type), event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time), start_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($5, start_time) END,
end_time = COALESCE($6, end_time), end_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($6, end_time) END,
duration_minutes = COALESCE($7, duration_minutes), duration_minutes = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($7, duration_minutes) END,
status = COALESCE($8, status), status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id), participant_id = COALESCE($9, participant_id),
developer_id = COALESCE($10, developer_id),
client_name = COALESCE($11, client_name),
client_email = COALESCE($12, client_email),
client_phone = COALESCE($13, client_phone),
updated_at = NOW() updated_at = NOW()
WHERE id = $10 WHERE id = $14
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`, RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[ [
title || null, title || null,
description ?? null, description ?? null,
@@ -71,6 +77,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
durationMinutes ?? null, durationMinutes ?? null,
status || null, status || null,
participantId ?? null, participantId ?? null,
developerId ?? null,
clientName ?? null,
clientEmail ?? null,
clientPhone ?? null,
id, id,
], ],
user.id, user.id,
@@ -78,6 +88,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const r = result.rows[0] const r = result.rows[0]
// Auto-close lead when developer marks event as completed
if (status === "completed" && r.lead_id) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'")
if (stageResult.rows.length > 0) {
await query(
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
[stageResult.rows[0].id, r.lead_id],
)
}
}
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time || 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.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status r.status !== old.status
@@ -108,12 +129,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
) )
const creatorInfo = creatorResult.rows[0] 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 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
const developerInfo = old.developer_id ? { id: old.developer_id, name: "", email: "" } : null
return NextResponse.json({ return NextResponse.json({
event: { event: {
id: r.id, id: r.id,
userId: r.user_id, userId: r.user_id,
participantId: r.participant_id, participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id, leadId: r.lead_id,
conversationId: r.conversation_id, conversationId: r.conversation_id,
title: r.title, title: r.title,
@@ -126,7 +149,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
status: r.status, 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" }, 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, participant: participantInfo,
developer: developerInfo,
lead: null, lead: null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at, createdAt: r.created_at,
}, },
}) })
+73 -15
View File
@@ -13,27 +13,33 @@ export async function GET(request: NextRequest) {
const end = searchParams.get("end") const end = searchParams.get("end")
const status = searchParams.get("status") const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id, let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at, e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
e.client_name, e.client_email, e.client_phone,
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, 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, 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, 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, pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
d.id AS d_id, d.first_name AS d_first, d.last_name AS d_last, d.email AS d_email, d.avatar_url AS d_avatar,
dr.role_id AS d_role_id, dr2.name AS d_role_name, dr2.display_name AS d_role_display,
l.id AS l_id, l.company_name, l.contact_name l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e FROM scheduled_events e
JOIN users u ON u.id = e.user_id JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN users d ON d.id = e.developer_id
LEFT JOIN leads l ON l.id = e.lead_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 user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_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 user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id` LEFT JOIN roles pr2 ON pr2.id = pr.role_id
LEFT JOIN user_roles dr ON dr.user_id = e.developer_id
LEFT JOIN roles dr2 ON dr2.id = dr.role_id`
const params: unknown[] = [] const params: unknown[] = []
let idx = 1 let idx = 1
if (user.role !== "super_admin") { if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}` sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
params.push(user.id) params.push(user.id)
idx++ idx++
} else { } else {
@@ -41,13 +47,13 @@ export async function GET(request: NextRequest) {
} }
if (start) { if (start) {
sql += ` AND e.start_time >= $${idx}` sql += ` AND (e.start_time IS NULL OR e.start_time >= $${idx})`
params.push(start) params.push(start)
idx++ idx++
} }
if (end) { if (end) {
sql += ` AND e.start_time <= $${idx}` sql += ` AND (e.start_time IS NULL OR e.start_time <= $${idx})`
params.push(end) params.push(end)
idx++ idx++
} }
@@ -66,6 +72,7 @@ export async function GET(request: NextRequest) {
id: r.id, id: r.id,
userId: r.user_id, userId: r.user_id,
participantId: r.participant_id, participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id, leadId: r.lead_id,
conversationId: r.conversation_id, conversationId: r.conversation_id,
title: r.title, title: r.title,
@@ -78,7 +85,11 @@ export async function GET(request: NextRequest) {
status: r.status, 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 }, 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, 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,
developer: r.d_id ? { id: r.d_id, name: `${r.d_first} ${r.d_last}`, email: r.d_email, role: r.d_role_display || r.d_role_name, avatar: r.d_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null, lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at, createdAt: r.created_at,
})) }))
@@ -95,36 +106,55 @@ export async function POST(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { const {
participantId, leadId, conversationId, participantId, developerId, leadId, conversationId,
title, description, eventType, title, description, eventType,
startTime, endTime, durationMinutes, startTime, endTime, durationMinutes,
clientName, clientEmail, clientPhone,
} = await request.json() } = await request.json()
if (!title || !startTime) { if (!title) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 }) return NextResponse.json({ error: "Title is required" }, { status: 400 })
}
if (eventType !== "website_creation" && !startTime) {
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
} }
const result = await query( 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) `INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
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`, RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[ [
user.id, user.id,
participantId || null, participantId || null,
developerId || null,
leadId || null, leadId || null,
conversationId || null, conversationId || null,
title, title,
description || null, description || null,
eventType || "meeting", eventType || "website_creation",
startTime, startTime || null,
endTime || null, eventType === "website_creation" ? null : (endTime || null),
durationMinutes || null, eventType === "website_creation" ? null : (durationMinutes || null),
clientName || null,
clientEmail || null,
clientPhone || null,
], ],
user.id, user.id,
) )
const r = result.rows[0] const r = result.rows[0]
// Auto-update lead to "pending" when a website creation event is created
if (r.event_type === "website_creation" && leadId) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
if (stageResult.rows.length > 0) {
await query(
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
[stageResult.rows[0].id, leadId],
)
}
}
if (participantId && participantId !== user.id) { if (participantId && participantId !== user.id) {
await query( await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) `INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
@@ -141,12 +171,35 @@ export async function POST(request: NextRequest) {
) )
} }
// Notify developer
if (developerId && developerId !== 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)`,
[
developerId,
"event_scheduled",
`Project assigned: ${title}`,
`${user.firstName} ${user.lastName} assigned a project to you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
const participantResult = participantId ? await query( const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`, `SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId], [participantId],
) : null ) : null
const participant = participantResult?.rows[0] || null const participant = participantResult?.rows[0] || null
const developerResult = developerId ? await query(
`SELECT id, first_name, last_name, email FROM users WHERE id = $1`,
[developerId],
) : null
const dev = developerResult?.rows[0] || null
sendEventConfirmation({ sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`, creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email, creatorEmail: user.email,
@@ -165,6 +218,7 @@ export async function POST(request: NextRequest) {
id: r.id, id: r.id,
userId: r.user_id, userId: r.user_id,
participantId: r.participant_id, participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id, leadId: r.lead_id,
conversationId: r.conversation_id, conversationId: r.conversation_id,
title: r.title, title: r.title,
@@ -177,7 +231,11 @@ export async function POST(request: NextRequest) {
status: r.status, status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role }, 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, participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null, lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at, createdAt: r.created_at,
}, },
}, { status: 201 }) }, { status: 201 })
+1 -5
View File
@@ -15,11 +15,7 @@ export async function GET(request: NextRequest) {
const pos = searchParams.get("pos") || "" const pos = searchParams.get("pos") || ""
if (!TENOR_API_KEY) { if (!TENOR_API_KEY) {
return NextResponse.json({ return NextResponse.json({ results: [], noKey: true })
results: [],
error: "TENOR_API_KEY not configured",
noKey: true,
})
} }
const endpoint = q const endpoint = q
+1 -4
View File
@@ -105,12 +105,9 @@
--sidebar-ring: 221 83% 53%; --sidebar-ring: 221 83% 53%;
--radius: 0.5rem; --radius: 0.5rem;
--theme-primary: hsl(var(--primary)); --theme-primary: hsl(var(--primary));
--event-meeting: 217 91% 55%;
--event-call: 160 84% 39%; --event-call: 160 84% 39%;
--event-chat: 271 81% 53%;
--event-follow_up: 38 92% 48%; --event-follow_up: 38 92% 48%;
--event-demo: 346 77% 48%; --event-website_creation: 263 70% 50%;
--event-other: 215 20% 55%;
} }
.dark { .dark {
+16 -2
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation" import { useSearchParams, useRouter } from "next/navigation"
import { Eye, EyeOff, Loader2 } from "lucide-react" import { Eye, EyeOff, Loader2 } from "lucide-react"
@@ -13,7 +13,20 @@ const waves = [
] ]
export default function LoginPage() { export default function LoginPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center">
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
)
}
function LoginForm() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
@@ -214,7 +227,8 @@ export default function LoginPage() {
}) })
if (res.ok) { if (res.ok) {
router.push("/dashboard") const redirectTo = searchParams.get("redirect") || "/dashboard"
router.push(redirectTo)
} else { } else {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.") setError(data.error || "Invalid email or password.")
+15 -7
View File
@@ -39,8 +39,9 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
const [gifResults, setGifResults] = useState<any[]>([]) const [gifResults, setGifResults] = useState<any[]>([])
const [gifLoading, setGifLoading] = useState(false) const [gifLoading, setGifLoading] = useState(false)
const [gifError, setGifError] = useState("") const [gifError, setGifError] = useState("")
const [gifNoKey, setGifNoKey] = useState(false) const [gifUnavailable, setGifUnavailable] = useState(false)
const [gifNext, setGifNext] = useState("") const [gifNext, setGifNext] = useState("")
const gifCache = useRef<Map<string, { results: any[]; next: string }>>(new Map())
const [recentGifs, setRecentGifs] = useState<string[]>([]) const [recentGifs, setRecentGifs] = useState<string[]>([])
const [recentStickers, setRecentStickers] = useState<string[]>([]) const [recentStickers, setRecentStickers] = useState<string[]>([])
const [stickerPacks] = useState<StickerPack[]>(getStickerPacks) const [stickerPacks] = useState<StickerPack[]>(getStickerPacks)
@@ -60,6 +61,13 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, []) useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
const fetchGifs = useCallback(async (query: string, pos = "") => { const fetchGifs = useCallback(async (query: string, pos = "") => {
const cacheKey = `${query}::${pos}`
const cached = gifCache.current.get(cacheKey)
if (cached && !pos) {
setGifResults(cached.results)
setGifNext(cached.next)
return
}
setGifLoading(true) setGifLoading(true)
setGifError("") setGifError("")
try { try {
@@ -68,17 +76,18 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
const res = await fetch(`/api/gifs?${params}`) const res = await fetch(`/api/gifs?${params}`)
const data = await res.json() const data = await res.json()
if (data.noKey) { if (data.noKey) {
setGifNoKey(true) setGifUnavailable(true)
setGifResults([]) setGifResults([])
} else if (data.results) { } else if (data.results) {
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results)) setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
setGifNext(data.next || "") setGifNext(data.next || "")
setGifNoKey(false) setGifUnavailable(false)
if (!pos) gifCache.current.set(cacheKey, { results: data.results, next: data.next || "" })
} else { } else {
setGifError("No results found") setGifError("No results found")
} }
} catch { } catch {
setGifError("Failed to load GIFs") setGifError("Could not load GIFs. Check your connection.")
} }
setGifLoading(false) setGifLoading(false)
}, []) }, [])
@@ -196,11 +205,10 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
<div className="flex items-center justify-center h-48"> <div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : gifNoKey ? ( ) : gifUnavailable ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2"> <div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<Image className="h-8 w-8 opacity-40" /> <Image className="h-8 w-8 opacity-40" />
<span>GIF search requires a Tenor API key</span> <span>GIFs are temporarily unavailable.</span>
<span className="text-xs opacity-60">Set TENOR_API_KEY in .env.local</span>
</div> </div>
) : gifError ? ( ) : gifError ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2"> <div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
@@ -98,7 +98,7 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
key={i} key={i}
d={arcPath(160, 160, oR, iR, s.start, s.end)} d={arcPath(160, 160, oR, iR, s.start, s.end)}
fill={`url(#chart-grad-${i})`} fill={`url(#chart-grad-${i})`}
stroke="#d4d4d4" stroke="#000000"
strokeWidth="1" strokeWidth="1"
opacity={hov !== null && !isH ? 0.3 : 1} opacity={hov !== null && !isH ? 0.3 : 1}
style={{ style={{
@@ -9,11 +9,11 @@ import { Lead } from "@/types"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
const statusStyles: Record<string, string> = { const statusStyles: Record<string, string> = {
open: "bg-white/40 text-gray-700 dark:text-gray-300 border-gray-300/50 dark:border-gray-600/50", open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
contacted: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
pending: "bg-blue-900/10 text-blue-900 dark:text-blue-300 border-blue-900/20", pending: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
closed: "bg-blue-400/10 text-blue-600 dark:text-blue-300 border-blue-400/20", closed: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
ignored: "bg-black/10 text-black dark:text-gray-300 border-black/20 dark:border-black/40", ignored: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
} }
interface RecentLeadsTableProps { interface RecentLeadsTableProps {
+1 -1
View File
@@ -62,7 +62,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
> >
{/* Logo */} {/* Logo */}
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}> <div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
<Link href="/" className="flex items-center gap-3 overflow-hidden"> <Link href="/dashboard" className="flex items-center gap-3 overflow-hidden">
{collapsed ? ( {collapsed ? (
<div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div> <div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
) : ( ) : (
+4 -32
View File
@@ -33,6 +33,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useWebsiteTheme } from "@/providers/website-theme-provider"; import { useWebsiteTheme } from "@/providers/website-theme-provider";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
interface TopbarProps { interface TopbarProps {
onMenuClick: () => void; onMenuClick: () => void;
@@ -72,40 +73,11 @@ export function Topbar({ onMenuClick }: TopbarProps) {
.toUpperCase(); .toUpperCase();
return ( return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden"> <header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0"> <div className="flex items-center gap-2 mr-2 shrink-0">
{websiteTheme === "spidey" && ( {websiteTheme === "spidey" && <CaptainAmericaShield />}
<> <span className="text-primary font-bold text-sm tracking-wide">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" className="dark:hidden shrink-0">
<circle cx="14" cy="14" r="14" fill="white"/>
<ellipse cx="14" cy="14" rx="4" ry="5" fill="#CC0000"/>
<ellipse cx="14" cy="14" rx="2" ry="2.5" fill="#CC0000"/>
<line x1="14" y1="9" x2="6" y2="5" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="9" x2="22" y2="5" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="5" y2="11" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="23" y2="11" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="6" y2="23" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="22" y2="23" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="5" y2="17" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="23" y2="17" stroke="#CC0000" strokeWidth="1.2"/>
</svg>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" className="hidden dark:block shrink-0">
<circle cx="14" cy="14" r="14" fill="#1A1A1A"/>
<ellipse cx="14" cy="14" rx="4" ry="5" fill="#FF1111"/>
<ellipse cx="14" cy="14" rx="2" ry="2.5" fill="#FF1111"/>
<line x1="14" y1="9" x2="6" y2="5" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="9" x2="22" y2="5" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="5" y2="11" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="23" y2="11" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="6" y2="23" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="22" y2="23" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="5" y2="17" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="23" y2="17" stroke="#FF1111" strokeWidth="1.2"/>
</svg>
</>
)}
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
CRM CRM
</span> </span>
</div> </div>
+10 -10
View File
@@ -7,23 +7,23 @@ import { cn } from "@/lib/utils"
const statusConfig: Record<LeadStatus, { label: string; class: string }> = { const statusConfig: Record<LeadStatus, { label: string; class: string }> = {
open: { open: {
label: "Open", label: "Open",
class: "bg-white/40 text-gray-700 dark:text-gray-300 border-gray-300/50 dark:border-gray-600/50", class: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
}, },
contacted: { contacted: {
label: "Contacted", label: "Contacted",
class: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", class: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
}, },
pending: { pending: {
label: "Pending", label: "Pending",
class: "bg-blue-900/10 text-blue-900 dark:text-blue-300 border-blue-900/20", class: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
}, },
closed: { closed: {
label: "Closed", label: "Closed",
class: "bg-blue-400/10 text-blue-600 dark:text-blue-300 border-blue-400/20", class: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
}, },
ignored: { ignored: {
label: "Ignored", label: "Ignored",
class: "bg-black/10 text-black dark:text-gray-300 border-black/20 dark:border-black/40", class: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
}, },
} }
@@ -37,11 +37,11 @@ export function LeadStatusBadge({ status, className }: LeadStatusBadgeProps) {
return ( return (
<Badge variant="outline" className={cn(config.class, className)}> <Badge variant="outline" className={cn(config.class, className)}>
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", { <span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", {
"bg-white border border-gray-300 dark:border-gray-500": status === "open", "bg-blue-500": status === "open",
"bg-red-500": status === "contacted", "bg-amber-500": status === "contacted",
"bg-blue-900": status === "pending", "bg-purple-500": status === "pending",
"bg-blue-400": status === "closed", "bg-emerald-500": status === "closed",
"bg-black": status === "ignored", "bg-zinc-500": status === "ignored",
})} /> })} />
{config.label} {config.label}
</Badge> </Badge>
@@ -8,24 +8,11 @@ export function CaptainAmericaShield() {
viewBox="0 0 100 100" viewBox="0 0 100 100"
aria-hidden="true" aria-hidden="true"
> >
<defs> <circle cx="50" cy="50" r="48" fill="#c62828" />
<clipPath id="shield-clip"> <circle cx="50" cy="50" r="36" fill="#efefef" />
<polygon points="50,95 5,60 5,30 50,5 95,30 95,60" /> <circle cx="50" cy="50" r="24" fill="#c62828" />
</clipPath> <circle cx="50" cy="50" r="14" fill="#1565c0" />
</defs> <polygon points="50,34 55,48 70,48 58,57 62,72 50,62 38,72 42,57 30,48 45,48" fill="#efefef" />
<g clipPath="url(#shield-clip)">
<circle cx="50" cy="45" r="45" fill="#c62828" />
<circle cx="50" cy="45" r="35" fill="#efefef" />
<circle cx="50" cy="45" r="25" fill="#c62828" />
<circle cx="50" cy="45" r="15" fill="#1565c0" />
<polygon points="50,32 56,47 72,48 60,58 64,75 50,64 36,75 40,58 28,48 44,47" fill="#efefef" />
</g>
<polygon
points="50,95 5,60 5,30 50,5 95,30 95,60"
fill="none"
stroke="#c62828"
strokeWidth="1.5"
/>
</svg> </svg>
) )
} }
+13 -26
View File
@@ -3,13 +3,21 @@ import bcrypt from "bcryptjs";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
const RAW_SECRET = process.env.JWT_SECRET; function randomHex(length: number): string {
if (!RAW_SECRET) { const chars = "abcdef0123456789";
throw new Error("JWT_SECRET environment variable is required"); let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
} }
const g = globalThis as typeof globalThis & { __JWT_RAW_SECRET?: string };
const RAW_SECRET = process.env.JWT_SECRET || (g.__JWT_RAW_SECRET ??= randomHex(32));
if (!RAW_SECRET) throw new Error("JWT_SECRET environment variable is required");
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET); const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
const SESSION_COOKIE = "session"; export const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5; const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION_MINUTES = 15; const LOCKOUT_DURATION_MINUTES = 15;
@@ -195,28 +203,7 @@ export function mapDbUserToSessionUser(
} }
export async function createSession(userId: string, role: string) { export async function createSession(userId: string, role: string) {
const token = await signToken({ userId, role }); return signToken({ userId, role });
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
return token;
}
export async function destroySession() {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 0,
});
} }
export async function encryptPassword(password: string): Promise<string> { export async function encryptPassword(password: string): Promise<string> {
+5 -5
View File
@@ -1,9 +1,9 @@
export const LEAD_STATUSES = { export const LEAD_STATUSES = {
open: { label: "Open", color: "bg-white" }, open: { label: "Open", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-red-500" }, contacted: { label: "Contacted", color: "bg-amber-500" },
pending: { label: "Pending", color: "bg-blue-900" }, pending: { label: "Pending", color: "bg-purple-500" },
closed: { label: "Closed", color: "bg-blue-400" }, closed: { label: "Closed", color: "bg-emerald-500" },
ignored: { label: "Ignored", color: "bg-black" }, ignored: { label: "Ignored", color: "bg-zinc-500" },
} as const } as const
export const LEAD_SOURCES = [ export const LEAD_SOURCES = [
+1 -18
View File
@@ -1,12 +1,5 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import type { NextRequest } from "next/server" import type { NextRequest } from "next/server"
import { jwtVerify } from "jose"
const RAW_SECRET = process.env.JWT_SECRET
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required")
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [ const publicRoutes = [
"/login", "/login",
@@ -41,17 +34,7 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl) return NextResponse.redirect(loginUrl)
} }
try { return NextResponse.next()
await jwtVerify(sessionCookie, JWT_SECRET)
return NextResponse.next()
} catch {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
}
} }
export const config = { export const config = {
+6 -1
View File
@@ -110,13 +110,14 @@ export interface Conversation {
messages: ChatMessage[]; messages: ChatMessage[];
} }
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other"; export type EventType = "call" | "follow_up" | "website_creation";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"; export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent { export interface CalendarEvent {
id: string; id: string;
userId: string; userId: string;
participantId: string | null; participantId: string | null;
developerId: string | null;
leadId: string | null; leadId: string | null;
conversationId: string | null; conversationId: string | null;
title: string; title: string;
@@ -129,6 +130,10 @@ export interface CalendarEvent {
status: EventStatus; status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null; creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null; participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null; lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string; createdAt: string;
} }