From d53833754f10b91c5df5d73854bef15ef2500709 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Sat, 25 Apr 2026 10:45:58 +0545 Subject: [PATCH 1/8] feat: dashboard UI --- .../src/controllers/analytics.controller.ts | 82 +++++++++++++ apps/api/src/routes/analytics.route.ts | 13 ++ apps/api/src/routes/index.ts | 2 + apps/web/app/(crm)/dashboard/page.tsx | 82 ++++++++++++- .../crm/dashboard/people-status-chart.tsx | 72 +++++++++++ .../crm/dashboard/pipeline-chart.tsx | 74 +++++++++++ .../components/crm/dashboard/stats-cards.tsx | 79 ++++++++++++ .../crm/dashboard/win-rate-card.tsx | 116 ++++++++++++++++++ apps/web/hooks/queries/use-analytics.ts | 38 ++++++ apps/web/hooks/queries/use-deals.ts | 3 + apps/web/hooks/queries/use-org.ts | 4 + apps/web/hooks/queries/use-people.ts | 4 + apps/web/lib/query-keys.ts | 5 + apps/web/services/crm/analytics.service.ts | 38 ++++++ 14 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/controllers/analytics.controller.ts create mode 100644 apps/api/src/routes/analytics.route.ts create mode 100644 apps/web/components/crm/dashboard/people-status-chart.tsx create mode 100644 apps/web/components/crm/dashboard/pipeline-chart.tsx create mode 100644 apps/web/components/crm/dashboard/stats-cards.tsx create mode 100644 apps/web/components/crm/dashboard/win-rate-card.tsx create mode 100644 apps/web/hooks/queries/use-analytics.ts create mode 100644 apps/web/services/crm/analytics.service.ts diff --git a/apps/api/src/controllers/analytics.controller.ts b/apps/api/src/controllers/analytics.controller.ts new file mode 100644 index 0000000..80fcdd1 --- /dev/null +++ b/apps/api/src/controllers/analytics.controller.ts @@ -0,0 +1,82 @@ +import type { Context } from "hono"; +import { and, count, eq, sql } from "drizzle-orm"; +import { db } from "@/db/client.js"; +import { deals, people } from "@/db/schema/index.js"; +import { STATUS_CODES } from "@/constants/status-codes.js"; +import { sendSuccess } from "@/lib/api-response.js"; +import { getSessionWorkspaceId } from "@/lib/workspace.js"; + +const DEAL_STAGES = ["new", "contacted", "demo", "proposal", "won", "lost"] as const; +const PEOPLE_STATUSES = ["lead", "prospect", "qualified", "customer", "churned"] as const; + +type StageCount = { stage: (typeof DEAL_STAGES)[number]; count: number }; +type StatusCount = { status: (typeof PEOPLE_STATUSES)[number]; count: number }; + +export async function pipelineByStage(c: Context) { + const workspaceId = getSessionWorkspaceId(c); + + const rows = await db + .select({ + stage: deals.stage, + count: count(), + }) + .from(deals) + .where(eq(deals.workspaceId, workspaceId)) + .groupBy(deals.stage); + + const countMap = new Map(rows.map((r) => [r.stage, Number(r.count)])); + + const result: StageCount[] = DEAL_STAGES.map((stage) => ({ + stage, + count: countMap.get(stage) ?? 0, + })); + + return sendSuccess(c, { pipeline: result }, STATUS_CODES.OK); +} + +export async function peopleByStatus(c: Context) { + const workspaceId = getSessionWorkspaceId(c); + + const rows = await db + .select({ + status: people.status, + count: count(), + }) + .from(people) + .where(eq(people.workspaceId, workspaceId)) + .groupBy(people.status); + + const countMap = new Map(rows.map((r) => [r.status, Number(r.count)])); + + const result: StatusCount[] = PEOPLE_STATUSES.map((status) => ({ + status, + count: countMap.get(status) ?? 0, + })); + + return sendSuccess(c, { peopleStatus: result }, STATUS_CODES.OK); +} + +export async function winRate(c: Context) { + const workspaceId = getSessionWorkspaceId(c); + + const rows = await db + .select({ + stage: deals.stage, + count: count(), + }) + .from(deals) + .where(and(eq(deals.workspaceId, workspaceId), sql`${deals.stage} IN ('won', 'lost')`)) + .groupBy(deals.stage); + + const countMap = new Map(rows.map((r) => [r.stage, Number(r.count)])); + const won = countMap.get("won") ?? 0; + const lost = countMap.get("lost") ?? 0; + const total = won + lost; + const rate = total === 0 ? 0 : Math.round((won / total) * 100); + + return sendSuccess( + c, + { won, lost, total, rate }, + STATUS_CODES.OK, + ); +} diff --git a/apps/api/src/routes/analytics.route.ts b/apps/api/src/routes/analytics.route.ts new file mode 100644 index 0000000..14482b1 --- /dev/null +++ b/apps/api/src/routes/analytics.route.ts @@ -0,0 +1,13 @@ +import { Hono } from "hono"; +import { authMiddleware } from "@/middlewares/auth-middleware.js"; +import { + pipelineByStage, + peopleByStatus, + winRate, +} from "@/controllers/analytics.controller.js"; + +export const analyticsRoutes = new Hono() + .use("*", authMiddleware) + .get("/pipeline", (c) => pipelineByStage(c)) + .get("/people-status", (c) => peopleByStatus(c)) + .get("/win-rate", (c) => winRate(c)); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index afd3f5d..3caa010 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,4 +1,5 @@ import type { Hono } from "hono"; +import { analyticsRoutes } from "./analytics.route.js"; import { authRoutes } from "./auth.route.js"; import { dealRoutes } from "./deals.route.js"; import { healthRoutes } from "./health.route.js"; @@ -11,4 +12,5 @@ export function registerRoutes(app: Hono) { app.route("/org", orgRoutes); app.route("/people", peopleRoutes); app.route("/deals", dealRoutes); + app.route("/analytics", analyticsRoutes); } diff --git a/apps/web/app/(crm)/dashboard/page.tsx b/apps/web/app/(crm)/dashboard/page.tsx index d1f1029..2a9cb47 100644 --- a/apps/web/app/(crm)/dashboard/page.tsx +++ b/apps/web/app/(crm)/dashboard/page.tsx @@ -1,3 +1,83 @@ +"use client"; + +import { PageHeader } from "@/components/layout/page-header"; +import { StatsCards } from "@/components/crm/dashboard/stats-cards"; +import { PipelineChart } from "@/components/crm/dashboard/pipeline-chart"; +import { PeopleStatusChart } from "@/components/crm/dashboard/people-status-chart"; +import { WinRateCard } from "@/components/crm/dashboard/win-rate-card"; +import { usePipelineByStage, usePeopleByStatus, useWinRate } from "@/hooks/queries/use-analytics"; +import { useOrganizations } from "@/hooks/queries/use-org"; +import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/ui/card"; +import { Skeleton } from "@workspace/ui/components/ui/skeleton"; + export default function Dashboard() { - return
Dashboard
; + const { data: pipelineData, isLoading: pipelineLoading } = usePipelineByStage(); + const { data: peopleStatusData, isLoading: peopleLoading } = usePeopleByStatus(); + const { data: winRateData, isLoading: winRateLoading } = useWinRate(); + const { data: orgsData, isLoading: orgsLoading } = useOrganizations({ pageSize: 1 }); + + const totalDeals = pipelineData?.reduce((sum, d) => sum + d.count, 0) ?? 0; + const totalPeople = peopleStatusData?.reduce((sum, p) => sum + p.count, 0) ?? 0; + const totalOrgs = orgsData?.meta.totalCount ?? 0; + const isLoading = pipelineLoading || peopleLoading || winRateLoading || orgsLoading; + + return ( +
+ + + + +
+ + + Pipeline by Stage +

+ Deal distribution across your sales pipeline +

+
+ + {pipelineLoading ? ( + + ) : ( + + )} + +
+ +
+ + + + + People by Status +

+ Contact distribution by lifecycle stage +

+
+ + {peopleLoading ? ( + + ) : ( + + )} + +
+
+
+
+ ); } diff --git a/apps/web/components/crm/dashboard/people-status-chart.tsx b/apps/web/components/crm/dashboard/people-status-chart.tsx new file mode 100644 index 0000000..527db3a --- /dev/null +++ b/apps/web/components/crm/dashboard/people-status-chart.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@workspace/ui/components/ui/chart"; + +interface PeopleStatusChartProps { + data: { status: string; count: number }[]; +} + +const STATUS_COLORS: Record = { + lead: "#64748b", + prospect: "#3b82f6", + qualified: "#10b981", + customer: "#8b5cf6", + churned: "#f43f5e", +}; + +const STATUS_LABELS: Record = { + lead: "Lead", + prospect: "Prospect", + qualified: "Qualified", + customer: "Customer", + churned: "Churned", +}; + +const chartConfig = { + count: { + label: "People", + color: "hsl(var(--chart-2))", + }, +}; + +export function PeopleStatusChart({ data }: PeopleStatusChartProps) { + const displayData = data.map((d) => ({ + ...d, + statusLabel: STATUS_LABELS[d.status] ?? d.status, + })); + + return ( + + + + + + } + /> + + {displayData.map((entry) => ( + + ))} + + + + ); +} diff --git a/apps/web/components/crm/dashboard/pipeline-chart.tsx b/apps/web/components/crm/dashboard/pipeline-chart.tsx new file mode 100644 index 0000000..ddd43557 --- /dev/null +++ b/apps/web/components/crm/dashboard/pipeline-chart.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@workspace/ui/components/ui/chart"; + +interface PipelineChartProps { + data: { stage: string; count: number }[]; +} + +const STAGE_COLORS: Record = { + new: "#64748b", + contacted: "#3b82f6", + demo: "#8b5cf6", + proposal: "#f59e0b", + won: "#10b981", + lost: "#f43f5e", +}; + +const STAGE_LABELS: Record = { + new: "New", + contacted: "Contacted", + demo: "Demo", + proposal: "Proposal", + won: "Won", + lost: "Lost", +}; + +const chartConfig = { + count: { + label: "Deals", + color: "hsl(var(--chart-1))", + }, +}; + +export function PipelineChart({ data }: PipelineChartProps) { + const displayData = data.map((d) => ({ + ...d, + stageLabel: STAGE_LABELS[d.stage] ?? d.stage, + })); + + return ( + + + + + + } + /> + + {displayData.map((entry) => ( + + ))} + + + + ); +} diff --git a/apps/web/components/crm/dashboard/stats-cards.tsx b/apps/web/components/crm/dashboard/stats-cards.tsx new file mode 100644 index 0000000..54efe99 --- /dev/null +++ b/apps/web/components/crm/dashboard/stats-cards.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Card, CardContent } from "@workspace/ui/components/ui/card"; +import { Skeleton } from "@workspace/ui/components/ui/skeleton"; +import { Users, Briefcase, Building2 } from "lucide-react"; +import { cn } from "@workspace/ui/lib/utils"; + +interface StatsCardsProps { + totalDeals?: number; + totalPeople?: number; + totalOrgs?: number; + isLoading: boolean; +} + +function StatCard({ + label, + value, + icon: Icon, + iconBg, + isLoading, +}: { + label: string; + value: number; + icon: React.ElementType; + iconBg: string; + isLoading: boolean; +}) { + return ( + + +
+
+ +
+
+

{label}

+ {isLoading ? ( + + ) : ( +

+ {value.toLocaleString()} +

+ )} +
+
+
+
+ ); +} + +export function StatsCards({ totalDeals, totalPeople, totalOrgs, isLoading }: StatsCardsProps) { + return ( +
+ + + +
+ ); +} diff --git a/apps/web/components/crm/dashboard/win-rate-card.tsx b/apps/web/components/crm/dashboard/win-rate-card.tsx new file mode 100644 index 0000000..e20890e --- /dev/null +++ b/apps/web/components/crm/dashboard/win-rate-card.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/ui/card"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { cn } from "@workspace/ui/lib/utils"; +import { TrendingUp, TrendingDown, Minus } from "lucide-react"; + +interface WinRateCardProps { + won: number; + lost: number; + total: number; + rate: number; +} + +function CircularProgress({ + value, + size = 96, + strokeWidth = 8, +}: { + value: number; + size?: number; + strokeWidth?: number; +}) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (value / 100) * circumference; + + let strokeColor = "#3b82f6"; + if (value >= 60) strokeColor = "#10b981"; + else if (value >= 40) strokeColor = "#f59e0b"; + else if (value > 0) strokeColor = "#f43f5e"; + + return ( +
+ + + + + {value}% +
+ ); +} + +export function WinRateCard({ won, lost, total, rate }: WinRateCardProps) { + const TrendIcon = rate > 50 ? TrendingUp : rate < 50 ? TrendingDown : Minus; + const trendColor = + rate > 50 + ? "text-emerald-600 bg-emerald-50 dark:bg-emerald-950/30" + : rate < 50 + ? "text-rose-600 bg-rose-50 dark:bg-rose-950/30" + : "text-amber-600 bg-amber-50 dark:bg-amber-950/30"; + + return ( + + +
+ Win Rate + + + {rate > 50 ? "Above avg" : rate < 50 ? "Below avg" : "Even"} + +
+
+ +
+ +
+ +
+
+ Won + + {won} + +
+
+ Lost + + {lost} + +
+
+ Total + + {total} + +
+
+
+
+ ); +} diff --git a/apps/web/hooks/queries/use-analytics.ts b/apps/web/hooks/queries/use-analytics.ts new file mode 100644 index 0000000..ecfcc28 --- /dev/null +++ b/apps/web/hooks/queries/use-analytics.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/lib/query-keys"; +import { useAuthSession } from "@/hooks/queries/use-auth"; +import { + getPipelineByStage, + getPeopleByStatus, + getWinRate, +} from "@/services/crm/analytics.service"; + +export function usePipelineByStage() { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.ANALYTICS, QUERY_KEYS.ANALYTICS_PIPELINE], + queryFn: getPipelineByStage, + enabled: !!session?.user, + }); +} + +export function usePeopleByStatus() { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.ANALYTICS, QUERY_KEYS.ANALYTICS_PEOPLE_STATUS], + queryFn: getPeopleByStatus, + enabled: !!session?.user, + }); +} + +export function useWinRate() { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.ANALYTICS, QUERY_KEYS.ANALYTICS_WIN_RATE], + queryFn: getWinRate, + enabled: !!session?.user, + }); +} diff --git a/apps/web/hooks/queries/use-deals.ts b/apps/web/hooks/queries/use-deals.ts index 3f343b5..5e007d6 100644 --- a/apps/web/hooks/queries/use-deals.ts +++ b/apps/web/hooks/queries/use-deals.ts @@ -40,6 +40,7 @@ export function useCreateDeal() { mutationFn: (input: CreateDeal) => createDeal(input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.DEALS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Deal created", { description: "The deal has been added successfully.", }); @@ -58,6 +59,7 @@ export function useUpdateDeal() { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.DEALS, QUERY_KEYS.DEALS_DETAIL, variables.dealId], }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Deal updated", { description: "The deal has been updated successfully.", }); @@ -72,6 +74,7 @@ export function useDeleteDeal() { mutationFn: (dealId: string) => deleteDeal(dealId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.DEALS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Deal deleted", { description: "The deal has been removed successfully.", }); diff --git a/apps/web/hooks/queries/use-org.ts b/apps/web/hooks/queries/use-org.ts index 5d1087d..1d550da 100644 --- a/apps/web/hooks/queries/use-org.ts +++ b/apps/web/hooks/queries/use-org.ts @@ -42,6 +42,7 @@ export function useCreateOrg() { mutationFn: (input: CreateOrganizationInput) => createOrganization(input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Organization created", { description: "The organization has been created successfully.", }); @@ -55,6 +56,7 @@ export function useUpdateOrg(orgId: string) { mutationFn: (input: UpdateOrganizationInput) => updateOrganization(orgId, input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Organization updated", { description: "The organization has been updated successfully.", }); @@ -68,6 +70,7 @@ export function useDeleteOrg() { mutationFn: (orgId: string) => deleteOrganization(orgId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Organization deleted", { description: "The organization has been deleted successfully.", }); @@ -81,6 +84,7 @@ export function useBulkDeleteOrgs() { mutationFn: (input: BulkDeleteInput) => bulkDeleteOrganizations(input), onSuccess: (deletedCount) => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Organizations deleted", { description: deletedCount === 1 diff --git a/apps/web/hooks/queries/use-people.ts b/apps/web/hooks/queries/use-people.ts index 0353d11..28a9853 100644 --- a/apps/web/hooks/queries/use-people.ts +++ b/apps/web/hooks/queries/use-people.ts @@ -46,6 +46,7 @@ export function useCreatePerson() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Person created", { description: "The person has been added to your CRM.", }); @@ -61,6 +62,7 @@ export function useUpdatePerson(personId: string) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Person updated", { description: "The person has been updated.", }); @@ -76,6 +78,7 @@ export function useDeletePerson() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("Person deleted", { description: "The person has been removed from your CRM.", }); @@ -91,6 +94,7 @@ export function useBulkDeletePeople() { onSuccess: (deletedCount) => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ANALYTICS] }); toast.success("People deleted", { description: deletedCount === 1 diff --git a/apps/web/lib/query-keys.ts b/apps/web/lib/query-keys.ts index 37de129..482a9af 100644 --- a/apps/web/lib/query-keys.ts +++ b/apps/web/lib/query-keys.ts @@ -19,4 +19,9 @@ export const QUERY_KEYS = { DEALS: "deals", DEALS_LIST: "deals-list", DEALS_DETAIL: "deals-detail", + + ANALYTICS: "analytics", + ANALYTICS_PIPELINE: "analytics-pipeline", + ANALYTICS_PEOPLE_STATUS: "analytics-people-status", + ANALYTICS_WIN_RATE: "analytics-win-rate", } as const; diff --git a/apps/web/services/crm/analytics.service.ts b/apps/web/services/crm/analytics.service.ts new file mode 100644 index 0000000..6f8c93f --- /dev/null +++ b/apps/web/services/crm/analytics.service.ts @@ -0,0 +1,38 @@ +import { apiClient } from "@/lib/axios-client"; +import type { ApiSuccessResponse } from "@workspace/validators/types/auth"; + +export interface PipelineStage { + stage: string; + count: number; +} + +export interface PeopleStatus { + status: string; + count: number; +} + +export interface WinRate { + won: number; + lost: number; + total: number; + rate: number; +} + +type PipelineResponse = ApiSuccessResponse<{ pipeline: PipelineStage[] }>; +type PeopleStatusResponse = ApiSuccessResponse<{ peopleStatus: PeopleStatus[] }>; +type WinRateResponse = ApiSuccessResponse; + +export async function getPipelineByStage() { + const response = await apiClient.get("/analytics/pipeline"); + return response.data.data.pipeline; +} + +export async function getPeopleByStatus() { + const response = await apiClient.get("/analytics/people-status"); + return response.data.data.peopleStatus; +} + +export async function getWinRate() { + const response = await apiClient.get("/analytics/win-rate"); + return response.data.data; +} From 25c0ff2232c6571884a992ca02113e734845d784 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Sat, 25 Apr 2026 12:18:14 +0545 Subject: [PATCH 2/8] feat: db seed --- apps/api/package.json | 1 + apps/api/src/db/client.ts | 4 +- apps/api/src/db/seed.ts | 510 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + turbo.json | 3 + 5 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/db/seed.ts diff --git a/apps/api/package.json b/apps/api/package.json index 984294b..d8d3fa4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "db:generate": "pnpm build && drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "pnpm build && drizzle-kit push", + "db:seed": "bun run src/db/seed.ts", "db:studio": "pnpm build && drizzle-kit studio", "db:reset": "pnpm build && drizzle-kit push --force" }, diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts index b04eaa5..041b912 100644 --- a/apps/api/src/db/client.ts +++ b/apps/api/src/db/client.ts @@ -3,6 +3,6 @@ import postgres from "postgres"; import { env } from "@/config/env.config.js"; import * as schema from "./schema/index.js"; -const client = postgres(env.DATABASE_URL); +export const dbClient = postgres(env.DATABASE_URL); -export const db = drizzle(client, { schema, casing: "snake_case" }); +export const db = drizzle(dbClient, { schema, casing: "snake_case" }); diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts new file mode 100644 index 0000000..fce83ed --- /dev/null +++ b/apps/api/src/db/seed.ts @@ -0,0 +1,510 @@ +import { config } from "dotenv"; +import { randomUUID } from "node:crypto"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const currentDirectory = dirname(fileURLToPath(import.meta.url)); + +config({ path: resolve(currentDirectory, "../../.env"), quiet: true }); +config({ path: resolve(currentDirectory, "../../../../.env"), quiet: true }); + +const { and, eq } = await import("drizzle-orm"); +const { hashPassword } = await import("better-auth/crypto"); +const { db, dbClient } = await import("./client.js"); +const { + account, + crmCustomFieldDefinitions, + deals, + org, + people, + user, + workspaceMembers, + workspaces, +} = await import("./schema/index.js"); + +const DEMO_EMAIL = "demo@gmail.com"; +const DEMO_PASSWORD = "12345678"; + +const workspaceSeeds = [ + { + name: "Stallion Demo Sales", + slug: "stallion-demo-sales", + market: "Outbound sales", + orgCount: 28, + peopleCount: 90, + dealCount: 36, + offset: 0, + }, + { + name: "Stallion Demo Success", + slug: "stallion-demo-success", + market: "Customer expansion", + orgCount: 17, + peopleCount: 45, + dealCount: 20, + offset: 11, + }, +] as const; + +const industries = [ + "SaaS", + "Fintech", + "Healthcare", + "E-commerce", + "Education", + "Logistics", + "Manufacturing", + "Real Estate", +] as const; + +const companyAdjectives = [ + "Apex", + "Northstar", + "Summit", + "Brightline", + "Nimbus", + "Vertex", + "Copper", + "Atlas", + "Signal", + "Keystone", + "Harbor", + "Prairie", +] as const; + +const companyNouns = [ + "Analytics", + "Cloud", + "Systems", + "Commerce", + "Health", + "Labs", + "Networks", + "Works", + "Logistics", + "Learning", + "Capital", + "Robotics", +] as const; + +const locations = [ + "New York, NY", + "Austin, TX", + "San Francisco, CA", + "Chicago, IL", + "Denver, CO", + "Seattle, WA", + "Boston, MA", + "Atlanta, GA", +] as const; + +const sizes = ["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"] as const; + +const firstNames = [ + "Maya", + "Ethan", + "Ava", + "Noah", + "Sophia", + "Liam", + "Isabella", + "Lucas", + "Olivia", + "Mason", + "Amelia", + "Logan", + "Harper", + "Elijah", + "Evelyn", + "James", + "Charlotte", + "Benjamin", + "Mia", + "Henry", + "Aria", + "Jack", + "Layla", + "Owen", +] as const; + +const lastNames = [ + "Patel", + "Chen", + "Johnson", + "Garcia", + "Williams", + "Nguyen", + "Brown", + "Davis", + "Miller", + "Wilson", + "Moore", + "Taylor", + "Anderson", + "Thomas", + "Jackson", + "White", + "Harris", + "Martin", + "Thompson", + "Robinson", + "Lewis", + "Walker", + "Young", + "King", +] as const; + +const jobTitles = [ + "VP of Sales", + "Head of Revenue", + "Sales Operations Manager", + "Founder", + "Chief Operating Officer", + "Product Lead", + "Finance Director", + "Customer Success Director", + "Marketing Manager", + "Procurement Lead", +] as const; + +const peopleStatuses = ["lead", "prospect", "qualified", "customer", "churned"] as const; +const peopleSources = ["manual", "csv", "api"] as const; +const dealStages = ["new", "contacted", "demo", "proposal", "won", "lost"] as const; +const dealThemes = [ + "Pilot rollout", + "Team expansion", + "Annual subscription", + "Workflow automation", + "Enterprise upgrade", + "Renewal package", +] as const; + +type WorkspaceSeed = (typeof workspaceSeeds)[number]; +type SelectOption = { id: string; label: string }; +type CustomField = { + id: string; + label: string; + options: SelectOption[]; +}; + +function requireValue(value: T | undefined | null, label: string): T { + if (value === undefined || value === null) { + throw new Error(`Expected ${label} to exist while seeding demo data.`); + } + + return value; +} + +function makeOptions(labels: readonly string[]): SelectOption[] { + return labels.map((label) => ({ id: randomUUID(), label })); +} + +function optionId(options: SelectOption[], index: number) { + return requireValue(options[index % options.length], "custom field option").id; +} + +function slugify(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +function daysFromNow(days: number) { + const date = new Date(); + date.setDate(date.getDate() + days); + return date; +} + +function dateTimeString(days: number) { + return daysFromNow(days).toISOString().slice(0, 16); +} + +function closeDateForStage(stage: (typeof dealStages)[number], index: number) { + if (stage === "won" || stage === "lost") { + return daysFromNow(-10 - index); + } + + return daysFromNow(14 + index * 3); +} + +async function upsertDemoUser() { + const password = await hashPassword(DEMO_PASSWORD); + const now = new Date(); + const [demoUser] = await db + .insert(user) + .values({ + name: "Demo User", + email: DEMO_EMAIL, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: user.email, + set: { + name: "Demo User", + emailVerified: true, + updatedAt: now, + }, + }) + .returning(); + + const result = requireValue(demoUser, "demo user"); + + await db + .delete(account) + .where(and(eq(account.userId, result.id), eq(account.providerId, "credential"))); + + await db.insert(account).values({ + userId: result.id, + accountId: result.id, + providerId: "credential", + password, + createdAt: now, + updatedAt: now, + }); + + return result; +} + +async function upsertWorkspace(seed: WorkspaceSeed, ownerId: string) { + const now = new Date(); + const [workspace] = await db + .insert(workspaces) + .values({ + name: seed.name, + slug: seed.slug, + ownerId, + metadata: { + seeded: true, + market: seed.market, + }, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: workspaces.slug, + set: { + name: seed.name, + ownerId, + metadata: { + seeded: true, + market: seed.market, + }, + updatedAt: now, + }, + }) + .returning(); + + const result = requireValue(workspace, `workspace ${seed.name}`); + + await db + .insert(workspaceMembers) + .values({ + workspaceId: result.id, + userId: ownerId, + role: "owner", + joinedAt: now, + }) + .onConflictDoUpdate({ + target: [workspaceMembers.workspaceId, workspaceMembers.userId], + set: { + role: "owner", + joinedAt: now, + }, + }); + + return result; +} + +function fieldByLabel(fields: CustomField[], label: string) { + return requireValue( + fields.find((field) => field.label === label), + `custom field ${label}`, + ); +} + +async function reseedWorkspaceCrm(workspaceId: string, ownerId: string, seed: WorkspaceSeed) { + return db.transaction(async (tx) => { + await tx.delete(deals).where(eq(deals.workspaceId, workspaceId)); + await tx.delete(people).where(eq(people.workspaceId, workspaceId)); + await tx.delete(org).where(eq(org.workspaceId, workspaceId)); + await tx + .delete(crmCustomFieldDefinitions) + .where(eq(crmCustomFieldDefinitions.workspaceId, workspaceId)); + + const buyingRoleOptions = makeOptions(["Economic buyer", "Champion", "Evaluator", "End user"]); + const channelOptions = makeOptions(["Email", "Phone", "LinkedIn", "Referral"]); + const tierOptions = makeOptions(["Strategic", "Growth", "Startup"]); + + const fields = (await tx + .insert(crmCustomFieldDefinitions) + .values([ + { + workspaceId, + entityType: "people", + fieldType: "select", + label: "Buying Role", + options: buyingRoleOptions, + }, + { + workspaceId, + entityType: "people", + fieldType: "number", + label: "Lead Score", + options: [], + }, + { + workspaceId, + entityType: "people", + fieldType: "select", + label: "Preferred Channel", + options: channelOptions, + }, + { + workspaceId, + entityType: "org", + fieldType: "select", + label: "Account Tier", + options: tierOptions, + }, + { + workspaceId, + entityType: "org", + fieldType: "number", + label: "Estimated ARR", + options: [], + }, + { + workspaceId, + entityType: "org", + fieldType: "dateTime", + label: "Renewal Date", + options: [], + }, + ]) + .returning()) as CustomField[]; + + const buyingRoleField = fieldByLabel(fields, "Buying Role"); + const leadScoreField = fieldByLabel(fields, "Lead Score"); + const channelField = fieldByLabel(fields, "Preferred Channel"); + const tierField = fieldByLabel(fields, "Account Tier"); + const arrField = fieldByLabel(fields, "Estimated ARR"); + const renewalField = fieldByLabel(fields, "Renewal Date"); + + const orgRows = Array.from({ length: seed.orgCount }, (_, index) => { + const baseName = `${companyAdjectives[(index + seed.offset) % companyAdjectives.length]} ${companyNouns[(index * 3 + seed.offset) % companyNouns.length]}`; + const name = `${baseName} ${index + 1}`; + const domain = `${slugify(name)}.example.com`; + + return { + workspaceId, + ownerId, + name, + domain, + industry: industries[(index + seed.offset) % industries.length], + size: sizes[(index + seed.offset) % sizes.length], + location: locations[(index * 2 + seed.offset) % locations.length], + customFields: { + [tierField.id]: optionId(tierOptions, index + seed.offset), + [arrField.id]: 25000 + index * 8500 + seed.offset * 1000, + [renewalField.id]: dateTimeString(30 + index * 9), + }, + }; + }); + + const insertedOrgs = await tx.insert(org).values(orgRows).returning(); + + const peopleRows = Array.from({ length: seed.peopleCount }, (_, index) => { + const company = requireValue(insertedOrgs[index % insertedOrgs.length], "seeded org"); + const firstName = firstNames[(index + seed.offset) % firstNames.length]; + const lastName = lastNames[(index * 2 + seed.offset) % lastNames.length]; + const emailLocalPart = `${firstName}.${lastName}.${index + 1}`.toLowerCase(); + const domain = company.domain ?? `${seed.slug}.example.com`; + const contactedRecently = index % 5 !== 0; + + return { + workspaceId, + orgId: company.id, + ownerId, + name: `${firstName} ${lastName}`, + email: `${emailLocalPart}@${domain}`, + phone: `+1-555-${String(1000 + index + seed.offset * 10).slice(0, 4)}`, + jobTitle: jobTitles[(index + seed.offset) % jobTitles.length], + linkedinUrl: `https://www.linkedin.com/in/${emailLocalPart}`, + status: peopleStatuses[(index + seed.offset) % peopleStatuses.length], + source: peopleSources[(index + seed.offset) % peopleSources.length], + lastContactedAt: contactedRecently ? daysFromNow(-(index % 21) - 1) : null, + customFields: { + [buyingRoleField.id]: optionId(buyingRoleOptions, index), + [leadScoreField.id]: 35 + ((index * 7 + seed.offset) % 66), + [channelField.id]: optionId(channelOptions, index + seed.offset), + }, + }; + }); + + const insertedPeople = await tx.insert(people).values(peopleRows).returning(); + + const dealRows = Array.from({ length: seed.dealCount }, (_, index) => { + const person = requireValue( + insertedPeople[(index * 2) % insertedPeople.length], + "seeded person", + ); + const company = + insertedOrgs.find((item) => item.id === person.orgId) ?? + requireValue(insertedOrgs[index % insertedOrgs.length], "seeded deal org"); + const stage = requireValue( + dealStages[(index + seed.offset) % dealStages.length], + "deal stage", + ); + + return { + workspaceId, + personId: person.id, + orgId: company.id, + ownerId, + title: `${company.name} - ${dealThemes[(index + seed.offset) % dealThemes.length]}`, + value: String(7500 + index * 2750 + seed.offset * 1250), + currency: "USD", + stage, + closeDate: closeDateForStage(stage, index), + }; + }); + + const insertedDeals = await tx.insert(deals).values(dealRows).returning(); + + return { + orgs: insertedOrgs.length, + people: insertedPeople.length, + deals: insertedDeals.length, + customFields: fields.length, + }; + }); +} + +async function main() { + console.log(`Seeding demo account ${DEMO_EMAIL}...`); + + const demoUser = await upsertDemoUser(); + const summaries = []; + + for (const seed of workspaceSeeds) { + const workspace = await upsertWorkspace(seed, demoUser.id); + const summary = await reseedWorkspaceCrm(workspace.id, demoUser.id, seed); + summaries.push({ workspace: workspace.name, ...summary }); + } + + console.log("Demo seed complete."); + console.log(`Login: ${DEMO_EMAIL} / ${DEMO_PASSWORD}`); + console.table(summaries); +} + +try { + await main(); +} catch (error) { + console.error("Demo seed failed:", error); + process.exitCode = 1; +} finally { + await dbClient.end(); +} diff --git a/package.json b/package.json index 59a61cd..feae9ff 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "db:push": "turbo run db:push --filter=api", "db:generate": "turbo run db:generate --filter=api", "db:migrate": "turbo run db:migrate --filter=api", + "db:seed": "turbo run db:seed --filter=api", "db:studio": "turbo run db:studio --filter=api", "db:reset:schema": "turbo run db:reset --filter=api", "db:reset": "pnpm docker:clean && pnpm docker:up && pnpm db:migrate" diff --git a/turbo.json b/turbo.json index 4cd95be..9dad802 100644 --- a/turbo.json +++ b/turbo.json @@ -25,6 +25,9 @@ "db:migrate": { "cache": false }, + "db:seed": { + "cache": false + }, "db:reset": { "cache": false }, From 10525898e1787862d710d4f99a5e06c3b770b92b Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Sat, 25 Apr 2026 12:20:03 +0545 Subject: [PATCH 3/8] fix: minor ui fixes --- apps/web/app/(crm)/layout.tsx | 4 ++-- apps/web/components/crm/deals/deals-drawer.tsx | 6 +++--- apps/web/components/layout/app-sidebar.tsx | 8 ++++---- apps/web/components/shared/data-table.tsx | 14 +++++++------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/web/app/(crm)/layout.tsx b/apps/web/app/(crm)/layout.tsx index 5f4ba5e..cddef2f 100644 --- a/apps/web/app/(crm)/layout.tsx +++ b/apps/web/app/(crm)/layout.tsx @@ -8,12 +8,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod - +
-
{children}
+
{children}
diff --git a/apps/web/components/crm/deals/deals-drawer.tsx b/apps/web/components/crm/deals/deals-drawer.tsx index c1123e2..cdc6c72 100644 --- a/apps/web/components/crm/deals/deals-drawer.tsx +++ b/apps/web/components/crm/deals/deals-drawer.tsx @@ -38,9 +38,9 @@ import { DEAL_STAGE_OPTIONS, DEAL_STAGE_MAP } from "@/components/crm/deals/deals function DealViewContent({ deal }: { deal: Deal }) { const stageConfig = DEAL_STAGE_MAP[deal.stage]; - const personName = deal.person?.name; - const orgName = deal.org?.name; - const ownerName = deal.owner?.name; + const personName = deal.personName ?? deal.person?.name; + const orgName = deal.orgName ?? deal.org?.name; + const ownerName = deal.ownerName ?? deal.owner?.name; return (
diff --git a/apps/web/components/layout/app-sidebar.tsx b/apps/web/components/layout/app-sidebar.tsx index 9dd2fac..b0e16b9 100644 --- a/apps/web/components/layout/app-sidebar.tsx +++ b/apps/web/components/layout/app-sidebar.tsx @@ -108,11 +108,11 @@ export function AppSidebar() { {/* Footer */} - + {sessionPending ? ( -
+
-
+
@@ -120,7 +120,7 @@ export function AppSidebar() { ) : ( -