ppEmiliano Claude Opus 4.6 commited on
Commit
ea8dde3
·
1 Parent(s): bc07eff

Add full CRM application with HF Spaces Docker deployment

Browse files

Includes CRM features (contacts, companies, deals, pipelines, activities),
NextAuth authentication, SQLite database with seed data, and Docker
configuration for Hugging Face Spaces deployment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +15 -0
  2. .gitignore +5 -0
  3. Dockerfile +69 -0
  4. README.md +1 -0
  5. components.json +23 -0
  6. drizzle.config.ts +10 -0
  7. next.config.ts +2 -1
  8. package-lock.json +0 -0
  9. package.json +31 -2
  10. src/app/(app)/activities/page.tsx +147 -0
  11. src/app/(app)/companies/[id]/page.tsx +128 -0
  12. src/app/(app)/companies/page.tsx +192 -0
  13. src/app/(app)/contacts/[id]/page.tsx +153 -0
  14. src/app/(app)/contacts/page.tsx +229 -0
  15. src/app/(app)/dashboard/page.tsx +209 -0
  16. src/app/(app)/deals/[id]/page.tsx +172 -0
  17. src/app/(app)/deals/page.tsx +75 -0
  18. src/app/(app)/layout.tsx +22 -0
  19. src/app/(app)/settings/page.tsx +159 -0
  20. src/app/(auth)/login/page.tsx +78 -0
  21. src/app/api/activities/[id]/route.ts +41 -0
  22. src/app/api/activities/route.ts +76 -0
  23. src/app/api/auth/[...nextauth]/route.ts +2 -0
  24. src/app/api/companies/[id]/route.ts +56 -0
  25. src/app/api/companies/route.ts +94 -0
  26. src/app/api/contacts/[id]/route.ts +68 -0
  27. src/app/api/contacts/route.ts +80 -0
  28. src/app/api/dashboard/route.ts +106 -0
  29. src/app/api/deals/[id]/route.ts +90 -0
  30. src/app/api/deals/reorder/route.ts +76 -0
  31. src/app/api/deals/route.ts +83 -0
  32. src/app/api/pipelines/route.ts +19 -0
  33. src/app/api/search/route.ts +68 -0
  34. src/app/api/stages/[id]/route.ts +55 -0
  35. src/app/api/stages/route.ts +42 -0
  36. src/app/globals.css +113 -13
  37. src/app/layout.tsx +4 -2
  38. src/app/page.tsx +2 -62
  39. src/components/activities/activity-form.tsx +101 -0
  40. src/components/activities/activity-item.tsx +88 -0
  41. src/components/deals/deal-form.tsx +162 -0
  42. src/components/deals/kanban-board.tsx +233 -0
  43. src/components/deals/kanban-card.tsx +106 -0
  44. src/components/deals/kanban-column.tsx +58 -0
  45. src/components/layout/sidebar.tsx +77 -0
  46. src/components/layout/topbar.tsx +50 -0
  47. src/components/shared/confirm-dialog.tsx +54 -0
  48. src/components/shared/data-table.tsx +132 -0
  49. src/components/shared/global-search.tsx +113 -0
  50. src/components/ui/avatar.tsx +109 -0
.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ .git
4
+ .gitignore
5
+ .env*
6
+ .DS_Store
7
+ .vercel
8
+ coverage
9
+ data/*.db
10
+ data/*.db-wal
11
+ data/*.db-shm
12
+ npm-debug.log*
13
+ yarn-debug.log*
14
+ .pnpm-debug.log*
15
+ *.tsbuildinfo
.gitignore CHANGED
@@ -39,3 +39,8 @@ yarn-error.log*
39
  # typescript
40
  *.tsbuildinfo
41
  next-env.d.ts
 
 
 
 
 
 
39
  # typescript
40
  *.tsbuildinfo
41
  next-env.d.ts
42
+
43
+ # sqlite database
44
+ /data/*.db
45
+ /data/*.db-wal
46
+ /data/*.db-shm
Dockerfile ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================
2
+ # Stage 1: Install dependencies
3
+ # ============================================
4
+ FROM node:20-slim AS dependencies
5
+
6
+ WORKDIR /app
7
+
8
+ # Install build tools needed for better-sqlite3 native compilation
9
+ RUN apt-get update && apt-get install -y \
10
+ python3 \
11
+ make \
12
+ g++ \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ COPY package.json package-lock.json ./
16
+
17
+ RUN npm ci
18
+
19
+ # ============================================
20
+ # Stage 2: Build the Next.js application
21
+ # ============================================
22
+ FROM node:20-slim AS builder
23
+
24
+ WORKDIR /app
25
+
26
+ COPY --from=dependencies /app/node_modules ./node_modules
27
+ COPY . .
28
+
29
+ ENV NODE_ENV=production
30
+ ENV NEXT_TELEMETRY_DISABLED=1
31
+
32
+ # Seed the database at build time so the image ships with data.
33
+ # The seed script creates ./data/crm.db if it does not exist.
34
+ RUN npx tsx src/lib/db/seed.ts
35
+
36
+ RUN npm run build
37
+
38
+ # ============================================
39
+ # Stage 3: Production runner
40
+ # ============================================
41
+ FROM node:20-slim AS runner
42
+
43
+ WORKDIR /app
44
+
45
+ ENV NODE_ENV=production
46
+ ENV NEXT_TELEMETRY_DISABLED=1
47
+ ENV PORT=3000
48
+ ENV HOSTNAME="0.0.0.0"
49
+
50
+ # Copy public assets (not included in standalone output)
51
+ COPY --from=builder --chown=node:node /app/public ./public
52
+
53
+ # Create .next directory owned by node for prerender cache
54
+ RUN mkdir -p .next && chown node:node .next
55
+
56
+ # Copy standalone server + static assets
57
+ COPY --from=builder --chown=node:node /app/.next/standalone ./
58
+ COPY --from=builder --chown=node:node /app/.next/static ./.next/static
59
+
60
+ # Copy the seeded SQLite database
61
+ COPY --from=builder --chown=node:node /app/data ./data
62
+
63
+ # HF Spaces runs containers as UID 1000.
64
+ # The node user in official Node images is already UID 1000.
65
+ USER node
66
+
67
+ EXPOSE 3000
68
+
69
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -6,6 +6,7 @@ colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  short_description: 'pipedrive clone '
 
9
  ---
10
 
11
  This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
 
6
  sdk: docker
7
  pinned: false
8
  short_description: 'pipedrive clone '
9
+ app_port: 3000
10
  ---
11
 
12
  This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
components.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "rtl": false,
15
+ "aliases": {
16
+ "components": "@/components",
17
+ "utils": "@/lib/utils",
18
+ "ui": "@/components/ui",
19
+ "lib": "@/lib",
20
+ "hooks": "@/hooks"
21
+ },
22
+ "registries": {}
23
+ }
drizzle.config.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/lib/db/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "sqlite",
7
+ dbCredentials: {
8
+ url: "./data/crm.db",
9
+ },
10
+ });
next.config.ts CHANGED
@@ -1,7 +1,8 @@
1
  import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
- /* config options here */
 
5
  };
6
 
7
  export default nextConfig;
 
1
  import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
+ output: "standalone",
5
+ serverExternalPackages: ["better-sqlite3"],
6
  };
7
 
8
  export default nextConfig;
package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -6,21 +6,50 @@
6
  "dev": "next dev",
7
  "build": "next build",
8
  "start": "next start",
9
- "lint": "eslint"
 
 
 
10
  },
11
  "dependencies": {
 
 
 
 
 
 
 
 
 
 
 
 
12
  "next": "16.1.6",
 
 
 
13
  "react": "19.2.3",
14
- "react-dom": "19.2.3"
 
 
 
 
 
15
  },
16
  "devDependencies": {
17
  "@tailwindcss/postcss": "^4",
 
 
18
  "@types/node": "^20",
19
  "@types/react": "^19",
20
  "@types/react-dom": "^19",
 
21
  "eslint": "^9",
22
  "eslint-config-next": "16.1.6",
 
23
  "tailwindcss": "^4",
 
 
24
  "typescript": "^5"
25
  }
26
  }
 
6
  "dev": "next dev",
7
  "build": "next build",
8
  "start": "next start",
9
+ "lint": "eslint",
10
+ "seed": "npx tsx src/lib/db/seed.ts",
11
+ "db:generate": "drizzle-kit generate",
12
+ "db:migrate": "drizzle-kit migrate"
13
  },
14
  "dependencies": {
15
+ "@dnd-kit/core": "^6.3.1",
16
+ "@dnd-kit/sortable": "^10.0.0",
17
+ "@dnd-kit/utilities": "^3.2.2",
18
+ "@tanstack/react-table": "^8.21.3",
19
+ "bcryptjs": "^3.0.3",
20
+ "better-sqlite3": "^12.6.2",
21
+ "class-variance-authority": "^0.7.1",
22
+ "clsx": "^2.1.1",
23
+ "cmdk": "^1.1.1",
24
+ "date-fns": "^4.1.0",
25
+ "drizzle-orm": "^0.45.1",
26
+ "lucide-react": "^0.575.0",
27
  "next": "16.1.6",
28
+ "next-auth": "^5.0.0-beta.30",
29
+ "next-themes": "^0.4.6",
30
+ "radix-ui": "^1.4.3",
31
  "react": "19.2.3",
32
+ "react-day-picker": "^9.14.0",
33
+ "react-dom": "19.2.3",
34
+ "recharts": "^3.7.0",
35
+ "sonner": "^2.0.7",
36
+ "tailwind-merge": "^3.5.0",
37
+ "zod": "^4.3.6"
38
  },
39
  "devDependencies": {
40
  "@tailwindcss/postcss": "^4",
41
+ "@types/bcryptjs": "^2.4.6",
42
+ "@types/better-sqlite3": "^7.6.13",
43
  "@types/node": "^20",
44
  "@types/react": "^19",
45
  "@types/react-dom": "^19",
46
+ "drizzle-kit": "^0.31.9",
47
  "eslint": "^9",
48
  "eslint-config-next": "16.1.6",
49
+ "shadcn": "^3.8.5",
50
  "tailwindcss": "^4",
51
+ "tsx": "^4.21.0",
52
+ "tw-animate-css": "^1.4.0",
53
  "typescript": "^5"
54
  }
55
  }
src/app/(app)/activities/page.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { ActivityItem } from "@/components/activities/activity-item";
7
+ import { ActivityForm } from "@/components/activities/activity-form";
8
+ import { Plus, Phone, Mail, Calendar, CheckSquare, StickyNote } from "lucide-react";
9
+ import { toast } from "sonner";
10
+
11
+ const typeFilters = [
12
+ { value: "", label: "All", icon: null },
13
+ { value: "call", label: "Calls", icon: Phone },
14
+ { value: "email", label: "Emails", icon: Mail },
15
+ { value: "meeting", label: "Meetings", icon: Calendar },
16
+ { value: "task", label: "Tasks", icon: CheckSquare },
17
+ { value: "note", label: "Notes", icon: StickyNote },
18
+ ];
19
+
20
+ export default function ActivitiesPage() {
21
+ const [activities, setActivities] = useState<any[]>([]);
22
+ const [typeFilter, setTypeFilter] = useState("");
23
+ const [doneFilter, setDoneFilter] = useState<string>("");
24
+ const [showForm, setShowForm] = useState(false);
25
+
26
+ const load = useCallback(async () => {
27
+ const params = new URLSearchParams();
28
+ if (typeFilter) params.set("type", typeFilter);
29
+ if (doneFilter !== "") params.set("isDone", doneFilter);
30
+ const res = await fetch(`/api/activities?${params}`);
31
+ if (res.ok) setActivities(await res.json());
32
+ }, [typeFilter, doneFilter]);
33
+
34
+ useEffect(() => { load(); }, [load]);
35
+
36
+ // Group by date
37
+ const grouped = activities.reduce<Record<string, any[]>>((acc, a) => {
38
+ const date = a.dueDate || "No date";
39
+ if (!acc[date]) acc[date] = [];
40
+ acc[date].push(a);
41
+ return acc;
42
+ }, {});
43
+
44
+ const sortedDates = Object.keys(grouped).sort((a, b) => {
45
+ if (a === "No date") return 1;
46
+ if (b === "No date") return -1;
47
+ return b.localeCompare(a);
48
+ });
49
+
50
+ return (
51
+ <div>
52
+ <div className="flex items-center justify-between mb-4">
53
+ <h1 className="text-2xl font-bold">Activities</h1>
54
+ <Button onClick={() => setShowForm(true)}>
55
+ <Plus className="h-4 w-4 mr-2" />
56
+ Add Activity
57
+ </Button>
58
+ </div>
59
+
60
+ <div className="flex flex-wrap gap-2 mb-4">
61
+ {typeFilters.map((f) => (
62
+ <Button
63
+ key={f.value}
64
+ variant={typeFilter === f.value ? "default" : "outline"}
65
+ size="sm"
66
+ onClick={() => setTypeFilter(f.value)}
67
+ >
68
+ {f.icon && <f.icon className="h-3 w-3 mr-1" />}
69
+ {f.label}
70
+ </Button>
71
+ ))}
72
+ <div className="w-px bg-border mx-1" />
73
+ <Button
74
+ variant={doneFilter === "" ? "default" : "outline"}
75
+ size="sm"
76
+ onClick={() => setDoneFilter("")}
77
+ >
78
+ All
79
+ </Button>
80
+ <Button
81
+ variant={doneFilter === "false" ? "default" : "outline"}
82
+ size="sm"
83
+ onClick={() => setDoneFilter("false")}
84
+ >
85
+ Pending
86
+ </Button>
87
+ <Button
88
+ variant={doneFilter === "true" ? "default" : "outline"}
89
+ size="sm"
90
+ onClick={() => setDoneFilter("true")}
91
+ >
92
+ Done
93
+ </Button>
94
+ </div>
95
+
96
+ {showForm && (
97
+ <div className="mb-6">
98
+ <ActivityForm
99
+ onSuccess={() => {
100
+ setShowForm(false);
101
+ load();
102
+ toast.success("Activity created");
103
+ }}
104
+ onCancel={() => setShowForm(false)}
105
+ />
106
+ </div>
107
+ )}
108
+
109
+ <div className="space-y-6">
110
+ {sortedDates.map((date) => (
111
+ <div key={date}>
112
+ <h3 className="text-sm font-medium text-muted-foreground mb-2">
113
+ {date === "No date"
114
+ ? "No date"
115
+ : new Date(date + "T00:00:00").toLocaleDateString("en-US", {
116
+ weekday: "long",
117
+ month: "long",
118
+ day: "numeric",
119
+ year: "numeric",
120
+ })}
121
+ </h3>
122
+ <div className="space-y-2">
123
+ {grouped[date].map((a: any) => (
124
+ <ActivityItem
125
+ key={a.id}
126
+ activity={a}
127
+ onToggle={async () => {
128
+ await fetch(`/api/activities/${a.id}`, {
129
+ method: "PATCH",
130
+ headers: { "Content-Type": "application/json" },
131
+ body: JSON.stringify({ isDone: !a.isDone }),
132
+ });
133
+ load();
134
+ toast.success(a.isDone ? "Marked as pending" : "Marked as done");
135
+ }}
136
+ />
137
+ ))}
138
+ </div>
139
+ </div>
140
+ ))}
141
+ {activities.length === 0 && (
142
+ <p className="text-center text-muted-foreground py-8">No activities found.</p>
143
+ )}
144
+ </div>
145
+ </div>
146
+ );
147
+ }
src/app/(app)/companies/[id]/page.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { Separator } from "@/components/ui/separator";
9
+ import { ArrowLeft, Globe, Phone, MapPin, Users, Handshake } from "lucide-react";
10
+ import { formatCurrency } from "@/lib/utils";
11
+ import Link from "next/link";
12
+
13
+ export default function CompanyDetailPage() {
14
+ const { id } = useParams();
15
+ const router = useRouter();
16
+ const [company, setCompany] = useState<any>(null);
17
+ const [contacts, setContacts] = useState<any[]>([]);
18
+ const [deals, setDeals] = useState<any[]>([]);
19
+
20
+ useEffect(() => {
21
+ fetch(`/api/companies/${id}`).then((r) => r.ok && r.json()).then(setCompany);
22
+ fetch(`/api/contacts?companyId=${id}&limit=100`).then((r) => r.ok && r.json()).then((d) => setContacts(d?.data || []));
23
+ fetch(`/api/deals`).then((r) => r.ok && r.json()).then((d) =>
24
+ setDeals((d || []).filter((deal: any) => deal.companyId === parseInt(id as string)))
25
+ );
26
+ }, [id]);
27
+
28
+ if (!company) return <div>Loading...</div>;
29
+
30
+ return (
31
+ <div className="max-w-4xl">
32
+ <Button variant="ghost" size="sm" onClick={() => router.back()} className="mb-4">
33
+ <ArrowLeft className="h-4 w-4 mr-2" />
34
+ Back
35
+ </Button>
36
+
37
+ <h1 className="text-2xl font-bold mb-6">{company.name}</h1>
38
+
39
+ <Card className="mb-6">
40
+ <CardContent className="pt-6 space-y-3">
41
+ {company.domain && (
42
+ <div className="flex items-center gap-2">
43
+ <Globe className="h-4 w-4 text-muted-foreground" />
44
+ <span>{company.domain}</span>
45
+ </div>
46
+ )}
47
+ {company.phone && (
48
+ <div className="flex items-center gap-2">
49
+ <Phone className="h-4 w-4 text-muted-foreground" />
50
+ <span>{company.phone}</span>
51
+ </div>
52
+ )}
53
+ {company.address && (
54
+ <div className="flex items-center gap-2">
55
+ <MapPin className="h-4 w-4 text-muted-foreground" />
56
+ <span>{company.address}</span>
57
+ </div>
58
+ )}
59
+ {company.notes && (
60
+ <p className="text-sm text-muted-foreground">{company.notes}</p>
61
+ )}
62
+ </CardContent>
63
+ </Card>
64
+
65
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
66
+ <Card>
67
+ <CardHeader>
68
+ <CardTitle className="flex items-center gap-2 text-base">
69
+ <Users className="h-4 w-4" />
70
+ Contacts ({contacts.length})
71
+ </CardTitle>
72
+ </CardHeader>
73
+ <CardContent>
74
+ {contacts.length === 0 ? (
75
+ <p className="text-sm text-muted-foreground">No contacts</p>
76
+ ) : (
77
+ <div className="space-y-2">
78
+ {contacts.map((c: any) => (
79
+ <Link
80
+ key={c.id}
81
+ href={`/contacts/${c.id}`}
82
+ className="block p-2 rounded hover:bg-muted"
83
+ >
84
+ <p className="text-sm font-medium">{c.firstName} {c.lastName}</p>
85
+ <p className="text-xs text-muted-foreground">{c.jobTitle || c.email}</p>
86
+ </Link>
87
+ ))}
88
+ </div>
89
+ )}
90
+ </CardContent>
91
+ </Card>
92
+
93
+ <Card>
94
+ <CardHeader>
95
+ <CardTitle className="flex items-center gap-2 text-base">
96
+ <Handshake className="h-4 w-4" />
97
+ Deals ({deals.length})
98
+ </CardTitle>
99
+ </CardHeader>
100
+ <CardContent>
101
+ {deals.length === 0 ? (
102
+ <p className="text-sm text-muted-foreground">No deals</p>
103
+ ) : (
104
+ <div className="space-y-2">
105
+ {deals.map((d: any) => (
106
+ <Link
107
+ key={d.id}
108
+ href={`/deals/${d.id}`}
109
+ className="flex items-center justify-between p-2 rounded hover:bg-muted"
110
+ >
111
+ <div>
112
+ <p className="text-sm font-medium">{d.title}</p>
113
+ <p className="text-xs text-muted-foreground">{d.stageName}</p>
114
+ </div>
115
+ <div className="text-right">
116
+ <p className="text-sm font-semibold">{formatCurrency(d.value)}</p>
117
+ <Badge variant="outline" className="text-xs">{d.status}</Badge>
118
+ </div>
119
+ </Link>
120
+ ))}
121
+ </div>
122
+ )}
123
+ </CardContent>
124
+ </Card>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
src/app/(app)/companies/page.tsx ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { ColumnDef } from "@tanstack/react-table";
6
+ import { DataTable } from "@/components/shared/data-table";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from "@/components/ui/dialog";
15
+ import { Label } from "@/components/ui/label";
16
+ import { Textarea } from "@/components/ui/textarea";
17
+ import { Plus, Search, Pencil, Trash2 } from "lucide-react";
18
+ import { toast } from "sonner";
19
+ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
20
+
21
+ type Company = {
22
+ id: number;
23
+ name: string;
24
+ domain: string | null;
25
+ phone: string | null;
26
+ address: string | null;
27
+ notes: string | null;
28
+ createdAt: string;
29
+ };
30
+
31
+ export default function CompaniesPage() {
32
+ const router = useRouter();
33
+ const [companies, setCompanies] = useState<Company[]>([]);
34
+ const [search, setSearch] = useState("");
35
+ const [showForm, setShowForm] = useState(false);
36
+ const [editing, setEditing] = useState<Company | null>(null);
37
+ const [deleteTarget, setDeleteTarget] = useState<Company | null>(null);
38
+ const [loading, setLoading] = useState(false);
39
+
40
+ const load = useCallback(async () => {
41
+ const res = await fetch(`/api/companies?search=${encodeURIComponent(search)}&limit=100`);
42
+ if (res.ok) {
43
+ const data = await res.json();
44
+ setCompanies(data.data);
45
+ }
46
+ }, [search]);
47
+
48
+ useEffect(() => { load(); }, [load]);
49
+
50
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
51
+ e.preventDefault();
52
+ setLoading(true);
53
+ const form = new FormData(e.currentTarget);
54
+ const data = {
55
+ name: form.get("name") as string,
56
+ domain: (form.get("domain") as string) || null,
57
+ phone: (form.get("phone") as string) || null,
58
+ address: (form.get("address") as string) || null,
59
+ notes: (form.get("notes") as string) || null,
60
+ };
61
+
62
+ const url = editing ? `/api/companies/${editing.id}` : "/api/companies";
63
+ const method = editing ? "PATCH" : "POST";
64
+ const res = await fetch(url, {
65
+ method,
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify(data),
68
+ });
69
+
70
+ setLoading(false);
71
+ if (res.ok) {
72
+ toast.success(editing ? "Company updated" : "Company created");
73
+ setShowForm(false);
74
+ setEditing(null);
75
+ load();
76
+ }
77
+ }
78
+
79
+ async function handleDelete() {
80
+ if (!deleteTarget) return;
81
+ const res = await fetch(`/api/companies/${deleteTarget.id}`, { method: "DELETE" });
82
+ if (res.ok) {
83
+ toast.success("Company deleted");
84
+ load();
85
+ }
86
+ setDeleteTarget(null);
87
+ }
88
+
89
+ const columns: ColumnDef<Company>[] = [
90
+ { accessorKey: "name", header: "Name" },
91
+ { accessorKey: "domain", header: "Domain" },
92
+ { accessorKey: "phone", header: "Phone" },
93
+ {
94
+ id: "actions",
95
+ cell: ({ row }) => (
96
+ <div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
97
+ <Button
98
+ variant="ghost"
99
+ size="icon"
100
+ className="h-8 w-8"
101
+ onClick={() => { setEditing(row.original); setShowForm(true); }}
102
+ >
103
+ <Pencil className="h-3 w-3" />
104
+ </Button>
105
+ <Button
106
+ variant="ghost"
107
+ size="icon"
108
+ className="h-8 w-8 text-destructive"
109
+ onClick={() => setDeleteTarget(row.original)}
110
+ >
111
+ <Trash2 className="h-3 w-3" />
112
+ </Button>
113
+ </div>
114
+ ),
115
+ },
116
+ ];
117
+
118
+ return (
119
+ <div>
120
+ <div className="flex items-center justify-between mb-4">
121
+ <h1 className="text-2xl font-bold">Companies</h1>
122
+ <Button onClick={() => { setEditing(null); setShowForm(true); }}>
123
+ <Plus className="h-4 w-4 mr-2" />
124
+ Add Company
125
+ </Button>
126
+ </div>
127
+
128
+ <div className="relative mb-4 max-w-sm">
129
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
130
+ <Input
131
+ placeholder="Search companies..."
132
+ value={search}
133
+ onChange={(e) => setSearch(e.target.value)}
134
+ className="pl-9"
135
+ />
136
+ </div>
137
+
138
+ <DataTable
139
+ columns={columns}
140
+ data={companies}
141
+ onRowClick={(row) => router.push(`/companies/${row.id}`)}
142
+ />
143
+
144
+ <Dialog open={showForm} onOpenChange={setShowForm}>
145
+ <DialogContent>
146
+ <DialogHeader>
147
+ <DialogTitle>{editing ? "Edit Company" : "New Company"}</DialogTitle>
148
+ </DialogHeader>
149
+ <form onSubmit={handleSubmit} className="space-y-4">
150
+ <div className="space-y-2">
151
+ <Label htmlFor="name">Name</Label>
152
+ <Input id="name" name="name" defaultValue={editing?.name} required />
153
+ </div>
154
+ <div className="space-y-2">
155
+ <Label htmlFor="domain">Domain</Label>
156
+ <Input id="domain" name="domain" defaultValue={editing?.domain || ""} />
157
+ </div>
158
+ <div className="space-y-2">
159
+ <Label htmlFor="phone">Phone</Label>
160
+ <Input id="phone" name="phone" defaultValue={editing?.phone || ""} />
161
+ </div>
162
+ <div className="space-y-2">
163
+ <Label htmlFor="address">Address</Label>
164
+ <Input id="address" name="address" defaultValue={editing?.address || ""} />
165
+ </div>
166
+ <div className="space-y-2">
167
+ <Label htmlFor="notes">Notes</Label>
168
+ <Textarea id="notes" name="notes" defaultValue={editing?.notes || ""} />
169
+ </div>
170
+ <div className="flex justify-end gap-2">
171
+ <Button type="button" variant="outline" onClick={() => setShowForm(false)}>
172
+ Cancel
173
+ </Button>
174
+ <Button type="submit" disabled={loading}>
175
+ {loading ? "Saving..." : editing ? "Update" : "Create"}
176
+ </Button>
177
+ </div>
178
+ </form>
179
+ </DialogContent>
180
+ </Dialog>
181
+
182
+ <ConfirmDialog
183
+ open={!!deleteTarget}
184
+ onOpenChange={() => setDeleteTarget(null)}
185
+ title="Delete Company"
186
+ description={`Are you sure you want to delete "${deleteTarget?.name}"? This cannot be undone.`}
187
+ onConfirm={handleDelete}
188
+ destructive
189
+ />
190
+ </div>
191
+ );
192
+ }
src/app/(app)/contacts/[id]/page.tsx ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { ArrowLeft, Mail, Phone, Briefcase, Building2, Handshake } from "lucide-react";
9
+ import { formatCurrency } from "@/lib/utils";
10
+ import { ActivityForm } from "@/components/activities/activity-form";
11
+ import { ActivityItem } from "@/components/activities/activity-item";
12
+ import Link from "next/link";
13
+ import { toast } from "sonner";
14
+
15
+ export default function ContactDetailPage() {
16
+ const { id } = useParams();
17
+ const router = useRouter();
18
+ const [contact, setContact] = useState<any>(null);
19
+ const [deals, setDeals] = useState<any[]>([]);
20
+ const [activities, setActivities] = useState<any[]>([]);
21
+ const [showActivityForm, setShowActivityForm] = useState(false);
22
+
23
+ function loadData() {
24
+ fetch(`/api/contacts/${id}`).then((r) => r.ok && r.json()).then(setContact);
25
+ fetch(`/api/deals`).then((r) => r.ok && r.json()).then((d) =>
26
+ setDeals((d || []).filter((deal: any) => deal.contactId === parseInt(id as string)))
27
+ );
28
+ fetch(`/api/activities?contactId=${id}`).then((r) => r.ok && r.json()).then(setActivities);
29
+ }
30
+
31
+ useEffect(() => { loadData(); }, [id]);
32
+
33
+ if (!contact) return <div>Loading...</div>;
34
+
35
+ return (
36
+ <div className="max-w-4xl">
37
+ <Button variant="ghost" size="sm" onClick={() => router.back()} className="mb-4">
38
+ <ArrowLeft className="h-4 w-4 mr-2" />
39
+ Back
40
+ </Button>
41
+
42
+ <h1 className="text-2xl font-bold mb-6">
43
+ {contact.firstName} {contact.lastName}
44
+ </h1>
45
+
46
+ <Card className="mb-6">
47
+ <CardContent className="pt-6 space-y-3">
48
+ {contact.email && (
49
+ <div className="flex items-center gap-2">
50
+ <Mail className="h-4 w-4 text-muted-foreground" />
51
+ <span>{contact.email}</span>
52
+ </div>
53
+ )}
54
+ {contact.phone && (
55
+ <div className="flex items-center gap-2">
56
+ <Phone className="h-4 w-4 text-muted-foreground" />
57
+ <span>{contact.phone}</span>
58
+ </div>
59
+ )}
60
+ {contact.jobTitle && (
61
+ <div className="flex items-center gap-2">
62
+ <Briefcase className="h-4 w-4 text-muted-foreground" />
63
+ <span>{contact.jobTitle}</span>
64
+ </div>
65
+ )}
66
+ {contact.companyName && (
67
+ <Link href={`/companies/${contact.companyId}`} className="flex items-center gap-2 hover:underline">
68
+ <Building2 className="h-4 w-4 text-muted-foreground" />
69
+ <span>{contact.companyName}</span>
70
+ </Link>
71
+ )}
72
+ </CardContent>
73
+ </Card>
74
+
75
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
76
+ <Card>
77
+ <CardHeader>
78
+ <CardTitle className="flex items-center gap-2 text-base">
79
+ <Handshake className="h-4 w-4" />
80
+ Deals ({deals.length})
81
+ </CardTitle>
82
+ </CardHeader>
83
+ <CardContent>
84
+ {deals.length === 0 ? (
85
+ <p className="text-sm text-muted-foreground">No deals</p>
86
+ ) : (
87
+ <div className="space-y-2">
88
+ {deals.map((d: any) => (
89
+ <Link
90
+ key={d.id}
91
+ href={`/deals/${d.id}`}
92
+ className="flex items-center justify-between p-2 rounded hover:bg-muted"
93
+ >
94
+ <div>
95
+ <p className="text-sm font-medium">{d.title}</p>
96
+ <p className="text-xs text-muted-foreground">{d.stageName}</p>
97
+ </div>
98
+ <div className="text-right">
99
+ <p className="text-sm font-semibold">{formatCurrency(d.value)}</p>
100
+ <Badge variant="outline" className="text-xs">{d.status}</Badge>
101
+ </div>
102
+ </Link>
103
+ ))}
104
+ </div>
105
+ )}
106
+ </CardContent>
107
+ </Card>
108
+
109
+ <Card>
110
+ <CardHeader className="flex flex-row items-center justify-between">
111
+ <CardTitle className="text-base">Activities</CardTitle>
112
+ <Button size="sm" onClick={() => setShowActivityForm(true)}>Add</Button>
113
+ </CardHeader>
114
+ <CardContent>
115
+ {showActivityForm && (
116
+ <div className="mb-4">
117
+ <ActivityForm
118
+ contactId={parseInt(id as string)}
119
+ onSuccess={() => {
120
+ setShowActivityForm(false);
121
+ loadData();
122
+ toast.success("Activity added");
123
+ }}
124
+ onCancel={() => setShowActivityForm(false)}
125
+ />
126
+ </div>
127
+ )}
128
+ {activities.length === 0 ? (
129
+ <p className="text-sm text-muted-foreground">No activities</p>
130
+ ) : (
131
+ <div className="space-y-2">
132
+ {activities.map((a: any) => (
133
+ <ActivityItem
134
+ key={a.id}
135
+ activity={a}
136
+ onToggle={async () => {
137
+ await fetch(`/api/activities/${a.id}`, {
138
+ method: "PATCH",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify({ isDone: !a.isDone }),
141
+ });
142
+ loadData();
143
+ }}
144
+ />
145
+ ))}
146
+ </div>
147
+ )}
148
+ </CardContent>
149
+ </Card>
150
+ </div>
151
+ </div>
152
+ );
153
+ }
src/app/(app)/contacts/page.tsx ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { ColumnDef } from "@tanstack/react-table";
6
+ import { DataTable } from "@/components/shared/data-table";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from "@/components/ui/dialog";
15
+ import { Label } from "@/components/ui/label";
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from "@/components/ui/select";
23
+ import { Plus, Search, Pencil, Trash2 } from "lucide-react";
24
+ import { toast } from "sonner";
25
+ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
26
+
27
+ type Contact = {
28
+ id: number;
29
+ firstName: string;
30
+ lastName: string;
31
+ email: string | null;
32
+ phone: string | null;
33
+ jobTitle: string | null;
34
+ companyId: number | null;
35
+ companyName: string | null;
36
+ createdAt: string;
37
+ };
38
+
39
+ export default function ContactsPage() {
40
+ const router = useRouter();
41
+ const [contacts, setContacts] = useState<Contact[]>([]);
42
+ const [companies, setCompanies] = useState<{ id: number; name: string }[]>([]);
43
+ const [search, setSearch] = useState("");
44
+ const [showForm, setShowForm] = useState(false);
45
+ const [editing, setEditing] = useState<Contact | null>(null);
46
+ const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
47
+ const [loading, setLoading] = useState(false);
48
+
49
+ const load = useCallback(async () => {
50
+ const res = await fetch(`/api/contacts?search=${encodeURIComponent(search)}&limit=100`);
51
+ if (res.ok) {
52
+ const data = await res.json();
53
+ setContacts(data.data);
54
+ }
55
+ }, [search]);
56
+
57
+ useEffect(() => { load(); }, [load]);
58
+
59
+ useEffect(() => {
60
+ fetch("/api/companies?limit=100").then((r) => r.ok && r.json()).then((d) => setCompanies(d?.data || []));
61
+ }, []);
62
+
63
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
64
+ e.preventDefault();
65
+ setLoading(true);
66
+ const form = new FormData(e.currentTarget);
67
+ const companyIdVal = form.get("companyId") as string;
68
+ const data = {
69
+ firstName: form.get("firstName") as string,
70
+ lastName: form.get("lastName") as string,
71
+ email: (form.get("email") as string) || null,
72
+ phone: (form.get("phone") as string) || null,
73
+ jobTitle: (form.get("jobTitle") as string) || null,
74
+ companyId: companyIdVal && companyIdVal !== "none" ? parseInt(companyIdVal) : null,
75
+ };
76
+
77
+ const url = editing ? `/api/contacts/${editing.id}` : "/api/contacts";
78
+ const method = editing ? "PATCH" : "POST";
79
+ const res = await fetch(url, {
80
+ method,
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify(data),
83
+ });
84
+
85
+ setLoading(false);
86
+ if (res.ok) {
87
+ toast.success(editing ? "Contact updated" : "Contact created");
88
+ setShowForm(false);
89
+ setEditing(null);
90
+ load();
91
+ }
92
+ }
93
+
94
+ async function handleDelete() {
95
+ if (!deleteTarget) return;
96
+ await fetch(`/api/contacts/${deleteTarget.id}`, { method: "DELETE" });
97
+ toast.success("Contact deleted");
98
+ load();
99
+ setDeleteTarget(null);
100
+ }
101
+
102
+ const columns: ColumnDef<Contact>[] = [
103
+ {
104
+ id: "name",
105
+ header: "Name",
106
+ accessorFn: (row) => `${row.firstName} ${row.lastName}`,
107
+ },
108
+ { accessorKey: "email", header: "Email" },
109
+ { accessorKey: "phone", header: "Phone" },
110
+ { accessorKey: "jobTitle", header: "Job Title" },
111
+ { accessorKey: "companyName", header: "Company" },
112
+ {
113
+ id: "actions",
114
+ cell: ({ row }) => (
115
+ <div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
116
+ <Button
117
+ variant="ghost"
118
+ size="icon"
119
+ className="h-8 w-8"
120
+ onClick={() => { setEditing(row.original); setShowForm(true); }}
121
+ >
122
+ <Pencil className="h-3 w-3" />
123
+ </Button>
124
+ <Button
125
+ variant="ghost"
126
+ size="icon"
127
+ className="h-8 w-8 text-destructive"
128
+ onClick={() => setDeleteTarget(row.original)}
129
+ >
130
+ <Trash2 className="h-3 w-3" />
131
+ </Button>
132
+ </div>
133
+ ),
134
+ },
135
+ ];
136
+
137
+ return (
138
+ <div>
139
+ <div className="flex items-center justify-between mb-4">
140
+ <h1 className="text-2xl font-bold">Contacts</h1>
141
+ <Button onClick={() => { setEditing(null); setShowForm(true); }}>
142
+ <Plus className="h-4 w-4 mr-2" />
143
+ Add Contact
144
+ </Button>
145
+ </div>
146
+
147
+ <div className="relative mb-4 max-w-sm">
148
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
149
+ <Input
150
+ placeholder="Search contacts..."
151
+ value={search}
152
+ onChange={(e) => setSearch(e.target.value)}
153
+ className="pl-9"
154
+ />
155
+ </div>
156
+
157
+ <DataTable
158
+ columns={columns}
159
+ data={contacts}
160
+ onRowClick={(row) => router.push(`/contacts/${row.id}`)}
161
+ />
162
+
163
+ <Dialog open={showForm} onOpenChange={setShowForm}>
164
+ <DialogContent>
165
+ <DialogHeader>
166
+ <DialogTitle>{editing ? "Edit Contact" : "New Contact"}</DialogTitle>
167
+ </DialogHeader>
168
+ <form onSubmit={handleSubmit} className="space-y-4">
169
+ <div className="grid grid-cols-2 gap-4">
170
+ <div className="space-y-2">
171
+ <Label htmlFor="firstName">First Name</Label>
172
+ <Input id="firstName" name="firstName" defaultValue={editing?.firstName} required />
173
+ </div>
174
+ <div className="space-y-2">
175
+ <Label htmlFor="lastName">Last Name</Label>
176
+ <Input id="lastName" name="lastName" defaultValue={editing?.lastName} required />
177
+ </div>
178
+ </div>
179
+ <div className="space-y-2">
180
+ <Label htmlFor="email">Email</Label>
181
+ <Input id="email" name="email" type="email" defaultValue={editing?.email || ""} />
182
+ </div>
183
+ <div className="space-y-2">
184
+ <Label htmlFor="phone">Phone</Label>
185
+ <Input id="phone" name="phone" defaultValue={editing?.phone || ""} />
186
+ </div>
187
+ <div className="space-y-2">
188
+ <Label htmlFor="jobTitle">Job Title</Label>
189
+ <Input id="jobTitle" name="jobTitle" defaultValue={editing?.jobTitle || ""} />
190
+ </div>
191
+ <div className="space-y-2">
192
+ <Label>Company</Label>
193
+ <Select name="companyId" defaultValue={editing?.companyId ? String(editing.companyId) : "none"}>
194
+ <SelectTrigger>
195
+ <SelectValue placeholder="Select company..." />
196
+ </SelectTrigger>
197
+ <SelectContent>
198
+ <SelectItem value="none">No company</SelectItem>
199
+ {companies.map((c) => (
200
+ <SelectItem key={c.id} value={String(c.id)}>
201
+ {c.name}
202
+ </SelectItem>
203
+ ))}
204
+ </SelectContent>
205
+ </Select>
206
+ </div>
207
+ <div className="flex justify-end gap-2">
208
+ <Button type="button" variant="outline" onClick={() => setShowForm(false)}>
209
+ Cancel
210
+ </Button>
211
+ <Button type="submit" disabled={loading}>
212
+ {loading ? "Saving..." : editing ? "Update" : "Create"}
213
+ </Button>
214
+ </div>
215
+ </form>
216
+ </DialogContent>
217
+ </Dialog>
218
+
219
+ <ConfirmDialog
220
+ open={!!deleteTarget}
221
+ onOpenChange={() => setDeleteTarget(null)}
222
+ title="Delete Contact"
223
+ description={`Are you sure you want to delete "${deleteTarget?.firstName} ${deleteTarget?.lastName}"?`}
224
+ onConfirm={handleDelete}
225
+ destructive
226
+ />
227
+ </div>
228
+ );
229
+ }
src/app/(app)/dashboard/page.tsx ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { formatCurrency } from "@/lib/utils";
6
+ import {
7
+ BarChart,
8
+ Bar,
9
+ XAxis,
10
+ YAxis,
11
+ CartesianGrid,
12
+ Tooltip,
13
+ ResponsiveContainer,
14
+ AreaChart,
15
+ Area,
16
+ PieChart,
17
+ Pie,
18
+ Cell,
19
+ } from "recharts";
20
+ import { DollarSign, Handshake, Target, CalendarCheck, Phone, Mail, Calendar, CheckSquare, StickyNote } from "lucide-react";
21
+ import { Badge } from "@/components/ui/badge";
22
+
23
+ const PIE_COLORS = ["#22c55e", "#ef4444"];
24
+
25
+ const typeIcons: Record<string, any> = {
26
+ call: Phone,
27
+ email: Mail,
28
+ meeting: Calendar,
29
+ task: CheckSquare,
30
+ note: StickyNote,
31
+ };
32
+
33
+ export default function DashboardPage() {
34
+ const [data, setData] = useState<any>(null);
35
+
36
+ useEffect(() => {
37
+ fetch("/api/dashboard").then((r) => r.ok && r.json()).then(setData);
38
+ }, []);
39
+
40
+ if (!data) return <div>Loading dashboard...</div>;
41
+
42
+ const { summary, dealsByStage, forecast, wonLost, recentActivities } = data;
43
+
44
+ return (
45
+ <div>
46
+ <h1 className="text-2xl font-bold mb-6">Dashboard</h1>
47
+
48
+ {/* Summary Cards */}
49
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
50
+ <Card>
51
+ <CardContent className="pt-6">
52
+ <div className="flex items-center gap-3">
53
+ <div className="p-2 bg-blue-100 rounded-lg">
54
+ <Handshake className="h-5 w-5 text-blue-600" />
55
+ </div>
56
+ <div>
57
+ <p className="text-sm text-muted-foreground">Open Deals</p>
58
+ <p className="text-2xl font-bold">{summary.openDeals}</p>
59
+ </div>
60
+ </div>
61
+ </CardContent>
62
+ </Card>
63
+ <Card>
64
+ <CardContent className="pt-6">
65
+ <div className="flex items-center gap-3">
66
+ <div className="p-2 bg-green-100 rounded-lg">
67
+ <DollarSign className="h-5 w-5 text-green-600" />
68
+ </div>
69
+ <div>
70
+ <p className="text-sm text-muted-foreground">Pipeline Value</p>
71
+ <p className="text-2xl font-bold">{formatCurrency(summary.totalOpenValue)}</p>
72
+ </div>
73
+ </div>
74
+ </CardContent>
75
+ </Card>
76
+ <Card>
77
+ <CardContent className="pt-6">
78
+ <div className="flex items-center gap-3">
79
+ <div className="p-2 bg-purple-100 rounded-lg">
80
+ <Target className="h-5 w-5 text-purple-600" />
81
+ </div>
82
+ <div>
83
+ <p className="text-sm text-muted-foreground">Win Rate</p>
84
+ <p className="text-2xl font-bold">{summary.winRate}%</p>
85
+ </div>
86
+ </div>
87
+ </CardContent>
88
+ </Card>
89
+ <Card>
90
+ <CardContent className="pt-6">
91
+ <div className="flex items-center gap-3">
92
+ <div className="p-2 bg-orange-100 rounded-lg">
93
+ <CalendarCheck className="h-5 w-5 text-orange-600" />
94
+ </div>
95
+ <div>
96
+ <p className="text-sm text-muted-foreground">Activities This Week</p>
97
+ <p className="text-2xl font-bold">{summary.activitiesThisWeek}</p>
98
+ </div>
99
+ </div>
100
+ </CardContent>
101
+ </Card>
102
+ </div>
103
+
104
+ {/* Charts */}
105
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
106
+ <Card>
107
+ <CardHeader>
108
+ <CardTitle className="text-base">Deals by Stage</CardTitle>
109
+ </CardHeader>
110
+ <CardContent>
111
+ <ResponsiveContainer width="100%" height={300}>
112
+ <BarChart data={dealsByStage}>
113
+ <CartesianGrid strokeDasharray="3 3" />
114
+ <XAxis dataKey="stage" fontSize={12} />
115
+ <YAxis fontSize={12} />
116
+ <Tooltip formatter={(value) => formatCurrency(Number(value))} />
117
+ <Bar dataKey="value" fill="#3b82f6" radius={[4, 4, 0, 0]} />
118
+ </BarChart>
119
+ </ResponsiveContainer>
120
+ </CardContent>
121
+ </Card>
122
+
123
+ <Card>
124
+ <CardHeader>
125
+ <CardTitle className="text-base">Revenue Forecast</CardTitle>
126
+ </CardHeader>
127
+ <CardContent>
128
+ <ResponsiveContainer width="100%" height={300}>
129
+ <AreaChart data={forecast}>
130
+ <CartesianGrid strokeDasharray="3 3" />
131
+ <XAxis dataKey="month" fontSize={12} />
132
+ <YAxis fontSize={12} />
133
+ <Tooltip formatter={(value) => formatCurrency(Number(value))} />
134
+ <Area
135
+ type="monotone"
136
+ dataKey="total"
137
+ stroke="#94a3b8"
138
+ fill="#f1f5f9"
139
+ name="Total"
140
+ />
141
+ <Area
142
+ type="monotone"
143
+ dataKey="weighted"
144
+ stroke="#3b82f6"
145
+ fill="#dbeafe"
146
+ name="Weighted"
147
+ />
148
+ </AreaChart>
149
+ </ResponsiveContainer>
150
+ </CardContent>
151
+ </Card>
152
+ </div>
153
+
154
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
155
+ <Card>
156
+ <CardHeader>
157
+ <CardTitle className="text-base">Won vs Lost</CardTitle>
158
+ </CardHeader>
159
+ <CardContent>
160
+ <ResponsiveContainer width="100%" height={200}>
161
+ <PieChart>
162
+ <Pie
163
+ data={wonLost}
164
+ dataKey="value"
165
+ nameKey="name"
166
+ cx="50%"
167
+ cy="50%"
168
+ innerRadius={50}
169
+ outerRadius={80}
170
+ label={({ name, value }) => `${name}: ${value}`}
171
+ >
172
+ {wonLost.map((_: any, idx: number) => (
173
+ <Cell key={idx} fill={PIE_COLORS[idx]} />
174
+ ))}
175
+ </Pie>
176
+ <Tooltip />
177
+ </PieChart>
178
+ </ResponsiveContainer>
179
+ </CardContent>
180
+ </Card>
181
+
182
+ <Card className="lg:col-span-2">
183
+ <CardHeader>
184
+ <CardTitle className="text-base">Recent Activity</CardTitle>
185
+ </CardHeader>
186
+ <CardContent>
187
+ <div className="space-y-3">
188
+ {recentActivities.map((a: any) => {
189
+ const Icon = typeIcons[a.type] || StickyNote;
190
+ return (
191
+ <div key={a.id} className="flex items-center gap-3">
192
+ <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
193
+ <span className={`text-sm flex-1 ${a.isDone ? "line-through text-muted-foreground" : ""}`}>
194
+ {a.subject}
195
+ </span>
196
+ <Badge variant="outline" className="text-xs shrink-0">{a.type}</Badge>
197
+ </div>
198
+ );
199
+ })}
200
+ {recentActivities.length === 0 && (
201
+ <p className="text-sm text-muted-foreground">No recent activities</p>
202
+ )}
203
+ </div>
204
+ </CardContent>
205
+ </Card>
206
+ </div>
207
+ </div>
208
+ );
209
+ }
src/app/(app)/deals/[id]/page.tsx ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Separator } from "@/components/ui/separator";
9
+ import { formatCurrency, formatDate } from "@/lib/utils";
10
+ import { ActivityForm } from "@/components/activities/activity-form";
11
+ import { ActivityItem } from "@/components/activities/activity-item";
12
+ import {
13
+ ArrowLeft,
14
+ Trophy,
15
+ XCircle,
16
+ Building2,
17
+ User,
18
+ Calendar,
19
+ DollarSign,
20
+ Target,
21
+ } from "lucide-react";
22
+ import { toast } from "sonner";
23
+ import Link from "next/link";
24
+
25
+ export default function DealDetailPage() {
26
+ const { id } = useParams();
27
+ const router = useRouter();
28
+ const [deal, setDeal] = useState<any>(null);
29
+ const [showActivityForm, setShowActivityForm] = useState(false);
30
+
31
+ async function loadDeal() {
32
+ const res = await fetch(`/api/deals/${id}`);
33
+ if (res.ok) setDeal(await res.json());
34
+ }
35
+
36
+ useEffect(() => { loadDeal(); }, [id]);
37
+
38
+ async function markStatus(status: "won" | "lost") {
39
+ const res = await fetch(`/api/deals/${id}`, {
40
+ method: "PATCH",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ status }),
43
+ });
44
+ if (res.ok) {
45
+ toast.success(`Deal marked as ${status}`);
46
+ loadDeal();
47
+ }
48
+ }
49
+
50
+ if (!deal) return <div className="p-6">Loading...</div>;
51
+
52
+ const statusColors: Record<string, string> = {
53
+ open: "bg-blue-100 text-blue-800",
54
+ won: "bg-green-100 text-green-800",
55
+ lost: "bg-red-100 text-red-800",
56
+ };
57
+
58
+ return (
59
+ <div className="max-w-4xl">
60
+ <Button variant="ghost" size="sm" onClick={() => router.back()} className="mb-4">
61
+ <ArrowLeft className="h-4 w-4 mr-2" />
62
+ Back to Deals
63
+ </Button>
64
+
65
+ <div className="flex items-start justify-between mb-6">
66
+ <div>
67
+ <h1 className="text-2xl font-bold">{deal.title}</h1>
68
+ <Badge className={`mt-2 ${statusColors[deal.status]}`}>
69
+ {deal.status.toUpperCase()}
70
+ </Badge>
71
+ </div>
72
+ {deal.status === "open" && (
73
+ <div className="flex gap-2">
74
+ <Button variant="outline" className="text-green-600" onClick={() => markStatus("won")}>
75
+ <Trophy className="h-4 w-4 mr-2" />
76
+ Mark Won
77
+ </Button>
78
+ <Button variant="outline" className="text-red-600" onClick={() => markStatus("lost")}>
79
+ <XCircle className="h-4 w-4 mr-2" />
80
+ Mark Lost
81
+ </Button>
82
+ </div>
83
+ )}
84
+ </div>
85
+
86
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
87
+ <Card>
88
+ <CardHeader>
89
+ <CardTitle className="text-sm font-medium text-muted-foreground">Deal Info</CardTitle>
90
+ </CardHeader>
91
+ <CardContent className="space-y-3">
92
+ <div className="flex items-center gap-2">
93
+ <DollarSign className="h-4 w-4 text-muted-foreground" />
94
+ <span className="font-semibold">{formatCurrency(deal.value)}</span>
95
+ </div>
96
+ <div className="flex items-center gap-2">
97
+ <Target className="h-4 w-4 text-muted-foreground" />
98
+ <span>{deal.stageName} ({deal.stageProbability}%)</span>
99
+ </div>
100
+ <div className="flex items-center gap-2">
101
+ <Calendar className="h-4 w-4 text-muted-foreground" />
102
+ <span>{formatDate(deal.expectedCloseDate) || "No close date"}</span>
103
+ </div>
104
+ </CardContent>
105
+ </Card>
106
+
107
+ <Card>
108
+ <CardHeader>
109
+ <CardTitle className="text-sm font-medium text-muted-foreground">Related</CardTitle>
110
+ </CardHeader>
111
+ <CardContent className="space-y-3">
112
+ {deal.contactFirstName && (
113
+ <Link href={`/contacts/${deal.contactId}`} className="flex items-center gap-2 hover:underline">
114
+ <User className="h-4 w-4 text-muted-foreground" />
115
+ <span>{deal.contactFirstName} {deal.contactLastName}</span>
116
+ </Link>
117
+ )}
118
+ {deal.companyName && (
119
+ <Link href={`/companies/${deal.companyId}`} className="flex items-center gap-2 hover:underline">
120
+ <Building2 className="h-4 w-4 text-muted-foreground" />
121
+ <span>{deal.companyName}</span>
122
+ </Link>
123
+ )}
124
+ </CardContent>
125
+ </Card>
126
+ </div>
127
+
128
+ <Separator className="my-6" />
129
+
130
+ <div className="flex items-center justify-between mb-4">
131
+ <h2 className="text-lg font-semibold">Activities</h2>
132
+ <Button size="sm" onClick={() => setShowActivityForm(true)}>Add Activity</Button>
133
+ </div>
134
+
135
+ {showActivityForm && (
136
+ <div className="mb-4">
137
+ <ActivityForm
138
+ dealId={deal.id}
139
+ contactId={deal.contactId}
140
+ companyId={deal.companyId}
141
+ onSuccess={() => {
142
+ setShowActivityForm(false);
143
+ loadDeal();
144
+ toast.success("Activity added");
145
+ }}
146
+ onCancel={() => setShowActivityForm(false)}
147
+ />
148
+ </div>
149
+ )}
150
+
151
+ <div className="space-y-2">
152
+ {deal.activities?.length === 0 && (
153
+ <p className="text-sm text-muted-foreground">No activities yet.</p>
154
+ )}
155
+ {deal.activities?.map((activity: any) => (
156
+ <ActivityItem
157
+ key={activity.id}
158
+ activity={activity}
159
+ onToggle={async () => {
160
+ await fetch(`/api/activities/${activity.id}`, {
161
+ method: "PATCH",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ isDone: !activity.isDone }),
164
+ });
165
+ loadDeal();
166
+ }}
167
+ />
168
+ ))}
169
+ </div>
170
+ </div>
171
+ );
172
+ }
src/app/(app)/deals/page.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db } from "@/lib/db";
2
+ import { deals, pipelineStages, pipelines, contacts, companies } from "@/lib/db/schema";
3
+ import { auth } from "@/lib/auth";
4
+ import { eq, asc } from "drizzle-orm";
5
+ import { redirect } from "next/navigation";
6
+ import { KanbanBoard } from "@/components/deals/kanban-board";
7
+
8
+ export default async function DealsPage() {
9
+ const session = await auth();
10
+ if (!session?.user?.id) redirect("/login");
11
+
12
+ const userId = parseInt(session.user.id);
13
+
14
+ const allPipelines = db.select().from(pipelines).where(eq(pipelines.userId, userId)).all();
15
+ const pipeline = allPipelines[0];
16
+ if (!pipeline) return <div>No pipeline found. Go to Settings to create one.</div>;
17
+
18
+ const stages = db
19
+ .select()
20
+ .from(pipelineStages)
21
+ .where(eq(pipelineStages.pipelineId, pipeline.id))
22
+ .orderBy(asc(pipelineStages.position))
23
+ .all();
24
+
25
+ const allDeals = db
26
+ .select({
27
+ id: deals.id,
28
+ title: deals.title,
29
+ value: deals.value,
30
+ status: deals.status,
31
+ position: deals.position,
32
+ expectedCloseDate: deals.expectedCloseDate,
33
+ stageId: deals.stageId,
34
+ stageName: pipelineStages.name,
35
+ contactId: deals.contactId,
36
+ contactFirstName: contacts.firstName,
37
+ contactLastName: contacts.lastName,
38
+ companyId: deals.companyId,
39
+ companyName: companies.name,
40
+ pipelineId: deals.pipelineId,
41
+ })
42
+ .from(deals)
43
+ .leftJoin(contacts, eq(deals.contactId, contacts.id))
44
+ .leftJoin(companies, eq(deals.companyId, companies.id))
45
+ .leftJoin(pipelineStages, eq(deals.stageId, pipelineStages.id))
46
+ .where(eq(deals.userId, userId))
47
+ .all();
48
+
49
+ const allContacts = db
50
+ .select({
51
+ id: contacts.id,
52
+ firstName: contacts.firstName,
53
+ lastName: contacts.lastName,
54
+ companyId: contacts.companyId,
55
+ })
56
+ .from(contacts)
57
+ .where(eq(contacts.userId, userId))
58
+ .all();
59
+
60
+ const allCompanies = db
61
+ .select({ id: companies.id, name: companies.name })
62
+ .from(companies)
63
+ .where(eq(companies.userId, userId))
64
+ .all();
65
+
66
+ return (
67
+ <KanbanBoard
68
+ initialDeals={allDeals}
69
+ stages={stages}
70
+ pipelineId={pipeline.id}
71
+ contacts={allContacts}
72
+ companies={allCompanies}
73
+ />
74
+ );
75
+ }
src/app/(app)/layout.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "next/navigation";
2
+ import { auth } from "@/lib/auth";
3
+ import { SessionProvider } from "next-auth/react";
4
+ import { Sidebar } from "@/components/layout/sidebar";
5
+ import { Topbar } from "@/components/layout/topbar";
6
+
7
+ export default async function AppLayout({ children }: { children: React.ReactNode }) {
8
+ const session = await auth();
9
+ if (!session) redirect("/login");
10
+
11
+ return (
12
+ <SessionProvider session={session}>
13
+ <div className="flex min-h-screen">
14
+ <Sidebar />
15
+ <div className="flex-1 flex flex-col">
16
+ <Topbar />
17
+ <main className="flex-1 p-6">{children}</main>
18
+ </div>
19
+ </div>
20
+ </SessionProvider>
21
+ );
22
+ }
src/app/(app)/settings/page.tsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import { Plus, GripVertical, Trash2, Save } from "lucide-react";
9
+ import { toast } from "sonner";
10
+ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
11
+
12
+ type Stage = {
13
+ id: number;
14
+ name: string;
15
+ position: number;
16
+ probability: number;
17
+ pipelineId: number;
18
+ };
19
+
20
+ export default function SettingsPage() {
21
+ const [stages, setStages] = useState<Stage[]>([]);
22
+ const [pipelineId, setPipelineId] = useState<number | null>(null);
23
+ const [deleteTarget, setDeleteTarget] = useState<Stage | null>(null);
24
+ const [newStageName, setNewStageName] = useState("");
25
+
26
+ async function loadStages() {
27
+ const pipRes = await fetch("/api/pipelines");
28
+ if (!pipRes.ok) return;
29
+ const pips = await pipRes.json();
30
+ if (pips.length === 0) return;
31
+ setPipelineId(pips[0].id);
32
+
33
+ const stagesRes = await fetch(`/api/stages?pipelineId=${pips[0].id}`);
34
+ if (stagesRes.ok) setStages(await stagesRes.json());
35
+ }
36
+
37
+ useEffect(() => { loadStages(); }, []);
38
+
39
+ async function addStage() {
40
+ if (!newStageName.trim() || !pipelineId) return;
41
+ const maxPos = stages.length > 0 ? Math.max(...stages.map((s) => s.position)) + 1 : 0;
42
+ const res = await fetch("/api/stages", {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json" },
45
+ body: JSON.stringify({
46
+ name: newStageName.trim(),
47
+ position: maxPos,
48
+ probability: 0,
49
+ pipelineId,
50
+ }),
51
+ });
52
+ if (res.ok) {
53
+ toast.success("Stage added");
54
+ setNewStageName("");
55
+ loadStages();
56
+ }
57
+ }
58
+
59
+ async function updateStage(stage: Stage, updates: Partial<Stage>) {
60
+ const res = await fetch(`/api/stages/${stage.id}`, {
61
+ method: "PATCH",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify(updates),
64
+ });
65
+ if (res.ok) {
66
+ toast.success("Stage updated");
67
+ loadStages();
68
+ }
69
+ }
70
+
71
+ async function deleteStage() {
72
+ if (!deleteTarget) return;
73
+ const res = await fetch(`/api/stages/${deleteTarget.id}`, { method: "DELETE" });
74
+ if (res.ok) {
75
+ toast.success("Stage deleted");
76
+ loadStages();
77
+ } else {
78
+ const data = await res.json();
79
+ toast.error(data.error || "Failed to delete stage");
80
+ }
81
+ setDeleteTarget(null);
82
+ }
83
+
84
+ return (
85
+ <div className="max-w-2xl">
86
+ <h1 className="text-2xl font-bold mb-6">Settings</h1>
87
+
88
+ <Card>
89
+ <CardHeader>
90
+ <CardTitle className="text-base">Pipeline Stages</CardTitle>
91
+ </CardHeader>
92
+ <CardContent className="space-y-3">
93
+ {stages
94
+ .sort((a, b) => a.position - b.position)
95
+ .map((stage) => (
96
+ <div key={stage.id} className="flex items-center gap-3 p-2 border rounded-md">
97
+ <GripVertical className="h-4 w-4 text-muted-foreground" />
98
+ <Input
99
+ defaultValue={stage.name}
100
+ className="flex-1"
101
+ onBlur={(e) => {
102
+ if (e.target.value !== stage.name) {
103
+ updateStage(stage, { name: e.target.value });
104
+ }
105
+ }}
106
+ />
107
+ <div className="flex items-center gap-1">
108
+ <Input
109
+ type="number"
110
+ min={0}
111
+ max={100}
112
+ defaultValue={stage.probability}
113
+ className="w-20"
114
+ onBlur={(e) => {
115
+ const val = parseInt(e.target.value);
116
+ if (val !== stage.probability) {
117
+ updateStage(stage, { probability: val });
118
+ }
119
+ }}
120
+ />
121
+ <span className="text-sm text-muted-foreground">%</span>
122
+ </div>
123
+ <Button
124
+ variant="ghost"
125
+ size="icon"
126
+ className="text-destructive"
127
+ onClick={() => setDeleteTarget(stage)}
128
+ >
129
+ <Trash2 className="h-4 w-4" />
130
+ </Button>
131
+ </div>
132
+ ))}
133
+
134
+ <div className="flex gap-2 pt-2">
135
+ <Input
136
+ placeholder="New stage name..."
137
+ value={newStageName}
138
+ onChange={(e) => setNewStageName(e.target.value)}
139
+ onKeyDown={(e) => e.key === "Enter" && addStage()}
140
+ />
141
+ <Button onClick={addStage} disabled={!newStageName.trim()}>
142
+ <Plus className="h-4 w-4 mr-2" />
143
+ Add Stage
144
+ </Button>
145
+ </div>
146
+ </CardContent>
147
+ </Card>
148
+
149
+ <ConfirmDialog
150
+ open={!!deleteTarget}
151
+ onOpenChange={() => setDeleteTarget(null)}
152
+ title="Delete Stage"
153
+ description={`Delete "${deleteTarget?.name}"? This only works if no deals are in this stage.`}
154
+ onConfirm={deleteStage}
155
+ destructive
156
+ />
157
+ </div>
158
+ );
159
+ }
src/app/(auth)/login/page.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { signIn } from "next-auth/react";
5
+ import { useRouter } from "next/navigation";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Label } from "@/components/ui/label";
9
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
10
+
11
+ export default function LoginPage() {
12
+ const router = useRouter();
13
+ const [error, setError] = useState("");
14
+ const [loading, setLoading] = useState(false);
15
+
16
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
17
+ e.preventDefault();
18
+ setLoading(true);
19
+ setError("");
20
+
21
+ const formData = new FormData(e.currentTarget);
22
+ const result = await signIn("credentials", {
23
+ email: formData.get("email") as string,
24
+ password: formData.get("password") as string,
25
+ redirect: false,
26
+ });
27
+
28
+ if (result?.error) {
29
+ setError("Invalid email or password");
30
+ setLoading(false);
31
+ } else {
32
+ router.push("/deals");
33
+ router.refresh();
34
+ }
35
+ }
36
+
37
+ return (
38
+ <div className="flex min-h-screen items-center justify-center bg-muted/40">
39
+ <Card className="w-full max-w-sm">
40
+ <CardHeader className="text-center">
41
+ <CardTitle className="text-2xl font-bold">PipeCRM</CardTitle>
42
+ <CardDescription>Sign in to your account</CardDescription>
43
+ </CardHeader>
44
+ <CardContent>
45
+ <form onSubmit={handleSubmit} className="space-y-4">
46
+ <div className="space-y-2">
47
+ <Label htmlFor="email">Email</Label>
48
+ <Input
49
+ id="email"
50
+ name="email"
51
+ type="email"
52
+ placeholder="admin@local.host"
53
+ defaultValue="admin@local.host"
54
+ required
55
+ />
56
+ </div>
57
+ <div className="space-y-2">
58
+ <Label htmlFor="password">Password</Label>
59
+ <Input
60
+ id="password"
61
+ name="password"
62
+ type="password"
63
+ defaultValue="admin123"
64
+ required
65
+ />
66
+ </div>
67
+ {error && (
68
+ <p className="text-sm text-destructive">{error}</p>
69
+ )}
70
+ <Button type="submit" className="w-full" disabled={loading}>
71
+ {loading ? "Signing in..." : "Sign in"}
72
+ </Button>
73
+ </form>
74
+ </CardContent>
75
+ </Card>
76
+ </div>
77
+ );
78
+ }
src/app/api/activities/[id]/route.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { activities } from "@/lib/db/schema";
4
+ import { activitySchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, and } from "drizzle-orm";
7
+
8
+ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const { id } = await params;
13
+ const body = await req.json();
14
+ const parsed = activitySchema.partial().safeParse(body);
15
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
16
+
17
+ const updated = db
18
+ .update(activities)
19
+ .set({ ...parsed.data, updatedAt: new Date().toISOString() })
20
+ .where(and(eq(activities.id, parseInt(id)), eq(activities.userId, parseInt(session.user.id))))
21
+ .returning()
22
+ .get();
23
+
24
+ if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
25
+ return NextResponse.json(updated);
26
+ }
27
+
28
+ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
29
+ const session = await auth();
30
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
31
+
32
+ const { id } = await params;
33
+ const deleted = db
34
+ .delete(activities)
35
+ .where(and(eq(activities.id, parseInt(id)), eq(activities.userId, parseInt(session.user.id))))
36
+ .returning()
37
+ .all();
38
+
39
+ if (!deleted.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
40
+ return NextResponse.json({ success: true });
41
+ }
src/app/api/activities/route.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { activities, contacts, deals, companies } from "@/lib/db/schema";
4
+ import { activitySchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, desc } from "drizzle-orm";
7
+
8
+ export async function GET(req: NextRequest) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const type = req.nextUrl.searchParams.get("type");
13
+ const isDone = req.nextUrl.searchParams.get("isDone");
14
+ const dealId = req.nextUrl.searchParams.get("dealId");
15
+ const contactId = req.nextUrl.searchParams.get("contactId");
16
+ const companyId = req.nextUrl.searchParams.get("companyId");
17
+ const userId = parseInt(session.user.id);
18
+
19
+ let results = db
20
+ .select({
21
+ id: activities.id,
22
+ type: activities.type,
23
+ subject: activities.subject,
24
+ description: activities.description,
25
+ dueDate: activities.dueDate,
26
+ isDone: activities.isDone,
27
+ dealId: activities.dealId,
28
+ dealTitle: deals.title,
29
+ contactId: activities.contactId,
30
+ contactFirstName: contacts.firstName,
31
+ contactLastName: contacts.lastName,
32
+ companyId: activities.companyId,
33
+ companyName: companies.name,
34
+ createdAt: activities.createdAt,
35
+ })
36
+ .from(activities)
37
+ .leftJoin(deals, eq(activities.dealId, deals.id))
38
+ .leftJoin(contacts, eq(activities.contactId, contacts.id))
39
+ .leftJoin(companies, eq(activities.companyId, companies.id))
40
+ .where(eq(activities.userId, userId))
41
+ .orderBy(desc(activities.dueDate))
42
+ .all();
43
+
44
+ if (type) results = results.filter((a) => a.type === type);
45
+ if (isDone !== null && isDone !== undefined && isDone !== "") {
46
+ results = results.filter((a) => a.isDone === (isDone === "true"));
47
+ }
48
+ if (dealId) results = results.filter((a) => a.dealId === parseInt(dealId));
49
+ if (contactId) results = results.filter((a) => a.contactId === parseInt(contactId));
50
+ if (companyId) results = results.filter((a) => a.companyId === parseInt(companyId));
51
+
52
+ return NextResponse.json(results);
53
+ }
54
+
55
+ export async function POST(req: NextRequest) {
56
+ const session = await auth();
57
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
58
+
59
+ const body = await req.json();
60
+ const parsed = activitySchema.safeParse(body);
61
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
62
+
63
+ const now = new Date().toISOString();
64
+ const activity = db
65
+ .insert(activities)
66
+ .values({
67
+ ...parsed.data,
68
+ userId: parseInt(session.user.id),
69
+ createdAt: now,
70
+ updatedAt: now,
71
+ })
72
+ .returning()
73
+ .get();
74
+
75
+ return NextResponse.json(activity, { status: 201 });
76
+ }
src/app/api/auth/[...nextauth]/route.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import { handlers } from "@/lib/auth";
2
+ export const { GET, POST } = handlers;
src/app/api/companies/[id]/route.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { companies } from "@/lib/db/schema";
4
+ import { companySchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, and } from "drizzle-orm";
7
+
8
+ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const { id } = await params;
13
+ const company = db
14
+ .select()
15
+ .from(companies)
16
+ .where(and(eq(companies.id, parseInt(id)), eq(companies.userId, parseInt(session.user.id))))
17
+ .get();
18
+
19
+ if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
20
+ return NextResponse.json(company);
21
+ }
22
+
23
+ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
24
+ const session = await auth();
25
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
26
+
27
+ const { id } = await params;
28
+ const body = await req.json();
29
+ const parsed = companySchema.partial().safeParse(body);
30
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
31
+
32
+ const updated = db
33
+ .update(companies)
34
+ .set({ ...parsed.data, updatedAt: new Date().toISOString() })
35
+ .where(and(eq(companies.id, parseInt(id)), eq(companies.userId, parseInt(session.user.id))))
36
+ .returning()
37
+ .get();
38
+
39
+ if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
40
+ return NextResponse.json(updated);
41
+ }
42
+
43
+ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
44
+ const session = await auth();
45
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
46
+
47
+ const { id } = await params;
48
+ const deleted = db
49
+ .delete(companies)
50
+ .where(and(eq(companies.id, parseInt(id)), eq(companies.userId, parseInt(session.user.id))))
51
+ .returning()
52
+ .all();
53
+
54
+ if (!deleted.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
55
+ return NextResponse.json({ success: true });
56
+ }
src/app/api/companies/route.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { companies } from "@/lib/db/schema";
4
+ import { companySchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, desc, like, or } from "drizzle-orm";
7
+
8
+ export async function GET(req: NextRequest) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const search = req.nextUrl.searchParams.get("search") || "";
13
+ const page = parseInt(req.nextUrl.searchParams.get("page") || "1");
14
+ const limit = parseInt(req.nextUrl.searchParams.get("limit") || "20");
15
+ const offset = (page - 1) * limit;
16
+
17
+ const userId = parseInt(session.user.id);
18
+ const conditions = [eq(companies.userId, userId)];
19
+ if (search) {
20
+ conditions.push(
21
+ or(
22
+ like(companies.name, `%${search}%`),
23
+ like(companies.domain, `%${search}%`)
24
+ )!
25
+ );
26
+ }
27
+
28
+ const where = conditions.length > 1
29
+ ? (() => {
30
+ const [userCond, searchCond] = conditions;
31
+ return (t: any, { and }: any) => and(userCond, searchCond);
32
+ })()
33
+ : eq(companies.userId, userId);
34
+
35
+ const data = db
36
+ .select()
37
+ .from(companies)
38
+ .where(typeof where === "function" ? undefined : where)
39
+ .orderBy(desc(companies.createdAt))
40
+ .limit(limit)
41
+ .offset(offset)
42
+ .all();
43
+
44
+ // Re-do query with proper filtering
45
+ let results;
46
+ if (search) {
47
+ results = db
48
+ .select()
49
+ .from(companies)
50
+ .where(eq(companies.userId, userId))
51
+ .orderBy(desc(companies.createdAt))
52
+ .all()
53
+ .filter(
54
+ (c) =>
55
+ c.name.toLowerCase().includes(search.toLowerCase()) ||
56
+ (c.domain && c.domain.toLowerCase().includes(search.toLowerCase()))
57
+ );
58
+ } else {
59
+ results = db
60
+ .select()
61
+ .from(companies)
62
+ .where(eq(companies.userId, userId))
63
+ .orderBy(desc(companies.createdAt))
64
+ .all();
65
+ }
66
+
67
+ const total = results.length;
68
+ const paged = results.slice(offset, offset + limit);
69
+
70
+ return NextResponse.json({ data: paged, total, page, limit });
71
+ }
72
+
73
+ export async function POST(req: NextRequest) {
74
+ const session = await auth();
75
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
76
+
77
+ const body = await req.json();
78
+ const parsed = companySchema.safeParse(body);
79
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
80
+
81
+ const now = new Date().toISOString();
82
+ const company = db
83
+ .insert(companies)
84
+ .values({
85
+ ...parsed.data,
86
+ userId: parseInt(session.user.id),
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ })
90
+ .returning()
91
+ .get();
92
+
93
+ return NextResponse.json(company, { status: 201 });
94
+ }
src/app/api/contacts/[id]/route.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { contacts, companies } from "@/lib/db/schema";
4
+ import { contactSchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, and } from "drizzle-orm";
7
+
8
+ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const { id } = await params;
13
+ const contact = db
14
+ .select({
15
+ id: contacts.id,
16
+ firstName: contacts.firstName,
17
+ lastName: contacts.lastName,
18
+ email: contacts.email,
19
+ phone: contacts.phone,
20
+ jobTitle: contacts.jobTitle,
21
+ companyId: contacts.companyId,
22
+ companyName: companies.name,
23
+ createdAt: contacts.createdAt,
24
+ updatedAt: contacts.updatedAt,
25
+ })
26
+ .from(contacts)
27
+ .leftJoin(companies, eq(contacts.companyId, companies.id))
28
+ .where(and(eq(contacts.id, parseInt(id)), eq(contacts.userId, parseInt(session.user.id))))
29
+ .get();
30
+
31
+ if (!contact) return NextResponse.json({ error: "Not found" }, { status: 404 });
32
+ return NextResponse.json(contact);
33
+ }
34
+
35
+ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
36
+ const session = await auth();
37
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
38
+
39
+ const { id } = await params;
40
+ const body = await req.json();
41
+ const parsed = contactSchema.partial().safeParse(body);
42
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
43
+
44
+ const updated = db
45
+ .update(contacts)
46
+ .set({ ...parsed.data, email: parsed.data.email || null, updatedAt: new Date().toISOString() })
47
+ .where(and(eq(contacts.id, parseInt(id)), eq(contacts.userId, parseInt(session.user.id))))
48
+ .returning()
49
+ .get();
50
+
51
+ if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
52
+ return NextResponse.json(updated);
53
+ }
54
+
55
+ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
56
+ const session = await auth();
57
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
58
+
59
+ const { id } = await params;
60
+ const deleted = db
61
+ .delete(contacts)
62
+ .where(and(eq(contacts.id, parseInt(id)), eq(contacts.userId, parseInt(session.user.id))))
63
+ .returning()
64
+ .all();
65
+
66
+ if (!deleted.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
67
+ return NextResponse.json({ success: true });
68
+ }
src/app/api/contacts/route.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { contacts, companies } from "@/lib/db/schema";
4
+ import { contactSchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, desc } from "drizzle-orm";
7
+
8
+ export async function GET(req: NextRequest) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const search = req.nextUrl.searchParams.get("search") || "";
13
+ const companyId = req.nextUrl.searchParams.get("companyId");
14
+ const page = parseInt(req.nextUrl.searchParams.get("page") || "1");
15
+ const limit = parseInt(req.nextUrl.searchParams.get("limit") || "20");
16
+ const offset = (page - 1) * limit;
17
+ const userId = parseInt(session.user.id);
18
+
19
+ let results = db
20
+ .select({
21
+ id: contacts.id,
22
+ firstName: contacts.firstName,
23
+ lastName: contacts.lastName,
24
+ email: contacts.email,
25
+ phone: contacts.phone,
26
+ jobTitle: contacts.jobTitle,
27
+ companyId: contacts.companyId,
28
+ companyName: companies.name,
29
+ createdAt: contacts.createdAt,
30
+ updatedAt: contacts.updatedAt,
31
+ })
32
+ .from(contacts)
33
+ .leftJoin(companies, eq(contacts.companyId, companies.id))
34
+ .where(eq(contacts.userId, userId))
35
+ .orderBy(desc(contacts.createdAt))
36
+ .all();
37
+
38
+ if (companyId) {
39
+ results = results.filter((c) => c.companyId === parseInt(companyId));
40
+ }
41
+ if (search) {
42
+ const s = search.toLowerCase();
43
+ results = results.filter(
44
+ (c) =>
45
+ c.firstName.toLowerCase().includes(s) ||
46
+ c.lastName.toLowerCase().includes(s) ||
47
+ (c.email && c.email.toLowerCase().includes(s)) ||
48
+ (c.companyName && c.companyName.toLowerCase().includes(s))
49
+ );
50
+ }
51
+
52
+ const total = results.length;
53
+ const paged = results.slice(offset, offset + limit);
54
+
55
+ return NextResponse.json({ data: paged, total, page, limit });
56
+ }
57
+
58
+ export async function POST(req: NextRequest) {
59
+ const session = await auth();
60
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
61
+
62
+ const body = await req.json();
63
+ const parsed = contactSchema.safeParse(body);
64
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
65
+
66
+ const now = new Date().toISOString();
67
+ const contact = db
68
+ .insert(contacts)
69
+ .values({
70
+ ...parsed.data,
71
+ email: parsed.data.email || null,
72
+ userId: parseInt(session.user.id),
73
+ createdAt: now,
74
+ updatedAt: now,
75
+ })
76
+ .returning()
77
+ .get();
78
+
79
+ return NextResponse.json(contact, { status: 201 });
80
+ }
src/app/api/dashboard/route.ts ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { deals, activities, pipelineStages } from "@/lib/db/schema";
4
+ import { auth } from "@/lib/auth";
5
+ import { eq, desc } from "drizzle-orm";
6
+
7
+ export async function GET() {
8
+ const session = await auth();
9
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10
+
11
+ const userId = parseInt(session.user.id);
12
+
13
+ const allDeals = db.select().from(deals).where(eq(deals.userId, userId)).all();
14
+ const allStages = db.select().from(pipelineStages).all();
15
+ const allActivities = db
16
+ .select()
17
+ .from(activities)
18
+ .where(eq(activities.userId, userId))
19
+ .orderBy(desc(activities.createdAt))
20
+ .all();
21
+
22
+ const openDeals = allDeals.filter((d) => d.status === "open");
23
+ const wonDeals = allDeals.filter((d) => d.status === "won");
24
+ const lostDeals = allDeals.filter((d) => d.status === "lost");
25
+
26
+ // Summary cards
27
+ const totalOpenValue = openDeals.reduce((sum, d) => sum + d.value, 0);
28
+ const totalWonValue = wonDeals.reduce((sum, d) => sum + d.value, 0);
29
+ const winRate =
30
+ wonDeals.length + lostDeals.length > 0
31
+ ? Math.round((wonDeals.length / (wonDeals.length + lostDeals.length)) * 100)
32
+ : 0;
33
+
34
+ // Activities this week
35
+ const weekAgo = new Date();
36
+ weekAgo.setDate(weekAgo.getDate() - 7);
37
+ const weekStr = weekAgo.toISOString().split("T")[0];
38
+ const activitiesThisWeek = allActivities.filter(
39
+ (a) => a.dueDate && a.dueDate >= weekStr
40
+ ).length;
41
+
42
+ // Deals by stage
43
+ const stageMap = new Map(allStages.map((s) => [s.id, s]));
44
+ const dealsByStage = allStages
45
+ .sort((a, b) => a.position - b.position)
46
+ .map((stage) => {
47
+ const stageDeals = openDeals.filter((d) => d.stageId === stage.id);
48
+ return {
49
+ stage: stage.name,
50
+ count: stageDeals.length,
51
+ value: stageDeals.reduce((sum, d) => sum + d.value, 0),
52
+ };
53
+ });
54
+
55
+ // Revenue forecast (next 6 months, weighted by stage probability)
56
+ const forecast: { month: string; weighted: number; total: number }[] = [];
57
+ for (let i = 0; i < 6; i++) {
58
+ const date = new Date();
59
+ date.setMonth(date.getMonth() + i);
60
+ const monthStr = date.toISOString().slice(0, 7);
61
+ const monthLabel = date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
62
+
63
+ const monthDeals = openDeals.filter(
64
+ (d) => d.expectedCloseDate && d.expectedCloseDate.slice(0, 7) === monthStr
65
+ );
66
+
67
+ const total = monthDeals.reduce((sum, d) => sum + d.value, 0);
68
+ const weighted = monthDeals.reduce((sum, d) => {
69
+ const stage = stageMap.get(d.stageId);
70
+ const prob = stage ? stage.probability / 100 : 0;
71
+ return sum + d.value * prob;
72
+ }, 0);
73
+
74
+ forecast.push({ month: monthLabel, weighted: Math.round(weighted), total: Math.round(total) });
75
+ }
76
+
77
+ // Won/Lost breakdown
78
+ const wonLost = [
79
+ { name: "Won", value: wonDeals.length },
80
+ { name: "Lost", value: lostDeals.length },
81
+ ];
82
+
83
+ // Recent activities
84
+ const recentActivities = allActivities.slice(0, 10).map((a) => ({
85
+ id: a.id,
86
+ type: a.type,
87
+ subject: a.subject,
88
+ dueDate: a.dueDate,
89
+ isDone: a.isDone,
90
+ createdAt: a.createdAt,
91
+ }));
92
+
93
+ return NextResponse.json({
94
+ summary: {
95
+ openDeals: openDeals.length,
96
+ totalOpenValue,
97
+ totalWonValue,
98
+ winRate,
99
+ activitiesThisWeek,
100
+ },
101
+ dealsByStage,
102
+ forecast,
103
+ wonLost,
104
+ recentActivities,
105
+ });
106
+ }
src/app/api/deals/[id]/route.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { deals, contacts, companies, pipelineStages, activities } from "@/lib/db/schema";
4
+ import { dealSchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, and, desc } from "drizzle-orm";
7
+
8
+ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const { id } = await params;
13
+ const deal = db
14
+ .select({
15
+ id: deals.id,
16
+ title: deals.title,
17
+ value: deals.value,
18
+ status: deals.status,
19
+ position: deals.position,
20
+ expectedCloseDate: deals.expectedCloseDate,
21
+ stageId: deals.stageId,
22
+ stageName: pipelineStages.name,
23
+ stageProbability: pipelineStages.probability,
24
+ contactId: deals.contactId,
25
+ contactFirstName: contacts.firstName,
26
+ contactLastName: contacts.lastName,
27
+ contactEmail: contacts.email,
28
+ companyId: deals.companyId,
29
+ companyName: companies.name,
30
+ pipelineId: deals.pipelineId,
31
+ createdAt: deals.createdAt,
32
+ updatedAt: deals.updatedAt,
33
+ })
34
+ .from(deals)
35
+ .leftJoin(contacts, eq(deals.contactId, contacts.id))
36
+ .leftJoin(companies, eq(deals.companyId, companies.id))
37
+ .leftJoin(pipelineStages, eq(deals.stageId, pipelineStages.id))
38
+ .where(and(eq(deals.id, parseInt(id)), eq(deals.userId, parseInt(session.user.id))))
39
+ .get();
40
+
41
+ if (!deal) return NextResponse.json({ error: "Not found" }, { status: 404 });
42
+
43
+ // Get activities for this deal
44
+ const dealActivities = db
45
+ .select()
46
+ .from(activities)
47
+ .where(eq(activities.dealId, parseInt(id)))
48
+ .orderBy(desc(activities.createdAt))
49
+ .all();
50
+
51
+ return NextResponse.json({ ...deal, activities: dealActivities });
52
+ }
53
+
54
+ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
55
+ const session = await auth();
56
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
57
+
58
+ const { id } = await params;
59
+ const body = await req.json();
60
+ const parsed = dealSchema.partial().safeParse(body);
61
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
62
+
63
+ const updated = db
64
+ .update(deals)
65
+ .set({ ...parsed.data, updatedAt: new Date().toISOString() })
66
+ .where(and(eq(deals.id, parseInt(id)), eq(deals.userId, parseInt(session.user.id))))
67
+ .returning()
68
+ .get();
69
+
70
+ if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
71
+ return NextResponse.json(updated);
72
+ }
73
+
74
+ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
75
+ const session = await auth();
76
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
77
+
78
+ const { id } = await params;
79
+ // Delete associated activities first
80
+ db.delete(activities).where(eq(activities.dealId, parseInt(id))).run();
81
+
82
+ const deleted = db
83
+ .delete(deals)
84
+ .where(and(eq(deals.id, parseInt(id)), eq(deals.userId, parseInt(session.user.id))))
85
+ .returning()
86
+ .all();
87
+
88
+ if (!deleted.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
89
+ return NextResponse.json({ success: true });
90
+ }
src/app/api/deals/reorder/route.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { deals } from "@/lib/db/schema";
4
+ import { dealReorderSchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, and } from "drizzle-orm";
7
+ import Database from "better-sqlite3";
8
+ import path from "path";
9
+
10
+ export async function PATCH(req: NextRequest) {
11
+ const session = await auth();
12
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
13
+
14
+ const body = await req.json();
15
+ const parsed = dealReorderSchema.safeParse(body);
16
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
17
+
18
+ const { dealId, newStageId, newPosition } = parsed.data;
19
+ const userId = parseInt(session.user.id);
20
+
21
+ // Use raw sqlite for transaction
22
+ const dbPath = path.join(process.cwd(), "data", "crm.db");
23
+ const sqlite = new Database(dbPath);
24
+ sqlite.pragma("foreign_keys = ON");
25
+
26
+ const txn = sqlite.transaction(() => {
27
+ const now = new Date().toISOString();
28
+
29
+ // Get the deal's current stage
30
+ const deal = sqlite
31
+ .prepare("SELECT stage_id FROM deals WHERE id = ? AND user_id = ?")
32
+ .get(dealId, userId) as { stage_id: number } | undefined;
33
+
34
+ if (!deal) throw new Error("Deal not found");
35
+
36
+ const oldStageId = deal.stage_id;
37
+
38
+ // Update the deal's stage and position
39
+ sqlite
40
+ .prepare("UPDATE deals SET stage_id = ?, position = ?, updated_at = ? WHERE id = ?")
41
+ .run(newStageId, newPosition, now, dealId);
42
+
43
+ // Re-index positions in the new stage
44
+ const newStageDeals = sqlite
45
+ .prepare("SELECT id FROM deals WHERE stage_id = ? AND user_id = ? AND id != ? ORDER BY position")
46
+ .all(newStageId, userId, dealId) as { id: number }[];
47
+
48
+ // Insert the moved deal at the right position
49
+ const reordered = [...newStageDeals];
50
+ reordered.splice(newPosition, 0, { id: dealId });
51
+
52
+ reordered.forEach((d, idx) => {
53
+ sqlite.prepare("UPDATE deals SET position = ? WHERE id = ?").run(idx, d.id);
54
+ });
55
+
56
+ // Re-index positions in the old stage (if different)
57
+ if (oldStageId !== newStageId) {
58
+ const oldStageDeals = sqlite
59
+ .prepare("SELECT id FROM deals WHERE stage_id = ? AND user_id = ? ORDER BY position")
60
+ .all(oldStageId, userId) as { id: number }[];
61
+
62
+ oldStageDeals.forEach((d, idx) => {
63
+ sqlite.prepare("UPDATE deals SET position = ? WHERE id = ?").run(idx, d.id);
64
+ });
65
+ }
66
+ });
67
+
68
+ try {
69
+ txn();
70
+ sqlite.close();
71
+ return NextResponse.json({ success: true });
72
+ } catch (e: any) {
73
+ sqlite.close();
74
+ return NextResponse.json({ error: e.message }, { status: 400 });
75
+ }
76
+ }
src/app/api/deals/route.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { deals, contacts, companies, pipelineStages } from "@/lib/db/schema";
4
+ import { dealSchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, desc } from "drizzle-orm";
7
+
8
+ export async function GET(req: NextRequest) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const pipelineId = req.nextUrl.searchParams.get("pipelineId");
13
+ const status = req.nextUrl.searchParams.get("status");
14
+ const userId = parseInt(session.user.id);
15
+
16
+ let results = db
17
+ .select({
18
+ id: deals.id,
19
+ title: deals.title,
20
+ value: deals.value,
21
+ status: deals.status,
22
+ position: deals.position,
23
+ expectedCloseDate: deals.expectedCloseDate,
24
+ stageId: deals.stageId,
25
+ stageName: pipelineStages.name,
26
+ contactId: deals.contactId,
27
+ contactFirstName: contacts.firstName,
28
+ contactLastName: contacts.lastName,
29
+ companyId: deals.companyId,
30
+ companyName: companies.name,
31
+ pipelineId: deals.pipelineId,
32
+ createdAt: deals.createdAt,
33
+ updatedAt: deals.updatedAt,
34
+ })
35
+ .from(deals)
36
+ .leftJoin(contacts, eq(deals.contactId, contacts.id))
37
+ .leftJoin(companies, eq(deals.companyId, companies.id))
38
+ .leftJoin(pipelineStages, eq(deals.stageId, pipelineStages.id))
39
+ .where(eq(deals.userId, userId))
40
+ .orderBy(deals.position)
41
+ .all();
42
+
43
+ if (pipelineId) {
44
+ results = results.filter((d) => d.pipelineId === parseInt(pipelineId));
45
+ }
46
+ if (status) {
47
+ results = results.filter((d) => d.status === status);
48
+ }
49
+
50
+ return NextResponse.json(results);
51
+ }
52
+
53
+ export async function POST(req: NextRequest) {
54
+ const session = await auth();
55
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
56
+
57
+ const body = await req.json();
58
+ const parsed = dealSchema.safeParse(body);
59
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
60
+
61
+ // Get max position in the target stage
62
+ const existing = db
63
+ .select({ position: deals.position })
64
+ .from(deals)
65
+ .where(eq(deals.stageId, parsed.data.stageId))
66
+ .all();
67
+ const maxPos = existing.length > 0 ? Math.max(...existing.map((d) => d.position)) + 1 : 0;
68
+
69
+ const now = new Date().toISOString();
70
+ const deal = db
71
+ .insert(deals)
72
+ .values({
73
+ ...parsed.data,
74
+ position: maxPos,
75
+ userId: parseInt(session.user.id),
76
+ createdAt: now,
77
+ updatedAt: now,
78
+ })
79
+ .returning()
80
+ .get();
81
+
82
+ return NextResponse.json(deal, { status: 201 });
83
+ }
src/app/api/pipelines/route.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { pipelines, pipelineStages } from "@/lib/db/schema";
4
+ import { auth } from "@/lib/auth";
5
+ import { eq } from "drizzle-orm";
6
+
7
+ export async function GET() {
8
+ const session = await auth();
9
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10
+
11
+ const userId = parseInt(session.user.id);
12
+ const results = db
13
+ .select()
14
+ .from(pipelines)
15
+ .where(eq(pipelines.userId, userId))
16
+ .all();
17
+
18
+ return NextResponse.json(results);
19
+ }
src/app/api/search/route.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { deals, contacts, companies } from "@/lib/db/schema";
4
+ import { auth } from "@/lib/auth";
5
+ import { eq } from "drizzle-orm";
6
+
7
+ export async function GET(req: NextRequest) {
8
+ const session = await auth();
9
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10
+
11
+ const q = (req.nextUrl.searchParams.get("q") || "").toLowerCase();
12
+ if (q.length < 2) return NextResponse.json([]);
13
+
14
+ const userId = parseInt(session.user.id);
15
+
16
+ const allDeals = db.select().from(deals).where(eq(deals.userId, userId)).all();
17
+ const allContacts = db.select().from(contacts).where(eq(contacts.userId, userId)).all();
18
+ const allCompanies = db.select().from(companies).where(eq(companies.userId, userId)).all();
19
+
20
+ const results: { id: number; type: string; title: string; subtitle: string }[] = [];
21
+
22
+ allDeals
23
+ .filter((d) => d.title.toLowerCase().includes(q))
24
+ .slice(0, 5)
25
+ .forEach((d) =>
26
+ results.push({
27
+ id: d.id,
28
+ type: "deal",
29
+ title: d.title,
30
+ subtitle: `$${d.value.toLocaleString()} - ${d.status}`,
31
+ })
32
+ );
33
+
34
+ allContacts
35
+ .filter(
36
+ (c) =>
37
+ c.firstName.toLowerCase().includes(q) ||
38
+ c.lastName.toLowerCase().includes(q) ||
39
+ (c.email && c.email.toLowerCase().includes(q))
40
+ )
41
+ .slice(0, 5)
42
+ .forEach((c) =>
43
+ results.push({
44
+ id: c.id,
45
+ type: "contact",
46
+ title: `${c.firstName} ${c.lastName}`,
47
+ subtitle: c.email || c.jobTitle || "",
48
+ })
49
+ );
50
+
51
+ allCompanies
52
+ .filter(
53
+ (c) =>
54
+ c.name.toLowerCase().includes(q) ||
55
+ (c.domain && c.domain.toLowerCase().includes(q))
56
+ )
57
+ .slice(0, 5)
58
+ .forEach((c) =>
59
+ results.push({
60
+ id: c.id,
61
+ type: "company",
62
+ title: c.name,
63
+ subtitle: c.domain || "",
64
+ })
65
+ );
66
+
67
+ return NextResponse.json(results.slice(0, 15));
68
+ }
src/app/api/stages/[id]/route.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { pipelineStages, deals } from "@/lib/db/schema";
4
+ import { stageSchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq } from "drizzle-orm";
7
+
8
+ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const { id } = await params;
13
+ const body = await req.json();
14
+ const parsed = stageSchema.partial().safeParse(body);
15
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
16
+
17
+ const updated = db
18
+ .update(pipelineStages)
19
+ .set(parsed.data)
20
+ .where(eq(pipelineStages.id, parseInt(id)))
21
+ .returning()
22
+ .get();
23
+
24
+ if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
25
+ return NextResponse.json(updated);
26
+ }
27
+
28
+ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
29
+ const session = await auth();
30
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
31
+
32
+ const { id } = await params;
33
+ // Check for deals in this stage
34
+ const dealsInStage = db
35
+ .select()
36
+ .from(deals)
37
+ .where(eq(deals.stageId, parseInt(id)))
38
+ .all();
39
+
40
+ if (dealsInStage.length > 0) {
41
+ return NextResponse.json(
42
+ { error: "Cannot delete stage with existing deals. Move or delete deals first." },
43
+ { status: 400 }
44
+ );
45
+ }
46
+
47
+ const deleted = db
48
+ .delete(pipelineStages)
49
+ .where(eq(pipelineStages.id, parseInt(id)))
50
+ .returning()
51
+ .all();
52
+
53
+ if (!deleted.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
54
+ return NextResponse.json({ success: true });
55
+ }
src/app/api/stages/route.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { pipelineStages } from "@/lib/db/schema";
4
+ import { stageSchema } from "@/lib/validators";
5
+ import { auth } from "@/lib/auth";
6
+ import { eq, asc } from "drizzle-orm";
7
+
8
+ export async function GET(req: NextRequest) {
9
+ const session = await auth();
10
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const pipelineId = req.nextUrl.searchParams.get("pipelineId");
13
+ let results = db
14
+ .select()
15
+ .from(pipelineStages)
16
+ .orderBy(asc(pipelineStages.position))
17
+ .all();
18
+
19
+ if (pipelineId) {
20
+ results = results.filter((s) => s.pipelineId === parseInt(pipelineId));
21
+ }
22
+
23
+ return NextResponse.json(results);
24
+ }
25
+
26
+ export async function POST(req: NextRequest) {
27
+ const session = await auth();
28
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
29
+
30
+ const body = await req.json();
31
+ const parsed = stageSchema.safeParse(body);
32
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
33
+
34
+ const now = new Date().toISOString();
35
+ const stage = db
36
+ .insert(pipelineStages)
37
+ .values({ ...parsed.data, createdAt: now })
38
+ .returning()
39
+ .get();
40
+
41
+ return NextResponse.json(stage, { status: 201 });
42
+ }
src/app/globals.css CHANGED
@@ -1,26 +1,126 @@
1
  @import "tailwindcss";
 
 
2
 
3
- :root {
4
- --background: #ffffff;
5
- --foreground: #171717;
6
- }
7
 
8
  @theme inline {
9
  --color-background: var(--background);
10
  --color-foreground: var(--foreground);
11
  --font-sans: var(--font-geist-sans);
12
  --font-mono: var(--font-geist-mono);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
- @media (prefers-color-scheme: dark) {
16
- :root {
17
- --background: #0a0a0a;
18
- --foreground: #ededed;
19
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
 
22
- body {
23
- background: var(--background);
24
- color: var(--foreground);
25
- font-family: Arial, Helvetica, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
 
 
 
 
 
 
 
 
 
 
1
  @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
 
5
+ @custom-variant dark (&:is(.dark *));
 
 
 
6
 
7
  @theme inline {
8
  --color-background: var(--background);
9
  --color-foreground: var(--foreground);
10
  --font-sans: var(--font-geist-sans);
11
  --font-mono: var(--font-geist-mono);
12
+ --color-sidebar-ring: var(--sidebar-ring);
13
+ --color-sidebar-border: var(--sidebar-border);
14
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
15
+ --color-sidebar-accent: var(--sidebar-accent);
16
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
17
+ --color-sidebar-primary: var(--sidebar-primary);
18
+ --color-sidebar-foreground: var(--sidebar-foreground);
19
+ --color-sidebar: var(--sidebar);
20
+ --color-chart-5: var(--chart-5);
21
+ --color-chart-4: var(--chart-4);
22
+ --color-chart-3: var(--chart-3);
23
+ --color-chart-2: var(--chart-2);
24
+ --color-chart-1: var(--chart-1);
25
+ --color-ring: var(--ring);
26
+ --color-input: var(--input);
27
+ --color-border: var(--border);
28
+ --color-destructive: var(--destructive);
29
+ --color-accent-foreground: var(--accent-foreground);
30
+ --color-accent: var(--accent);
31
+ --color-muted-foreground: var(--muted-foreground);
32
+ --color-muted: var(--muted);
33
+ --color-secondary-foreground: var(--secondary-foreground);
34
+ --color-secondary: var(--secondary);
35
+ --color-primary-foreground: var(--primary-foreground);
36
+ --color-primary: var(--primary);
37
+ --color-popover-foreground: var(--popover-foreground);
38
+ --color-popover: var(--popover);
39
+ --color-card-foreground: var(--card-foreground);
40
+ --color-card: var(--card);
41
+ --radius-sm: calc(var(--radius) - 4px);
42
+ --radius-md: calc(var(--radius) - 2px);
43
+ --radius-lg: var(--radius);
44
+ --radius-xl: calc(var(--radius) + 4px);
45
+ --radius-2xl: calc(var(--radius) + 8px);
46
+ --radius-3xl: calc(var(--radius) + 12px);
47
+ --radius-4xl: calc(var(--radius) + 16px);
48
  }
49
 
50
+ :root {
51
+ --radius: 0.625rem;
52
+ --background: oklch(1 0 0);
53
+ --foreground: oklch(0.145 0 0);
54
+ --card: oklch(1 0 0);
55
+ --card-foreground: oklch(0.145 0 0);
56
+ --popover: oklch(1 0 0);
57
+ --popover-foreground: oklch(0.145 0 0);
58
+ --primary: oklch(0.205 0 0);
59
+ --primary-foreground: oklch(0.985 0 0);
60
+ --secondary: oklch(0.97 0 0);
61
+ --secondary-foreground: oklch(0.205 0 0);
62
+ --muted: oklch(0.97 0 0);
63
+ --muted-foreground: oklch(0.556 0 0);
64
+ --accent: oklch(0.97 0 0);
65
+ --accent-foreground: oklch(0.205 0 0);
66
+ --destructive: oklch(0.577 0.245 27.325);
67
+ --border: oklch(0.922 0 0);
68
+ --input: oklch(0.922 0 0);
69
+ --ring: oklch(0.708 0 0);
70
+ --chart-1: oklch(0.646 0.222 41.116);
71
+ --chart-2: oklch(0.6 0.118 184.704);
72
+ --chart-3: oklch(0.398 0.07 227.392);
73
+ --chart-4: oklch(0.828 0.189 84.429);
74
+ --chart-5: oklch(0.769 0.188 70.08);
75
+ --sidebar: oklch(0.985 0 0);
76
+ --sidebar-foreground: oklch(0.145 0 0);
77
+ --sidebar-primary: oklch(0.205 0 0);
78
+ --sidebar-primary-foreground: oklch(0.985 0 0);
79
+ --sidebar-accent: oklch(0.97 0 0);
80
+ --sidebar-accent-foreground: oklch(0.205 0 0);
81
+ --sidebar-border: oklch(0.922 0 0);
82
+ --sidebar-ring: oklch(0.708 0 0);
83
  }
84
 
85
+ .dark {
86
+ --background: oklch(0.145 0 0);
87
+ --foreground: oklch(0.985 0 0);
88
+ --card: oklch(0.205 0 0);
89
+ --card-foreground: oklch(0.985 0 0);
90
+ --popover: oklch(0.205 0 0);
91
+ --popover-foreground: oklch(0.985 0 0);
92
+ --primary: oklch(0.922 0 0);
93
+ --primary-foreground: oklch(0.205 0 0);
94
+ --secondary: oklch(0.269 0 0);
95
+ --secondary-foreground: oklch(0.985 0 0);
96
+ --muted: oklch(0.269 0 0);
97
+ --muted-foreground: oklch(0.708 0 0);
98
+ --accent: oklch(0.269 0 0);
99
+ --accent-foreground: oklch(0.985 0 0);
100
+ --destructive: oklch(0.704 0.191 22.216);
101
+ --border: oklch(1 0 0 / 10%);
102
+ --input: oklch(1 0 0 / 15%);
103
+ --ring: oklch(0.556 0 0);
104
+ --chart-1: oklch(0.488 0.243 264.376);
105
+ --chart-2: oklch(0.696 0.17 162.48);
106
+ --chart-3: oklch(0.769 0.188 70.08);
107
+ --chart-4: oklch(0.627 0.265 303.9);
108
+ --chart-5: oklch(0.645 0.246 16.439);
109
+ --sidebar: oklch(0.205 0 0);
110
+ --sidebar-foreground: oklch(0.985 0 0);
111
+ --sidebar-primary: oklch(0.488 0.243 264.376);
112
+ --sidebar-primary-foreground: oklch(0.985 0 0);
113
+ --sidebar-accent: oklch(0.269 0 0);
114
+ --sidebar-accent-foreground: oklch(0.985 0 0);
115
+ --sidebar-border: oklch(1 0 0 / 10%);
116
+ --sidebar-ring: oklch(0.556 0 0);
117
  }
118
+
119
+ @layer base {
120
+ * {
121
+ @apply border-border outline-ring/50;
122
+ }
123
+ body {
124
+ @apply bg-background text-foreground;
125
+ }
126
+ }
src/app/layout.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import type { Metadata } from "next";
2
  import { Geist, Geist_Mono } from "next/font/google";
3
  import "./globals.css";
 
4
 
5
  const geistSans = Geist({
6
  variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
13
  });
14
 
15
  export const metadata: Metadata = {
16
- title: "Create Next App",
17
- description: "Generated by create next app",
18
  };
19
 
20
  export default function RootLayout({
@@ -28,6 +29,7 @@ export default function RootLayout({
28
  className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29
  >
30
  {children}
 
31
  </body>
32
  </html>
33
  );
 
1
  import type { Metadata } from "next";
2
  import { Geist, Geist_Mono } from "next/font/google";
3
  import "./globals.css";
4
+ import { Toaster } from "@/components/ui/sonner";
5
 
6
  const geistSans = Geist({
7
  variable: "--font-geist-sans",
 
14
  });
15
 
16
  export const metadata: Metadata = {
17
+ title: "PipeCRM",
18
+ description: "CRM with deal pipelines, contacts, and activity tracking",
19
  };
20
 
21
  export default function RootLayout({
 
29
  className={`${geistSans.variable} ${geistMono.variable} antialiased`}
30
  >
31
  {children}
32
+ <Toaster />
33
  </body>
34
  </html>
35
  );
src/app/page.tsx CHANGED
@@ -1,65 +1,5 @@
1
- import Image from "next/image";
2
 
3
  export default function Home() {
4
- return (
5
- <div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
6
- <main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
7
- <Image
8
- className="dark:invert"
9
- src="/next.svg"
10
- alt="Next.js logo"
11
- width={100}
12
- height={20}
13
- priority
14
- />
15
- <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
16
- <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
17
- To get started, edit the page.tsx file.
18
- </h1>
19
- <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
20
- Looking for a starting point or more instructions? Head over to{" "}
21
- <a
22
- href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
23
- className="font-medium text-zinc-950 dark:text-zinc-50"
24
- >
25
- Templates
26
- </a>{" "}
27
- or the{" "}
28
- <a
29
- href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
30
- className="font-medium text-zinc-950 dark:text-zinc-50"
31
- >
32
- Learning
33
- </a>{" "}
34
- center.
35
- </p>
36
- </div>
37
- <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
38
- <a
39
- className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
40
- href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
41
- target="_blank"
42
- rel="noopener noreferrer"
43
- >
44
- <Image
45
- className="dark:invert"
46
- src="/vercel.svg"
47
- alt="Vercel logomark"
48
- width={16}
49
- height={16}
50
- />
51
- Deploy Now
52
- </a>
53
- <a
54
- className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
55
- href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
56
- target="_blank"
57
- rel="noopener noreferrer"
58
- >
59
- Documentation
60
- </a>
61
- </div>
62
- </main>
63
- </div>
64
- );
65
  }
 
1
+ import { redirect } from "next/navigation";
2
 
3
  export default function Home() {
4
+ redirect("/deals");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  }
src/components/activities/activity-form.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Label } from "@/components/ui/label";
7
+ import { Textarea } from "@/components/ui/textarea";
8
+ import { Card, CardContent } from "@/components/ui/card";
9
+ import { Phone, Mail, Calendar, CheckSquare, StickyNote } from "lucide-react";
10
+ import { cn } from "@/lib/utils";
11
+
12
+ const activityTypes = [
13
+ { value: "call", label: "Call", icon: Phone },
14
+ { value: "email", label: "Email", icon: Mail },
15
+ { value: "meeting", label: "Meeting", icon: Calendar },
16
+ { value: "task", label: "Task", icon: CheckSquare },
17
+ { value: "note", label: "Note", icon: StickyNote },
18
+ ];
19
+
20
+ type Props = {
21
+ dealId?: number | null;
22
+ contactId?: number | null;
23
+ companyId?: number | null;
24
+ onSuccess: () => void;
25
+ onCancel: () => void;
26
+ };
27
+
28
+ export function ActivityForm({ dealId, contactId, companyId, onSuccess, onCancel }: Props) {
29
+ const [type, setType] = useState("call");
30
+ const [loading, setLoading] = useState(false);
31
+
32
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
33
+ e.preventDefault();
34
+ setLoading(true);
35
+
36
+ const form = new FormData(e.currentTarget);
37
+ const data = {
38
+ type,
39
+ subject: form.get("subject") as string,
40
+ description: (form.get("description") as string) || null,
41
+ dueDate: (form.get("dueDate") as string) || null,
42
+ isDone: false,
43
+ dealId: dealId || null,
44
+ contactId: contactId || null,
45
+ companyId: companyId || null,
46
+ };
47
+
48
+ const res = await fetch("/api/activities", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify(data),
52
+ });
53
+
54
+ setLoading(false);
55
+ if (res.ok) onSuccess();
56
+ }
57
+
58
+ return (
59
+ <Card>
60
+ <CardContent className="pt-4">
61
+ <form onSubmit={handleSubmit} className="space-y-4">
62
+ <div className="flex gap-2">
63
+ {activityTypes.map((t) => (
64
+ <Button
65
+ key={t.value}
66
+ type="button"
67
+ variant={type === t.value ? "default" : "outline"}
68
+ size="sm"
69
+ onClick={() => setType(t.value)}
70
+ className="gap-1"
71
+ >
72
+ <t.icon className="h-3 w-3" />
73
+ {t.label}
74
+ </Button>
75
+ ))}
76
+ </div>
77
+ <div className="space-y-2">
78
+ <Label htmlFor="subject">Subject</Label>
79
+ <Input id="subject" name="subject" required placeholder="What needs to be done?" />
80
+ </div>
81
+ <div className="space-y-2">
82
+ <Label htmlFor="description">Description</Label>
83
+ <Textarea id="description" name="description" placeholder="Optional details..." rows={2} />
84
+ </div>
85
+ <div className="space-y-2">
86
+ <Label htmlFor="dueDate">Due Date</Label>
87
+ <Input id="dueDate" name="dueDate" type="date" defaultValue={new Date().toISOString().split("T")[0]} />
88
+ </div>
89
+ <div className="flex justify-end gap-2">
90
+ <Button type="button" variant="outline" size="sm" onClick={onCancel}>
91
+ Cancel
92
+ </Button>
93
+ <Button type="submit" size="sm" disabled={loading}>
94
+ {loading ? "Adding..." : "Add Activity"}
95
+ </Button>
96
+ </div>
97
+ </form>
98
+ </CardContent>
99
+ </Card>
100
+ );
101
+ }
src/components/activities/activity-item.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Checkbox } from "@/components/ui/checkbox";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { formatDate } from "@/lib/utils";
6
+ import { Phone, Mail, Calendar, CheckSquare, StickyNote } from "lucide-react";
7
+ import Link from "next/link";
8
+
9
+ const typeIcons = {
10
+ call: Phone,
11
+ email: Mail,
12
+ meeting: Calendar,
13
+ task: CheckSquare,
14
+ note: StickyNote,
15
+ };
16
+
17
+ const typeColors: Record<string, string> = {
18
+ call: "bg-blue-100 text-blue-800",
19
+ email: "bg-purple-100 text-purple-800",
20
+ meeting: "bg-orange-100 text-orange-800",
21
+ task: "bg-green-100 text-green-800",
22
+ note: "bg-gray-100 text-gray-800",
23
+ };
24
+
25
+ type Props = {
26
+ activity: {
27
+ id: number;
28
+ type: string;
29
+ subject: string;
30
+ dueDate: string | null;
31
+ isDone: boolean;
32
+ dealId?: number | null;
33
+ dealTitle?: string | null;
34
+ contactId?: number | null;
35
+ contactFirstName?: string | null;
36
+ contactLastName?: string | null;
37
+ companyId?: number | null;
38
+ companyName?: string | null;
39
+ };
40
+ onToggle: () => void;
41
+ };
42
+
43
+ export function ActivityItem({ activity, onToggle }: Props) {
44
+ const Icon = typeIcons[activity.type as keyof typeof typeIcons] || StickyNote;
45
+
46
+ return (
47
+ <div className="flex items-start gap-3 p-3 rounded-lg border">
48
+ <Checkbox
49
+ checked={activity.isDone}
50
+ onCheckedChange={onToggle}
51
+ className="mt-0.5"
52
+ />
53
+ <div className="flex-1 min-w-0">
54
+ <div className="flex items-center gap-2 mb-1">
55
+ <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
56
+ <span className={`text-sm font-medium ${activity.isDone ? "line-through text-muted-foreground" : ""}`}>
57
+ {activity.subject}
58
+ </span>
59
+ </div>
60
+ <div className="flex flex-wrap gap-1.5">
61
+ <Badge variant="outline" className={`text-xs ${typeColors[activity.type]}`}>
62
+ {activity.type}
63
+ </Badge>
64
+ {activity.dueDate && (
65
+ <span className="text-xs text-muted-foreground">
66
+ {formatDate(activity.dueDate)}
67
+ </span>
68
+ )}
69
+ {activity.dealTitle && (
70
+ <Link href={`/deals/${activity.dealId}`} className="text-xs text-primary hover:underline">
71
+ {activity.dealTitle}
72
+ </Link>
73
+ )}
74
+ {activity.contactFirstName && (
75
+ <Link href={`/contacts/${activity.contactId}`} className="text-xs text-primary hover:underline">
76
+ {activity.contactFirstName} {activity.contactLastName}
77
+ </Link>
78
+ )}
79
+ {activity.companyName && (
80
+ <Link href={`/companies/${activity.companyId}`} className="text-xs text-primary hover:underline">
81
+ {activity.companyName}
82
+ </Link>
83
+ )}
84
+ </div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
src/components/deals/deal-form.tsx ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from "@/components/ui/dialog";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Input } from "@/components/ui/input";
12
+ import { Label } from "@/components/ui/label";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "@/components/ui/select";
20
+ import type { Deal, Stage } from "./kanban-board";
21
+
22
+ type Props = {
23
+ open: boolean;
24
+ onOpenChange: (open: boolean) => void;
25
+ deal: Deal | null;
26
+ stages: Stage[];
27
+ contacts: { id: number; firstName: string; lastName: string; companyId: number | null }[];
28
+ companies: { id: number; name: string }[];
29
+ pipelineId: number;
30
+ onSuccess: () => void;
31
+ };
32
+
33
+ export function DealFormDialog({
34
+ open,
35
+ onOpenChange,
36
+ deal,
37
+ stages,
38
+ contacts,
39
+ companies,
40
+ pipelineId,
41
+ onSuccess,
42
+ }: Props) {
43
+ const [loading, setLoading] = useState(false);
44
+
45
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
46
+ e.preventDefault();
47
+ setLoading(true);
48
+
49
+ const form = new FormData(e.currentTarget);
50
+ const data = {
51
+ title: form.get("title") as string,
52
+ value: parseFloat(form.get("value") as string) || 0,
53
+ stageId: parseInt(form.get("stageId") as string),
54
+ contactId: form.get("contactId") ? parseInt(form.get("contactId") as string) : null,
55
+ companyId: form.get("companyId") ? parseInt(form.get("companyId") as string) : null,
56
+ expectedCloseDate: (form.get("expectedCloseDate") as string) || null,
57
+ pipelineId,
58
+ };
59
+
60
+ const url = deal ? `/api/deals/${deal.id}` : "/api/deals";
61
+ const method = deal ? "PATCH" : "POST";
62
+
63
+ const res = await fetch(url, {
64
+ method,
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify(data),
67
+ });
68
+
69
+ setLoading(false);
70
+ if (res.ok) onSuccess();
71
+ }
72
+
73
+ return (
74
+ <Dialog open={open} onOpenChange={onOpenChange}>
75
+ <DialogContent>
76
+ <DialogHeader>
77
+ <DialogTitle>{deal ? "Edit Deal" : "New Deal"}</DialogTitle>
78
+ </DialogHeader>
79
+ <form onSubmit={handleSubmit} className="space-y-4">
80
+ <div className="space-y-2">
81
+ <Label htmlFor="title">Title</Label>
82
+ <Input id="title" name="title" defaultValue={deal?.title} required />
83
+ </div>
84
+ <div className="space-y-2">
85
+ <Label htmlFor="value">Value ($)</Label>
86
+ <Input
87
+ id="value"
88
+ name="value"
89
+ type="number"
90
+ step="0.01"
91
+ defaultValue={deal?.value || ""}
92
+ />
93
+ </div>
94
+ <div className="space-y-2">
95
+ <Label>Stage</Label>
96
+ <Select name="stageId" defaultValue={String(deal?.stageId || stages[0]?.id)}>
97
+ <SelectTrigger>
98
+ <SelectValue />
99
+ </SelectTrigger>
100
+ <SelectContent>
101
+ {stages
102
+ .sort((a, b) => a.position - b.position)
103
+ .map((s) => (
104
+ <SelectItem key={s.id} value={String(s.id)}>
105
+ {s.name}
106
+ </SelectItem>
107
+ ))}
108
+ </SelectContent>
109
+ </Select>
110
+ </div>
111
+ <div className="space-y-2">
112
+ <Label>Contact</Label>
113
+ <Select name="contactId" defaultValue={deal?.contactId ? String(deal.contactId) : ""}>
114
+ <SelectTrigger>
115
+ <SelectValue placeholder="Select contact..." />
116
+ </SelectTrigger>
117
+ <SelectContent>
118
+ {contacts.map((c) => (
119
+ <SelectItem key={c.id} value={String(c.id)}>
120
+ {c.firstName} {c.lastName}
121
+ </SelectItem>
122
+ ))}
123
+ </SelectContent>
124
+ </Select>
125
+ </div>
126
+ <div className="space-y-2">
127
+ <Label>Company</Label>
128
+ <Select name="companyId" defaultValue={deal?.companyId ? String(deal.companyId) : ""}>
129
+ <SelectTrigger>
130
+ <SelectValue placeholder="Select company..." />
131
+ </SelectTrigger>
132
+ <SelectContent>
133
+ {companies.map((c) => (
134
+ <SelectItem key={c.id} value={String(c.id)}>
135
+ {c.name}
136
+ </SelectItem>
137
+ ))}
138
+ </SelectContent>
139
+ </Select>
140
+ </div>
141
+ <div className="space-y-2">
142
+ <Label htmlFor="expectedCloseDate">Expected Close Date</Label>
143
+ <Input
144
+ id="expectedCloseDate"
145
+ name="expectedCloseDate"
146
+ type="date"
147
+ defaultValue={deal?.expectedCloseDate || ""}
148
+ />
149
+ </div>
150
+ <div className="flex justify-end gap-2">
151
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
152
+ Cancel
153
+ </Button>
154
+ <Button type="submit" disabled={loading}>
155
+ {loading ? "Saving..." : deal ? "Update" : "Create"}
156
+ </Button>
157
+ </div>
158
+ </form>
159
+ </DialogContent>
160
+ </Dialog>
161
+ );
162
+ }
src/components/deals/kanban-board.tsx ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect } from "react";
4
+ import {
5
+ DndContext,
6
+ DragOverlay,
7
+ closestCorners,
8
+ KeyboardSensor,
9
+ PointerSensor,
10
+ useSensor,
11
+ useSensors,
12
+ DragStartEvent,
13
+ DragEndEvent,
14
+ DragOverEvent,
15
+ } from "@dnd-kit/core";
16
+ import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
17
+ import { KanbanColumn } from "./kanban-column";
18
+ import { KanbanCard } from "./kanban-card";
19
+ import { DealFormDialog } from "./deal-form";
20
+ import { Button } from "@/components/ui/button";
21
+ import { Plus } from "lucide-react";
22
+ import { toast } from "sonner";
23
+
24
+ export type Deal = {
25
+ id: number;
26
+ title: string;
27
+ value: number;
28
+ status: string;
29
+ position: number;
30
+ expectedCloseDate: string | null;
31
+ stageId: number;
32
+ stageName: string | null;
33
+ contactId: number | null;
34
+ contactFirstName: string | null;
35
+ contactLastName: string | null;
36
+ companyId: number | null;
37
+ companyName: string | null;
38
+ pipelineId: number;
39
+ };
40
+
41
+ export type Stage = {
42
+ id: number;
43
+ name: string;
44
+ position: number;
45
+ probability: number;
46
+ pipelineId: number;
47
+ };
48
+
49
+ type Props = {
50
+ initialDeals: Deal[];
51
+ stages: Stage[];
52
+ pipelineId: number;
53
+ contacts: { id: number; firstName: string; lastName: string; companyId: number | null }[];
54
+ companies: { id: number; name: string }[];
55
+ };
56
+
57
+ export function KanbanBoard({ initialDeals, stages, pipelineId, contacts, companies }: Props) {
58
+ const [deals, setDeals] = useState<Deal[]>(initialDeals);
59
+ const [activeId, setActiveId] = useState<number | null>(null);
60
+ const [showForm, setShowForm] = useState(false);
61
+ const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
62
+
63
+ const sensors = useSensors(
64
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
65
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
66
+ );
67
+
68
+ const activeDeal = deals.find((d) => d.id === activeId);
69
+
70
+ function getDealsByStage(stageId: number) {
71
+ return deals
72
+ .filter((d) => d.stageId === stageId)
73
+ .sort((a, b) => a.position - b.position);
74
+ }
75
+
76
+ function handleDragStart(event: DragStartEvent) {
77
+ setActiveId(event.active.id as number);
78
+ }
79
+
80
+ function handleDragOver(event: DragOverEvent) {
81
+ const { active, over } = event;
82
+ if (!over) return;
83
+
84
+ const activeId = active.id as number;
85
+ const overId = over.id;
86
+
87
+ const activeDeal = deals.find((d) => d.id === activeId);
88
+ if (!activeDeal) return;
89
+
90
+ // Determine target stage
91
+ let targetStageId: number;
92
+ const overDeal = deals.find((d) => d.id === overId);
93
+ if (overDeal) {
94
+ targetStageId = overDeal.stageId;
95
+ } else {
96
+ // Over a column
97
+ targetStageId = overId as number;
98
+ }
99
+
100
+ if (activeDeal.stageId !== targetStageId) {
101
+ setDeals((prev) => {
102
+ const updated = prev.map((d) =>
103
+ d.id === activeId ? { ...d, stageId: targetStageId } : d
104
+ );
105
+ return updated;
106
+ });
107
+ }
108
+ }
109
+
110
+ async function handleDragEnd(event: DragEndEvent) {
111
+ const { active, over } = event;
112
+ setActiveId(null);
113
+
114
+ if (!over) return;
115
+
116
+ const activeId = active.id as number;
117
+ const overId = over.id;
118
+
119
+ const activeDeal = deals.find((d) => d.id === activeId);
120
+ if (!activeDeal) return;
121
+
122
+ let targetStageId = activeDeal.stageId;
123
+ let newPosition = 0;
124
+
125
+ const overDeal = deals.find((d) => d.id === overId);
126
+ if (overDeal) {
127
+ targetStageId = overDeal.stageId;
128
+ const stageDeals = getDealsByStage(targetStageId).filter((d) => d.id !== activeId);
129
+ const overIndex = stageDeals.findIndex((d) => d.id === overId);
130
+ newPosition = overIndex >= 0 ? overIndex : stageDeals.length;
131
+ } else {
132
+ targetStageId = overId as number;
133
+ newPosition = getDealsByStage(targetStageId).filter((d) => d.id !== activeId).length;
134
+ }
135
+
136
+ // Optimistic update
137
+ setDeals((prev) => {
138
+ const updated = prev.map((d) =>
139
+ d.id === activeId ? { ...d, stageId: targetStageId, position: newPosition } : d
140
+ );
141
+ // Re-index positions
142
+ const stageDeals = updated
143
+ .filter((d) => d.stageId === targetStageId && d.id !== activeId)
144
+ .sort((a, b) => a.position - b.position);
145
+ stageDeals.splice(newPosition, 0, { ...activeDeal, stageId: targetStageId });
146
+ const reindexed = stageDeals.map((d, i) => ({ ...d, position: i }));
147
+
148
+ return updated.map((d) => {
149
+ const reindexedDeal = reindexed.find((r) => r.id === d.id);
150
+ return reindexedDeal || d;
151
+ });
152
+ });
153
+
154
+ try {
155
+ await fetch("/api/deals/reorder", {
156
+ method: "PATCH",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify({ dealId: activeId, newStageId: targetStageId, newPosition }),
159
+ });
160
+ } catch {
161
+ toast.error("Failed to reorder deal");
162
+ }
163
+ }
164
+
165
+ async function handleDealCreated() {
166
+ const res = await fetch(`/api/deals?pipelineId=${pipelineId}`);
167
+ if (res.ok) setDeals(await res.json());
168
+ setShowForm(false);
169
+ setEditingDeal(null);
170
+ toast.success("Deal saved");
171
+ }
172
+
173
+ async function handleDealDeleted(dealId: number) {
174
+ await fetch(`/api/deals/${dealId}`, { method: "DELETE" });
175
+ setDeals((prev) => prev.filter((d) => d.id !== dealId));
176
+ toast.success("Deal deleted");
177
+ }
178
+
179
+ const stageValue = (stageId: number) =>
180
+ deals
181
+ .filter((d) => d.stageId === stageId)
182
+ .reduce((sum, d) => sum + d.value, 0);
183
+
184
+ return (
185
+ <div>
186
+ <div className="flex items-center justify-between mb-4">
187
+ <h1 className="text-2xl font-bold">Deals</h1>
188
+ <Button onClick={() => { setEditingDeal(null); setShowForm(true); }}>
189
+ <Plus className="h-4 w-4 mr-2" />
190
+ Add Deal
191
+ </Button>
192
+ </div>
193
+
194
+ <DndContext
195
+ sensors={sensors}
196
+ collisionDetection={closestCorners}
197
+ onDragStart={handleDragStart}
198
+ onDragOver={handleDragOver}
199
+ onDragEnd={handleDragEnd}
200
+ >
201
+ <div className="flex gap-4 overflow-x-auto pb-4">
202
+ {stages
203
+ .sort((a, b) => a.position - b.position)
204
+ .map((stage) => (
205
+ <KanbanColumn
206
+ key={stage.id}
207
+ stage={stage}
208
+ deals={getDealsByStage(stage.id)}
209
+ totalValue={stageValue(stage.id)}
210
+ onEdit={(deal) => { setEditingDeal(deal); setShowForm(true); }}
211
+ onDelete={handleDealDeleted}
212
+ />
213
+ ))}
214
+ </div>
215
+
216
+ <DragOverlay>
217
+ {activeDeal ? <KanbanCard deal={activeDeal} isOverlay /> : null}
218
+ </DragOverlay>
219
+ </DndContext>
220
+
221
+ <DealFormDialog
222
+ open={showForm}
223
+ onOpenChange={setShowForm}
224
+ deal={editingDeal}
225
+ stages={stages}
226
+ contacts={contacts}
227
+ companies={companies}
228
+ pipelineId={pipelineId}
229
+ onSuccess={handleDealCreated}
230
+ />
231
+ </div>
232
+ );
233
+ }
src/components/deals/kanban-card.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useSortable } from "@dnd-kit/sortable";
4
+ import { CSS } from "@dnd-kit/utilities";
5
+ import { Card } from "@/components/ui/card";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ } from "@/components/ui/dropdown-menu";
14
+ import { MoreHorizontal, Pencil, Trash2, Calendar, User, Building2 } from "lucide-react";
15
+ import { formatCurrency, formatDate } from "@/lib/utils";
16
+ import Link from "next/link";
17
+ import type { Deal } from "./kanban-board";
18
+
19
+ type Props = {
20
+ deal: Deal;
21
+ isOverlay?: boolean;
22
+ onEdit?: () => void;
23
+ onDelete?: () => void;
24
+ };
25
+
26
+ export function KanbanCard({ deal, isOverlay, onEdit, onDelete }: Props) {
27
+ const {
28
+ attributes,
29
+ listeners,
30
+ setNodeRef,
31
+ transform,
32
+ transition,
33
+ isDragging,
34
+ } = useSortable({ id: deal.id });
35
+
36
+ const style = {
37
+ transform: CSS.Transform.toString(transform),
38
+ transition,
39
+ opacity: isDragging ? 0.5 : 1,
40
+ };
41
+
42
+ return (
43
+ <Card
44
+ ref={setNodeRef}
45
+ style={style}
46
+ {...attributes}
47
+ {...listeners}
48
+ className={`p-3 cursor-grab active:cursor-grabbing ${
49
+ isOverlay ? "shadow-lg ring-2 ring-primary" : ""
50
+ }`}
51
+ >
52
+ <div className="flex items-start justify-between">
53
+ <Link href={`/deals/${deal.id}`} className="hover:underline flex-1" onClick={(e) => e.stopPropagation()}>
54
+ <h4 className="text-sm font-medium leading-tight">{deal.title}</h4>
55
+ </Link>
56
+ {!isOverlay && (
57
+ <DropdownMenu>
58
+ <DropdownMenuTrigger asChild>
59
+ <Button
60
+ variant="ghost"
61
+ size="icon"
62
+ className="h-6 w-6 shrink-0"
63
+ onClick={(e) => e.stopPropagation()}
64
+ >
65
+ <MoreHorizontal className="h-3 w-3" />
66
+ </Button>
67
+ </DropdownMenuTrigger>
68
+ <DropdownMenuContent align="end">
69
+ <DropdownMenuItem onClick={onEdit}>
70
+ <Pencil className="h-3 w-3 mr-2" />
71
+ Edit
72
+ </DropdownMenuItem>
73
+ <DropdownMenuItem onClick={onDelete} className="text-destructive">
74
+ <Trash2 className="h-3 w-3 mr-2" />
75
+ Delete
76
+ </DropdownMenuItem>
77
+ </DropdownMenuContent>
78
+ </DropdownMenu>
79
+ )}
80
+ </div>
81
+ <p className="text-sm font-semibold text-primary mt-1">
82
+ {formatCurrency(deal.value)}
83
+ </p>
84
+ <div className="flex flex-wrap gap-1 mt-2 text-xs text-muted-foreground">
85
+ {deal.contactFirstName && (
86
+ <span className="flex items-center gap-1">
87
+ <User className="h-3 w-3" />
88
+ {deal.contactFirstName} {deal.contactLastName}
89
+ </span>
90
+ )}
91
+ {deal.companyName && (
92
+ <span className="flex items-center gap-1">
93
+ <Building2 className="h-3 w-3" />
94
+ {deal.companyName}
95
+ </span>
96
+ )}
97
+ {deal.expectedCloseDate && (
98
+ <span className="flex items-center gap-1">
99
+ <Calendar className="h-3 w-3" />
100
+ {formatDate(deal.expectedCloseDate)}
101
+ </span>
102
+ )}
103
+ </div>
104
+ </Card>
105
+ );
106
+ }
src/components/deals/kanban-column.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useDroppable } from "@dnd-kit/core";
4
+ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
5
+ import { KanbanCard } from "./kanban-card";
6
+ import { formatCurrency } from "@/lib/utils";
7
+ import { ScrollArea } from "@/components/ui/scroll-area";
8
+ import type { Deal, Stage } from "./kanban-board";
9
+
10
+ type Props = {
11
+ stage: Stage;
12
+ deals: Deal[];
13
+ totalValue: number;
14
+ onEdit: (deal: Deal) => void;
15
+ onDelete: (dealId: number) => void;
16
+ };
17
+
18
+ export function KanbanColumn({ stage, deals, totalValue, onEdit, onDelete }: Props) {
19
+ const { setNodeRef, isOver } = useDroppable({ id: stage.id });
20
+
21
+ return (
22
+ <div
23
+ ref={setNodeRef}
24
+ className={`flex flex-col w-72 min-w-72 rounded-lg bg-muted/50 ${
25
+ isOver ? "ring-2 ring-primary/50" : ""
26
+ }`}
27
+ >
28
+ <div className="p-3 border-b">
29
+ <div className="flex items-center justify-between">
30
+ <h3 className="font-semibold text-sm">{stage.name}</h3>
31
+ <span className="text-xs text-muted-foreground bg-muted rounded-full px-2 py-0.5">
32
+ {deals.length}
33
+ </span>
34
+ </div>
35
+ <p className="text-xs text-muted-foreground mt-1">
36
+ {formatCurrency(totalValue)}
37
+ </p>
38
+ </div>
39
+ <ScrollArea className="flex-1 p-2" style={{ maxHeight: "calc(100vh - 240px)" }}>
40
+ <SortableContext
41
+ items={deals.map((d) => d.id)}
42
+ strategy={verticalListSortingStrategy}
43
+ >
44
+ <div className="space-y-2 min-h-[40px]">
45
+ {deals.map((deal) => (
46
+ <KanbanCard
47
+ key={deal.id}
48
+ deal={deal}
49
+ onEdit={() => onEdit(deal)}
50
+ onDelete={() => onDelete(deal.id)}
51
+ />
52
+ ))}
53
+ </div>
54
+ </SortableContext>
55
+ </ScrollArea>
56
+ </div>
57
+ );
58
+ }
src/components/layout/sidebar.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { cn } from "@/lib/utils";
6
+ import {
7
+ LayoutDashboard,
8
+ Handshake,
9
+ Users,
10
+ Building2,
11
+ CalendarCheck,
12
+ Settings,
13
+ ChevronLeft,
14
+ } from "lucide-react";
15
+ import { Button } from "@/components/ui/button";
16
+ import { useState } from "react";
17
+
18
+ const navItems = [
19
+ { href: "/deals", label: "Deals", icon: Handshake },
20
+ { href: "/contacts", label: "Contacts", icon: Users },
21
+ { href: "/companies", label: "Companies", icon: Building2 },
22
+ { href: "/activities", label: "Activities", icon: CalendarCheck },
23
+ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
24
+ { href: "/settings", label: "Settings", icon: Settings },
25
+ ];
26
+
27
+ export function Sidebar() {
28
+ const pathname = usePathname();
29
+ const [collapsed, setCollapsed] = useState(false);
30
+
31
+ return (
32
+ <aside
33
+ className={cn(
34
+ "flex flex-col bg-zinc-900 text-zinc-100 transition-all duration-200 h-screen sticky top-0",
35
+ collapsed ? "w-16" : "w-60"
36
+ )}
37
+ >
38
+ <div className="flex items-center justify-between px-4 h-14 border-b border-zinc-800">
39
+ {!collapsed && (
40
+ <Link href="/deals" className="text-lg font-bold tracking-tight">
41
+ PipeCRM
42
+ </Link>
43
+ )}
44
+ <Button
45
+ variant="ghost"
46
+ size="icon"
47
+ className="text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 ml-auto"
48
+ onClick={() => setCollapsed(!collapsed)}
49
+ >
50
+ <ChevronLeft
51
+ className={cn("h-4 w-4 transition-transform", collapsed && "rotate-180")}
52
+ />
53
+ </Button>
54
+ </div>
55
+ <nav className="flex-1 py-4 space-y-1 px-2">
56
+ {navItems.map((item) => {
57
+ const isActive = pathname.startsWith(item.href);
58
+ return (
59
+ <Link
60
+ key={item.href}
61
+ href={item.href}
62
+ className={cn(
63
+ "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
64
+ isActive
65
+ ? "bg-zinc-800 text-white"
66
+ : "text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-100"
67
+ )}
68
+ >
69
+ <item.icon className="h-4 w-4 shrink-0" />
70
+ {!collapsed && <span>{item.label}</span>}
71
+ </Link>
72
+ );
73
+ })}
74
+ </nav>
75
+ </aside>
76
+ );
77
+ }
src/components/layout/topbar.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { signOut, useSession } from "next-auth/react";
4
+ import { Button } from "@/components/ui/button";
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuTrigger,
10
+ } from "@/components/ui/dropdown-menu";
11
+ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
12
+ import { LogOut, Search } from "lucide-react";
13
+ import { GlobalSearch } from "@/components/shared/global-search";
14
+
15
+ export function Topbar() {
16
+ const { data: session } = useSession();
17
+ const initials = session?.user?.name
18
+ ?.split(" ")
19
+ .map((n) => n[0])
20
+ .join("")
21
+ .toUpperCase() || "U";
22
+
23
+ return (
24
+ <header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-6">
25
+ <GlobalSearch />
26
+ <div className="ml-auto flex items-center gap-2">
27
+ <DropdownMenu>
28
+ <DropdownMenuTrigger asChild>
29
+ <Button variant="ghost" className="relative h-8 w-8 rounded-full">
30
+ <Avatar className="h-8 w-8">
31
+ <AvatarFallback className="bg-primary text-primary-foreground text-xs">
32
+ {initials}
33
+ </AvatarFallback>
34
+ </Avatar>
35
+ </Button>
36
+ </DropdownMenuTrigger>
37
+ <DropdownMenuContent align="end">
38
+ <DropdownMenuItem className="text-sm text-muted-foreground" disabled>
39
+ {session?.user?.email}
40
+ </DropdownMenuItem>
41
+ <DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
42
+ <LogOut className="mr-2 h-4 w-4" />
43
+ Sign out
44
+ </DropdownMenuItem>
45
+ </DropdownMenuContent>
46
+ </DropdownMenu>
47
+ </div>
48
+ </header>
49
+ );
50
+ }
src/components/shared/confirm-dialog.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { Button } from "@/components/ui/button";
12
+
13
+ type Props = {
14
+ open: boolean;
15
+ onOpenChange: (open: boolean) => void;
16
+ title: string;
17
+ description: string;
18
+ onConfirm: () => void;
19
+ destructive?: boolean;
20
+ };
21
+
22
+ export function ConfirmDialog({
23
+ open,
24
+ onOpenChange,
25
+ title,
26
+ description,
27
+ onConfirm,
28
+ destructive,
29
+ }: Props) {
30
+ return (
31
+ <Dialog open={open} onOpenChange={onOpenChange}>
32
+ <DialogContent>
33
+ <DialogHeader>
34
+ <DialogTitle>{title}</DialogTitle>
35
+ <DialogDescription>{description}</DialogDescription>
36
+ </DialogHeader>
37
+ <DialogFooter>
38
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
39
+ Cancel
40
+ </Button>
41
+ <Button
42
+ variant={destructive ? "destructive" : "default"}
43
+ onClick={() => {
44
+ onConfirm();
45
+ onOpenChange(false);
46
+ }}
47
+ >
48
+ Confirm
49
+ </Button>
50
+ </DialogFooter>
51
+ </DialogContent>
52
+ </Dialog>
53
+ );
54
+ }
src/components/shared/data-table.tsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ ColumnDef,
5
+ flexRender,
6
+ getCoreRowModel,
7
+ getSortedRowModel,
8
+ getPaginationRowModel,
9
+ SortingState,
10
+ useReactTable,
11
+ } from "@tanstack/react-table";
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow,
19
+ } from "@/components/ui/table";
20
+ import { Button } from "@/components/ui/button";
21
+ import { useState } from "react";
22
+ import { ChevronLeft, ChevronRight, ChevronsUpDown, ChevronUp, ChevronDown } from "lucide-react";
23
+
24
+ interface DataTableProps<TData, TValue> {
25
+ columns: ColumnDef<TData, TValue>[];
26
+ data: TData[];
27
+ onRowClick?: (row: TData) => void;
28
+ }
29
+
30
+ export function DataTable<TData, TValue>({
31
+ columns,
32
+ data,
33
+ onRowClick,
34
+ }: DataTableProps<TData, TValue>) {
35
+ const [sorting, setSorting] = useState<SortingState>([]);
36
+
37
+ const table = useReactTable({
38
+ data,
39
+ columns,
40
+ getCoreRowModel: getCoreRowModel(),
41
+ getSortedRowModel: getSortedRowModel(),
42
+ getPaginationRowModel: getPaginationRowModel(),
43
+ onSortingChange: setSorting,
44
+ state: { sorting },
45
+ initialState: { pagination: { pageSize: 15 } },
46
+ });
47
+
48
+ return (
49
+ <div>
50
+ <div className="rounded-md border">
51
+ <Table>
52
+ <TableHeader>
53
+ {table.getHeaderGroups().map((headerGroup) => (
54
+ <TableRow key={headerGroup.id}>
55
+ {headerGroup.headers.map((header) => (
56
+ <TableHead
57
+ key={header.id}
58
+ className={header.column.getCanSort() ? "cursor-pointer select-none" : ""}
59
+ onClick={header.column.getToggleSortingHandler()}
60
+ >
61
+ <div className="flex items-center gap-1">
62
+ {header.isPlaceholder
63
+ ? null
64
+ : flexRender(header.column.columnDef.header, header.getContext())}
65
+ {header.column.getCanSort() && (
66
+ <span className="ml-1">
67
+ {header.column.getIsSorted() === "asc" ? (
68
+ <ChevronUp className="h-3 w-3" />
69
+ ) : header.column.getIsSorted() === "desc" ? (
70
+ <ChevronDown className="h-3 w-3" />
71
+ ) : (
72
+ <ChevronsUpDown className="h-3 w-3 text-muted-foreground/50" />
73
+ )}
74
+ </span>
75
+ )}
76
+ </div>
77
+ </TableHead>
78
+ ))}
79
+ </TableRow>
80
+ ))}
81
+ </TableHeader>
82
+ <TableBody>
83
+ {table.getRowModel().rows?.length ? (
84
+ table.getRowModel().rows.map((row) => (
85
+ <TableRow
86
+ key={row.id}
87
+ className={onRowClick ? "cursor-pointer" : ""}
88
+ onClick={() => onRowClick?.(row.original)}
89
+ >
90
+ {row.getVisibleCells().map((cell) => (
91
+ <TableCell key={cell.id}>
92
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
93
+ </TableCell>
94
+ ))}
95
+ </TableRow>
96
+ ))
97
+ ) : (
98
+ <TableRow>
99
+ <TableCell colSpan={columns.length} className="h-24 text-center">
100
+ No results.
101
+ </TableCell>
102
+ </TableRow>
103
+ )}
104
+ </TableBody>
105
+ </Table>
106
+ </div>
107
+ <div className="flex items-center justify-between py-4">
108
+ <p className="text-sm text-muted-foreground">
109
+ {table.getFilteredRowModel().rows.length} row(s)
110
+ </p>
111
+ <div className="flex gap-2">
112
+ <Button
113
+ variant="outline"
114
+ size="sm"
115
+ onClick={() => table.previousPage()}
116
+ disabled={!table.getCanPreviousPage()}
117
+ >
118
+ <ChevronLeft className="h-4 w-4" />
119
+ </Button>
120
+ <Button
121
+ variant="outline"
122
+ size="sm"
123
+ onClick={() => table.nextPage()}
124
+ disabled={!table.getCanNextPage()}
125
+ >
126
+ <ChevronRight className="h-4 w-4" />
127
+ </Button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ );
132
+ }
src/components/shared/global-search.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import {
6
+ CommandDialog,
7
+ CommandInput,
8
+ CommandList,
9
+ CommandEmpty,
10
+ CommandGroup,
11
+ CommandItem,
12
+ } from "@/components/ui/command";
13
+ import { Button } from "@/components/ui/button";
14
+ import { Search, Handshake, Users, Building2 } from "lucide-react";
15
+
16
+ type SearchResult = {
17
+ id: number;
18
+ type: "deal" | "contact" | "company";
19
+ title: string;
20
+ subtitle: string;
21
+ };
22
+
23
+ export function GlobalSearch() {
24
+ const [open, setOpen] = useState(false);
25
+ const [query, setQuery] = useState("");
26
+ const [results, setResults] = useState<SearchResult[]>([]);
27
+ const router = useRouter();
28
+
29
+ useEffect(() => {
30
+ const down = (e: KeyboardEvent) => {
31
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
32
+ e.preventDefault();
33
+ setOpen((o) => !o);
34
+ }
35
+ };
36
+ document.addEventListener("keydown", down);
37
+ return () => document.removeEventListener("keydown", down);
38
+ }, []);
39
+
40
+ useEffect(() => {
41
+ if (!query || query.length < 2) {
42
+ setResults([]);
43
+ return;
44
+ }
45
+ const timer = setTimeout(async () => {
46
+ const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
47
+ if (res.ok) setResults(await res.json());
48
+ }, 200);
49
+ return () => clearTimeout(timer);
50
+ }, [query]);
51
+
52
+ function handleSelect(result: SearchResult) {
53
+ setOpen(false);
54
+ setQuery("");
55
+ const paths = {
56
+ deal: `/deals/${result.id}`,
57
+ contact: `/contacts/${result.id}`,
58
+ company: `/companies/${result.id}`,
59
+ };
60
+ router.push(paths[result.type]);
61
+ }
62
+
63
+ const icons = {
64
+ deal: Handshake,
65
+ contact: Users,
66
+ company: Building2,
67
+ };
68
+
69
+ return (
70
+ <>
71
+ <Button
72
+ variant="outline"
73
+ className="relative h-9 w-full max-w-sm justify-start text-sm text-muted-foreground"
74
+ onClick={() => setOpen(true)}
75
+ >
76
+ <Search className="mr-2 h-4 w-4" />
77
+ Search...
78
+ <kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100">
79
+ <span className="text-xs">&#8984;</span>K
80
+ </kbd>
81
+ </Button>
82
+ <CommandDialog open={open} onOpenChange={setOpen}>
83
+ <CommandInput
84
+ placeholder="Search deals, contacts, companies..."
85
+ value={query}
86
+ onValueChange={setQuery}
87
+ />
88
+ <CommandList>
89
+ <CommandEmpty>No results found.</CommandEmpty>
90
+ {results.length > 0 && (
91
+ <CommandGroup heading="Results">
92
+ {results.map((r) => {
93
+ const Icon = icons[r.type];
94
+ return (
95
+ <CommandItem
96
+ key={`${r.type}-${r.id}`}
97
+ onSelect={() => handleSelect(r)}
98
+ >
99
+ <Icon className="mr-2 h-4 w-4 text-muted-foreground" />
100
+ <div>
101
+ <div className="text-sm font-medium">{r.title}</div>
102
+ <div className="text-xs text-muted-foreground">{r.subtitle}</div>
103
+ </div>
104
+ </CommandItem>
105
+ );
106
+ })}
107
+ </CommandGroup>
108
+ )}
109
+ </CommandList>
110
+ </CommandDialog>
111
+ </>
112
+ );
113
+ }
src/components/ui/avatar.tsx ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Avatar as AvatarPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Avatar({
9
+ className,
10
+ size = "default",
11
+ ...props
12
+ }: React.ComponentProps<typeof AvatarPrimitive.Root> & {
13
+ size?: "default" | "sm" | "lg"
14
+ }) {
15
+ return (
16
+ <AvatarPrimitive.Root
17
+ data-slot="avatar"
18
+ data-size={size}
19
+ className={cn(
20
+ "group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ function AvatarImage({
29
+ className,
30
+ ...props
31
+ }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
32
+ return (
33
+ <AvatarPrimitive.Image
34
+ data-slot="avatar-image"
35
+ className={cn("aspect-square size-full", className)}
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function AvatarFallback({
42
+ className,
43
+ ...props
44
+ }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
45
+ return (
46
+ <AvatarPrimitive.Fallback
47
+ data-slot="avatar-fallback"
48
+ className={cn(
49
+ "bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
50
+ className
51
+ )}
52
+ {...props}
53
+ />
54
+ )
55
+ }
56
+
57
+ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
58
+ return (
59
+ <span
60
+ data-slot="avatar-badge"
61
+ className={cn(
62
+ "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
63
+ "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
64
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
65
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
66
+ className
67
+ )}
68
+ {...props}
69
+ />
70
+ )
71
+ }
72
+
73
+ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
74
+ return (
75
+ <div
76
+ data-slot="avatar-group"
77
+ className={cn(
78
+ "*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
79
+ className
80
+ )}
81
+ {...props}
82
+ />
83
+ )
84
+ }
85
+
86
+ function AvatarGroupCount({
87
+ className,
88
+ ...props
89
+ }: React.ComponentProps<"div">) {
90
+ return (
91
+ <div
92
+ data-slot="avatar-group-count"
93
+ className={cn(
94
+ "bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
95
+ className
96
+ )}
97
+ {...props}
98
+ />
99
+ )
100
+ }
101
+
102
+ export {
103
+ Avatar,
104
+ AvatarImage,
105
+ AvatarFallback,
106
+ AvatarBadge,
107
+ AvatarGroup,
108
+ AvatarGroupCount,
109
+ }