Spaces:
Sleeping
Sleeping
Commit ·
ea8dde3
1
Parent(s): bc07eff
Add full CRM application with HF Spaces Docker deployment
Browse filesIncludes 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
- .dockerignore +15 -0
- .gitignore +5 -0
- Dockerfile +69 -0
- README.md +1 -0
- components.json +23 -0
- drizzle.config.ts +10 -0
- next.config.ts +2 -1
- package-lock.json +0 -0
- package.json +31 -2
- src/app/(app)/activities/page.tsx +147 -0
- src/app/(app)/companies/[id]/page.tsx +128 -0
- src/app/(app)/companies/page.tsx +192 -0
- src/app/(app)/contacts/[id]/page.tsx +153 -0
- src/app/(app)/contacts/page.tsx +229 -0
- src/app/(app)/dashboard/page.tsx +209 -0
- src/app/(app)/deals/[id]/page.tsx +172 -0
- src/app/(app)/deals/page.tsx +75 -0
- src/app/(app)/layout.tsx +22 -0
- src/app/(app)/settings/page.tsx +159 -0
- src/app/(auth)/login/page.tsx +78 -0
- src/app/api/activities/[id]/route.ts +41 -0
- src/app/api/activities/route.ts +76 -0
- src/app/api/auth/[...nextauth]/route.ts +2 -0
- src/app/api/companies/[id]/route.ts +56 -0
- src/app/api/companies/route.ts +94 -0
- src/app/api/contacts/[id]/route.ts +68 -0
- src/app/api/contacts/route.ts +80 -0
- src/app/api/dashboard/route.ts +106 -0
- src/app/api/deals/[id]/route.ts +90 -0
- src/app/api/deals/reorder/route.ts +76 -0
- src/app/api/deals/route.ts +83 -0
- src/app/api/pipelines/route.ts +19 -0
- src/app/api/search/route.ts +68 -0
- src/app/api/stages/[id]/route.ts +55 -0
- src/app/api/stages/route.ts +42 -0
- src/app/globals.css +113 -13
- src/app/layout.tsx +4 -2
- src/app/page.tsx +2 -62
- src/components/activities/activity-form.tsx +101 -0
- src/components/activities/activity-item.tsx +88 -0
- src/components/deals/deal-form.tsx +162 -0
- src/components/deals/kanban-board.tsx +233 -0
- src/components/deals/kanban-card.tsx +106 -0
- src/components/deals/kanban-column.tsx +58 -0
- src/components/layout/sidebar.tsx +77 -0
- src/components/layout/topbar.tsx +50 -0
- src/components/shared/confirm-dialog.tsx +54 -0
- src/components/shared/data-table.tsx +132 -0
- src/components/shared/global-search.tsx +113 -0
- 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 |
-
|
|
|
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
:
|
| 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 |
-
|
| 16 |
-
:
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
background:
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: "
|
| 17 |
-
description: "
|
| 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
|
| 2 |
|
| 3 |
export default function Home() {
|
| 4 |
-
|
| 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">⌘</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 |
+
}
|