diff --git a/.gitignore b/.gitignore index c506f81..c1b3cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.git +.git_bak +node_modules +target \ No newline at end of file diff --git a/database/migrations/008_notifications.sql b/database/migrations/008_notifications.sql new file mode 100644 index 0000000..616b4d5 --- /dev/null +++ b/database/migrations/008_notifications.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + link TEXT, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE; + +CREATE TABLE IF NOT EXISTS notification_preferences ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + lead_assigned BOOLEAN NOT NULL DEFAULT TRUE, + lead_status BOOLEAN NOT NULL DEFAULT TRUE, + note_added BOOLEAN NOT NULL DEFAULT FALSE, + daily_digest BOOLEAN NOT NULL DEFAULT FALSE, + weekly_report BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/database/migrations/009_settings.sql b/database/migrations/009_settings.sql new file mode 100644 index 0000000..5874ba0 --- /dev/null +++ b/database/migrations/009_settings.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS company_settings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + company_name VARCHAR(255) NOT NULL DEFAULT '', + company_email VARCHAR(255) NOT NULL DEFAULT '', + company_phone VARCHAR(50) NOT NULL DEFAULT '', + company_website VARCHAR(255) NOT NULL DEFAULT '', + company_address TEXT NOT NULL DEFAULT '', + updated_by UUID REFERENCES users(id), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address) +VALUES ('Coastal IT Solutions', 'info@coastalit.com', '(555) 123-4567', 'https://coastalit.com', '123 Business Ave, Suite 100, San Francisco, CA 94105') +ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS user_preferences ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + timezone VARCHAR(100) NOT NULL DEFAULT 'america-los_angeles', + date_format VARCHAR(10) NOT NULL DEFAULT 'mdy', + items_per_page INTEGER NOT NULL DEFAULT 20, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index 19380f0..b1add20 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -10,6 +10,27 @@ \echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ===' \i 002_seed.sql +\echo '=== Running 003_chat.sql (Chat Tables) ===' +\i 003_chat.sql + +\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ===' +\i 004_avatar_url.sql + +\echo '=== Running 005_last_read_at.sql (Last Read At Column) ===' +\i 005_last_read_at.sql + +\echo '=== Running 006_test_leads.sql (Test Lead Data) ===' +\i 006_test_leads.sql + +\echo '=== Running 007_ai_features.sql (AI Features) ===' +\i 007_ai_features.sql + +\echo '=== Running 008_notifications.sql (Notifications + Preferences) ===' +\i 008_notifications.sql + +\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ===' +\i 009_settings.sql + \echo '=== Migration Complete ===' \echo '' \echo 'Test accounts created:' diff --git a/package-lock.json b/package-lock.json index 7f6eddc..03119b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3297,7 +3297,6 @@ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, - "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..f6404f8 --- /dev/null +++ b/src/app/api/notifications/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + + const result = await query( + `UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 RETURNING id`, + [id, user.id], + ) + + if (result.rowCount === 0) { + return NextResponse.json({ error: "Notification not found" }, { status: 404 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Notification PATCH error:", error) + return NextResponse.json({ error: "Failed to mark notification as read" }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + + const result = await query( + `DELETE FROM notifications WHERE id = $1 AND user_id = $2 RETURNING id`, + [id, user.id], + ) + + if (result.rowCount === 0) { + return NextResponse.json({ error: "Notification not found" }, { status: 404 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Notification DELETE error:", error) + return NextResponse.json({ error: "Failed to dismiss notification" }, { status: 500 }) + } +} diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts new file mode 100644 index 0000000..dd1682a --- /dev/null +++ b/src/app/api/notifications/preferences/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function GET() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const result = await query( + `SELECT lead_assigned, lead_status, note_added, daily_digest, weekly_report + FROM notification_preferences + WHERE user_id = $1`, + [user.id], + ) + + if (result.rowCount === 0) { + return NextResponse.json({ + leadAssigned: true, + leadStatus: true, + noteAdded: false, + dailyDigest: false, + weeklyReport: true, + }) + } + + const r = result.rows[0] + return NextResponse.json({ + leadAssigned: r.lead_assigned, + leadStatus: r.lead_status, + noteAdded: r.note_added, + dailyDigest: r.daily_digest, + weeklyReport: r.weekly_report, + }) + } catch (error) { + console.error("Preferences GET error:", error) + return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const body = await request.json() + + await query( + `INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (user_id) + DO UPDATE SET lead_assigned = $2, lead_status = $3, note_added = $4, + daily_digest = $5, weekly_report = $6, updated_at = NOW()`, + [ + user.id, + body.leadAssigned ?? true, + body.leadStatus ?? true, + body.noteAdded ?? false, + body.dailyDigest ?? false, + body.weeklyReport ?? true, + ], + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Preferences PATCH error:", error) + return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 }) + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..d8c2c00 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function GET() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const result = await query( + `SELECT id, type, title, description, link, is_read, created_at + FROM notifications + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 50`, + [user.id], + ) + + const notifications = result.rows.map((r: any) => ({ + id: r.id, + type: r.type, + title: r.title, + description: r.description, + link: r.link, + read: r.is_read, + timestamp: r.created_at, + })) + + const unreadResult = await query( + `SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`, + [user.id], + ) + + return NextResponse.json({ + notifications, + unreadCount: parseInt(unreadResult.rows[0].count, 10), + }) + } catch (error) { + console.error("Notifications GET error:", error) + return NextResponse.json({ error: "Failed to load notifications" }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { type, title, description, link, userId } = await request.json() + const targetUserId = userId || user.id + + const result = await query( + `INSERT INTO notifications (user_id, type, title, description, link) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, type, title, description, link, is_read, created_at`, + [targetUserId, type, title, description || null, link || null], + ) + + const notif = result.rows[0] + return NextResponse.json({ + id: notif.id, + type: notif.type, + title: notif.title, + description: notif.description, + link: notif.link, + read: notif.is_read, + timestamp: notif.created_at, + }, { status: 201 }) + } catch (error) { + console.error("Notifications POST error:", error) + return NextResponse.json({ error: "Failed to create notification" }, { status: 500 }) + } +} + +export async function PATCH() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + await query( + `UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`, + [user.id], + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Notifications PATCH error:", error) + return NextResponse.json({ error: "Failed to mark all as read" }, { status: 500 }) + } +} diff --git a/src/app/api/settings/company/route.ts b/src/app/api/settings/company/route.ts new file mode 100644 index 0000000..7499a4f --- /dev/null +++ b/src/app/api/settings/company/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function GET() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`) + const row = result.rows[0] + + if (!row) { + return NextResponse.json({ + companyName: "Coastal IT Solutions", + companyEmail: "info@coastalit.com", + companyPhone: "(555) 123-4567", + companyWebsite: "https://coastalit.com", + companyAddress: "123 Business Ave, Suite 100, San Francisco, CA 94105", + }) + } + + return NextResponse.json({ + companyName: row.company_name || "", + companyEmail: row.company_email || "", + companyPhone: row.company_phone || "", + companyWebsite: row.company_website || "", + companyAddress: row.company_address || "", + }) + } catch (error) { + console.error("Company settings GET error:", error) + return NextResponse.json({ error: "Failed to load company settings" }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const body = await request.json() + + await query( + `UPDATE company_settings SET + company_name = $1, company_email = $2, company_phone = $3, + company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`, + [ + body.companyName || "", + body.companyEmail || "", + body.companyPhone || "", + body.companyWebsite || "", + body.companyAddress || "", + user.id, + ], + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Company settings PATCH error:", error) + return NextResponse.json({ error: "Failed to save company settings" }, { status: 500 }) + } +} diff --git a/src/app/api/settings/preferences/route.ts b/src/app/api/settings/preferences/route.ts new file mode 100644 index 0000000..6a7da54 --- /dev/null +++ b/src/app/api/settings/preferences/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function GET() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const result = await query( + `SELECT timezone, date_format, items_per_page FROM user_preferences WHERE user_id = $1`, + [user.id], + ) + + if (result.rowCount === 0) { + return NextResponse.json({ + timezone: "america-los_angeles", + dateFormat: "mdy", + itemsPerPage: 20, + }) + } + + const r = result.rows[0] + return NextResponse.json({ + timezone: r.timezone, + dateFormat: r.date_format, + itemsPerPage: r.items_per_page, + }) + } catch (error) { + console.error("Preferences GET error:", error) + return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const body = await request.json() + + await query( + `INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (user_id) + DO UPDATE SET timezone = $2, date_format = $3, items_per_page = $4, updated_at = NOW()`, + [ + user.id, + body.timezone || "america-los_angeles", + body.dateFormat || "mdy", + body.itemsPerPage || 20, + ], + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Preferences PATCH error:", error) + return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 }) + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 5489282..b50acdf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,14 +22,14 @@ --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 221.2 83.2% 53.3%; - --sidebar: 222.2 84% 4.9%; - --sidebar-foreground: 210 40% 98%; - --sidebar-primary: 217.2 91.2% 59.8%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 221.2 83.2% 53.3%; --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 217.2 32.6% 17.5%; - --sidebar-accent-foreground: 210 40% 98%; - --sidebar-border: 217.2 32.6% 17.5%; - --sidebar-ring: 224.3 76.3% 48%; + --sidebar-accent: 210 40% 96.1%; + --sidebar-accent-foreground: 222.2 84% 4.9%; + --sidebar-border: 214.3 31.8% 91.4%; + --sidebar-ring: 221.2 83.2% 53.3%; --radius: 0.5rem; } @@ -63,6 +63,258 @@ --sidebar-ring: 224.3 76.3% 48%; } +.ocean { + --primary: 187 75% 42%; + --primary-foreground: 210 40% 98%; + --ring: 187 75% 42%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 187 75% 42%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 187 30% 94%; + --sidebar-accent-foreground: 187 50% 20%; + --sidebar-border: 187 20% 88%; + --sidebar-ring: 187 75% 42%; +} + +.dark.ocean { + --primary: 187 75% 50%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 187 75% 50%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 187 75% 55%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 187 75% 50%; +} + +.forest { + --primary: 142 76% 36%; + --primary-foreground: 210 40% 98%; + --ring: 142 76% 36%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 142 76% 36%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 142 30% 94%; + --sidebar-accent-foreground: 142 50% 20%; + --sidebar-border: 142 20% 88%; + --sidebar-ring: 142 76% 36%; +} + +.dark.forest { + --primary: 142 76% 44%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 142 76% 44%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 142 76% 50%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 142 76% 44%; +} + +.sunset { + --primary: 24 95% 53%; + --primary-foreground: 210 40% 98%; + --ring: 24 95% 53%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 24 95% 53%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 24 30% 94%; + --sidebar-accent-foreground: 24 50% 20%; + --sidebar-border: 24 20% 88%; + --sidebar-ring: 24 95% 53%; +} + +.dark.sunset { + --primary: 24 95% 58%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 24 95% 58%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 24 95% 65%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 24 95% 58%; +} + +.midnight { + --primary: 230 75% 55%; + --primary-foreground: 210 40% 98%; + --ring: 230 75% 55%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 230 75% 55%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 230 30% 94%; + --sidebar-accent-foreground: 230 50% 20%; + --sidebar-border: 230 20% 88%; + --sidebar-ring: 230 75% 55%; +} + +.dark.midnight { + --primary: 230 75% 62%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 230 75% 62%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 230 75% 65%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 230 75% 62%; +} + +.rose { + --primary: 346 77% 50%; + --primary-foreground: 210 40% 98%; + --ring: 346 77% 50%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 346 77% 50%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 346 30% 94%; + --sidebar-accent-foreground: 346 50% 20%; + --sidebar-border: 346 20% 88%; + --sidebar-ring: 346 77% 50%; +} + +.dark.rose { + --primary: 346 77% 58%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 346 77% 58%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 346 77% 60%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 346 77% 58%; +} + +.amber { + --primary: 38 92% 50%; + --primary-foreground: 210 40% 98%; + --ring: 38 92% 50%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 38 92% 50%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 38 30% 94%; + --sidebar-accent-foreground: 38 50% 20%; + --sidebar-border: 38 20% 88%; + --sidebar-ring: 38 92% 50%; +} + +.dark.amber { + --primary: 38 92% 56%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 38 92% 56%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 38 92% 60%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 38 92% 56%; +} + +.violet { + --primary: 262 83% 58%; + --primary-foreground: 210 40% 98%; + --ring: 262 83% 58%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 262 83% 58%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 262 30% 94%; + --sidebar-accent-foreground: 262 50% 20%; + --sidebar-border: 262 20% 88%; + --sidebar-ring: 262 83% 58%; +} + +.dark.violet { + --primary: 262 83% 65%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 262 83% 65%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 262 83% 68%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 262 83% 65%; +} + +.slate { + --primary: 215 20% 45%; + --primary-foreground: 210 40% 98%; + --ring: 215 20% 45%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 215 20% 45%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 215 20% 94%; + --sidebar-accent-foreground: 215 50% 20%; + --sidebar-border: 215 20% 88%; + --sidebar-ring: 215 20% 45%; +} + +.dark.slate { + --primary: 215 20% 60%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 215 20% 60%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 215 20% 65%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 215 20% 60%; +} + +.ruby { + --primary: 351 85% 45%; + --primary-foreground: 210 40% 98%; + --ring: 351 85% 45%; + --sidebar: 0 0% 100%; + --sidebar-foreground: 222.2 84% 4.9%; + --sidebar-primary: 351 85% 45%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 351 30% 94%; + --sidebar-accent-foreground: 351 50% 20%; + --sidebar-border: 351 20% 88%; + --sidebar-ring: 351 85% 45%; +} + +.dark.ruby { + --primary: 351 85% 52%; + --primary-foreground: 222.2 47.4% 11.2%; + --ring: 351 85% 52%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 351 85% 55%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 351 85% 52%; +} + @layer base { * { @apply border-border; diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index b28720c..f1bbebb 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -134,7 +134,7 @@ export function Topbar({ onMenuClick }: TopbarProps) { > - 3 + {unreadCount} diff --git a/src/components/settings/company-settings-form.tsx b/src/components/settings/company-settings-form.tsx index bc20de3..38dbaa1 100644 --- a/src/components/settings/company-settings-form.tsx +++ b/src/components/settings/company-settings-form.tsx @@ -1,13 +1,72 @@ "use client" +import { useEffect, useState } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Button } from "@/components/ui/button" -import { COMPANY_NAME } from "@/lib/constants" import { toast } from "sonner" +interface CompanyData { + companyName: string + companyEmail: string + companyPhone: string + companyWebsite: string + companyAddress: string +} + export function CompanySettingsForm() { + const [data, setData] = useState({ + companyName: "", + companyEmail: "", + companyPhone: "", + companyWebsite: "", + companyAddress: "", + }) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + async function load() { + try { + const res = await fetch("/api/settings/company") + if (res.ok) { + const json = await res.json() + setData(json) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + load() + }, []) + + async function handleSave() { + setSaving(true) + try { + const res = await fetch("/api/settings/company", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + if (res.ok) { + toast.success("Company settings saved") + } else { + toast.error("Failed to save company settings") + } + } catch { + toast.error("Failed to save company settings") + } finally { + setSaving(false) + } + } + + function update(field: keyof CompanyData, value: string) { + setData((prev) => ({ ...prev, [field]: value })) + } + return ( @@ -20,27 +79,29 @@ export function CompanySettingsForm() {
- + update("companyName", e.target.value)} />
- + update("companyEmail", e.target.value)} />
- + update("companyPhone", e.target.value)} />
- + update("companyWebsite", e.target.value)} />
- + update("companyAddress", e.target.value)} />
- +
diff --git a/src/components/settings/notification-settings.tsx b/src/components/settings/notification-settings.tsx index 80d092c..60d2cfc 100644 --- a/src/components/settings/notification-settings.tsx +++ b/src/components/settings/notification-settings.tsx @@ -1,45 +1,82 @@ "use client" +import { useEffect, useState } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { Button } from "@/components/ui/button" import { toast } from "sonner" +interface Preferences { + leadAssigned: boolean + leadStatus: boolean + noteAdded: boolean + dailyDigest: boolean + weeklyReport: boolean +} + +const defaultPreferences: Preferences = { + leadAssigned: true, + leadStatus: true, + noteAdded: false, + dailyDigest: false, + weeklyReport: true, +} + const notifications = [ - { - id: "lead-assigned", - title: "Lead Assigned", - description: "When a lead is assigned to you", - defaultChecked: true, - }, - { - id: "lead-status", - title: "Status Changes", - description: "When a lead's status is updated", - defaultChecked: true, - }, - { - id: "note-added", - title: "Note Added", - description: "When a note is added to your lead", - defaultChecked: false, - }, - { - id: "daily-digest", - title: "Daily Digest", - description: "Receive a daily summary of lead activity", - defaultChecked: false, - }, - { - id: "weekly-report", - title: "Weekly Report", - description: "Receive a weekly performance report", - defaultChecked: true, - }, + { id: "leadAssigned", title: "Lead Assigned", description: "When a lead is assigned to you" }, + { id: "leadStatus", title: "Status Changes", description: "When a lead's status is updated" }, + { id: "noteAdded", title: "Note Added", description: "When a note is added to your lead" }, + { id: "dailyDigest", title: "Daily Digest", description: "Receive a daily summary of lead activity" }, + { id: "weeklyReport", title: "Weekly Report", description: "Receive a weekly performance report" }, ] export function NotificationSettings() { + const [prefs, setPrefs] = useState(defaultPreferences) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + async function load() { + try { + const res = await fetch("/api/notifications/preferences") + if (res.ok) { + const data = await res.json() + setPrefs(data) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + load() + }, []) + + async function handleSave() { + setSaving(true) + try { + const res = await fetch("/api/notifications/preferences", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(prefs), + }) + if (res.ok) { + toast.success("Notification preferences saved") + } else { + toast.error("Failed to save preferences") + } + } catch { + toast.error("Failed to save preferences") + } finally { + setSaving(false) + } + } + + function toggle(key: keyof Preferences) { + setPrefs((prev) => ({ ...prev, [key]: !prev[key] })) + } + return ( @@ -60,11 +97,18 @@ export function NotificationSettings() {

{n.description}

- + toggle(n.id as keyof Preferences)} + /> ))}
- +
diff --git a/src/components/settings/theme-settings.tsx b/src/components/settings/theme-settings.tsx index c70d9f4..3879277 100644 --- a/src/components/settings/theme-settings.tsx +++ b/src/components/settings/theme-settings.tsx @@ -1,5 +1,6 @@ "use client" +import { useEffect, useState } from "react" import { useTheme } from "next-themes" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Label } from "@/components/ui/label" @@ -7,42 +8,117 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { cn } from "@/lib/utils" import { Sun, Moon, Monitor } from "lucide-react" +const COLOR_THEME_KEY = "crm-color-theme" + +const modeOptions = [ + { value: "light", icon: Sun, label: "Light" }, + { value: "dark", icon: Moon, label: "Dark" }, + { value: "system", icon: Monitor, label: "System" }, +] + +const themeOptions = [ + { value: "default", label: "Default", color: "bg-blue-600", ring: "ring-blue-600" }, + { value: "ocean", label: "Ocean", color: "bg-teal-500", ring: "ring-teal-500" }, + { value: "forest", label: "Forest", color: "bg-green-600", ring: "ring-green-600" }, + { value: "sunset", label: "Sunset", color: "bg-orange-500", ring: "ring-orange-500" }, + { value: "midnight", label: "Midnight", color: "bg-indigo-600", ring: "ring-indigo-600" }, + { value: "rose", label: "Rose", color: "bg-pink-600", ring: "ring-pink-600" }, + { value: "amber", label: "Amber", color: "bg-amber-500", ring: "ring-amber-500" }, + { value: "violet", label: "Violet", color: "bg-violet-600", ring: "ring-violet-600" }, + { value: "slate", label: "Slate", color: "bg-slate-500", ring: "ring-slate-500" }, + { value: "ruby", label: "Ruby", color: "bg-red-600", ring: "ring-red-600" }, +] + +function getStoredColorTheme(): string { + if (typeof window === "undefined") return "default" + return localStorage.getItem(COLOR_THEME_KEY) || "default" +} + +function applyColorTheme(theme: string) { + document.documentElement.className = document.documentElement.className + .replace(/\b(default|ocean|forest|sunset|midnight|rose|amber|violet|slate|ruby)\b/g, "") + .trim() + if (theme !== "default") { + document.documentElement.classList.add(theme) + } + localStorage.setItem(COLOR_THEME_KEY, theme) +} + export function ThemeSettings() { const { theme, setTheme } = useTheme() + const [colorTheme, setColorTheme] = useState("default") + const [mounted, setMounted] = useState(false) - const options = [ - { value: "light", icon: Sun, label: "Light" }, - { value: "dark", icon: Moon, label: "Dark" }, - { value: "system", icon: Monitor, label: "System" }, - ] + useEffect(() => { + setMounted(true) + setColorTheme(getStoredColorTheme()) + applyColorTheme(getStoredColorTheme()) + }, []) + + function handleColorChange(value: string) { + setColorTheme(value) + applyColorTheme(value) + } + + if (!mounted) return null return ( - - - Theme Settings - - Choose your preferred appearance for the dashboard. - - - - - {options.map(({ value, icon: Icon, label }) => ( -
- - -
- ))} -
-
-
+
+ + + Theme Mode + + Choose your preferred appearance mode. + + + + + {modeOptions.map(({ value, icon: Icon, label }) => ( +
+ + +
+ ))} +
+
+
+ + + + Color Theme + + Select a color accent for the dashboard. + + + + + {themeOptions.map(({ value, label, color, ring }) => ( +
+ +
) } diff --git a/src/components/settings/user-preferences-form.tsx b/src/components/settings/user-preferences-form.tsx index 68d2481..3e419aa 100644 --- a/src/components/settings/user-preferences-form.tsx +++ b/src/components/settings/user-preferences-form.tsx @@ -1,5 +1,6 @@ "use client" +import { useEffect, useState } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Label } from "@/components/ui/label" import { Button } from "@/components/ui/button" @@ -12,7 +13,58 @@ import { SelectValue, } from "@/components/ui/select" +interface Preferences { + timezone: string + dateFormat: string + itemsPerPage: number +} + export function UserPreferencesForm() { + const [prefs, setPrefs] = useState({ + timezone: "america-los_angeles", + dateFormat: "mdy", + itemsPerPage: 20, + }) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + async function load() { + try { + const res = await fetch("/api/settings/preferences") + if (res.ok) { + const json = await res.json() + setPrefs(json) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + load() + }, []) + + async function handleSave() { + setSaving(true) + try { + const res = await fetch("/api/settings/preferences", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(prefs), + }) + if (res.ok) { + toast.success("Preferences saved") + } else { + toast.error("Failed to save preferences") + } + } catch { + toast.error("Failed to save preferences") + } finally { + setSaving(false) + } + } + return ( @@ -25,7 +77,7 @@ export function UserPreferencesForm() {
- setPrefs((p) => ({ ...p, timezone: v }))}> @@ -39,7 +91,7 @@ export function UserPreferencesForm() {
- setPrefs((p) => ({ ...p, dateFormat: v }))}> @@ -52,7 +104,7 @@ export function UserPreferencesForm() {
- setPrefs((p) => ({ ...p, itemsPerPage: parseInt(v) }))}> @@ -66,7 +118,9 @@ export function UserPreferencesForm() {
- +
diff --git a/src/providers/notification-provider.tsx b/src/providers/notification-provider.tsx index 2275e51..fb28489 100644 --- a/src/providers/notification-provider.tsx +++ b/src/providers/notification-provider.tsx @@ -2,7 +2,6 @@ import { createContext, useContext, useState, useCallback, useMemo, useEffect, ReactNode } from "react" import { Notification, NotificationType } from "@/types" -import { leads } from "@/data/leads" interface NotificationContextValue { notifications: Notification[] @@ -16,62 +15,29 @@ interface NotificationContextValue { const NotificationContext = createContext(null) -function generateId(): string { - return `notif-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` -} - -function seedInitialNotifications(): Notification[] { - const result: Notification[] = [] - - const recentLeads = leads.slice(0, 5) - recentLeads.forEach((lead) => { - result.push({ - id: generateId(), - type: "lead_created", - title: "New Lead Created", - description: `${lead.companyName} — ${lead.contactName}`, - timestamp: lead.createdAt, - read: false, - link: `/leads/${lead.id}`, - }) - }) - - const statusChanged = leads.filter((l) => l.status !== "open").slice(0, 3) - statusChanged.forEach((lead) => { - result.push({ - id: generateId(), - type: "lead_status_changed", - title: "Lead Status Updated", - description: `${lead.companyName} moved to ${lead.status}`, - timestamp: lead.updatedAt, - read: false, - link: `/leads/${lead.id}`, - }) - }) - - const assignedLeads = leads.filter((l) => l.assignedUser).slice(0, 3) - assignedLeads.forEach((lead) => { - result.push({ - id: generateId(), - type: "lead_assigned", - title: "Lead Assigned", - description: `${lead.companyName} assigned to ${lead.assignedUser!.name}`, - timestamp: lead.updatedAt, - read: false, - link: `/leads/${lead.id}`, - }) - }) - - result.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - - return result -} - export function NotificationProvider({ children }: { children: ReactNode }) { - const [notifications, setNotifications] = useState(seedInitialNotifications) - + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(true) const [unreadChatCount, setUnreadChatCount] = useState(0) + useEffect(() => { + async function fetchNotifications() { + try { + const res = await fetch("/api/notifications") + if (!res.ok) return + const data = await res.json() + if (data.notifications) { + setNotifications(data.notifications) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + fetchNotifications() + }, []) + useEffect(() => { let cancelled = false async function fetchUnread() { @@ -99,9 +65,9 @@ export function NotificationProvider({ children }: { children: ReactNode }) { ) const addNotification = useCallback( - (type: NotificationType, title: string, description: string, link?: string) => { + async (type: NotificationType, title: string, description: string, link?: string) => { const notif: Notification = { - id: generateId(), + id: `temp-${Date.now()}`, type, title, description, @@ -110,22 +76,48 @@ export function NotificationProvider({ children }: { children: ReactNode }) { link, } setNotifications((prev) => [notif, ...prev]) + try { + await fetch("/api/notifications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, title, description, link }), + }) + } catch { + // ignore + } }, [] ) - const markAsRead = useCallback((id: string) => { + const markAsRead = useCallback(async (id: string) => { + if (id.startsWith("temp-")) return setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)) ) + try { + await fetch(`/api/notifications/${id}`, { method: "PATCH" }) + } catch { + // ignore + } }, []) - const markAllAsRead = useCallback(() => { + const markAllAsRead = useCallback(async () => { setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + try { + await fetch("/api/notifications", { method: "PATCH" }) + } catch { + // ignore + } }, []) - const dismiss = useCallback((id: string) => { + const dismiss = useCallback(async (id: string) => { setNotifications((prev) => prev.filter((n) => n.id !== id)) + if (id.startsWith("temp-")) return + try { + await fetch(`/api/notifications/${id}`, { method: "DELETE" }) + } catch { + // ignore + } }, []) return ( diff --git a/src/providers/theme-provider.tsx b/src/providers/theme-provider.tsx index b912564..027eb3b 100644 --- a/src/providers/theme-provider.tsx +++ b/src/providers/theme-provider.tsx @@ -4,5 +4,12 @@ import { ThemeProvider as NextThemesProvider } from "next-themes" import { type ThemeProviderProps } from "next-themes" export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return {children} + return ( + + {children} + + ) }