diff --git a/apps/api/src/controllers/deals.controller.ts b/apps/api/src/controllers/deals.controller.ts index c5746fc..c09d757 100644 --- a/apps/api/src/controllers/deals.controller.ts +++ b/apps/api/src/controllers/deals.controller.ts @@ -1,19 +1,99 @@ import type { Context } from "hono"; -import type { CreateDeal, UpdateDeal } from "@workspace/validators/schemas/crm"; -import { and, eq } from "drizzle-orm"; +import type { CreateDeal, ListDealsQuery, UpdateDeal } from "@workspace/validators/schemas/crm"; +import { and, asc, count, desc, eq, ilike, or } from "drizzle-orm"; import { db } from "@/db/client.js"; -import { deals } from "@/db/schema/index.js"; +import { deals, orgs, people, user } from "@/db/schema/index.js"; import { STATUS_CODES } from "@/constants/status-codes.js"; import { sendSuccess } from "@/lib/api-response.js"; import { AppError } from "@/lib/app-error.js"; import { toDate } from "@/lib/date.js"; import { getSessionWorkspaceId } from "@/lib/workspace.js"; -export async function listDeals(c: Context) { +export async function listDeals(c: Context, query: ListDealsQuery) { const workspaceId = getSessionWorkspaceId(c); - const results = await db.select().from(deals).where(eq(deals.workspaceId, workspaceId)); + const { page, pageSize, sortBy, sortOrder, stage, ownerId, search } = query; + const offset = (page - 1) * pageSize; + const conditions = [eq(deals.workspaceId, workspaceId)]; - return sendSuccess(c, { deals: results }, STATUS_CODES.OK); + if (stage) conditions.push(eq(deals.stage, stage)); + if (ownerId) conditions.push(eq(deals.ownerId, ownerId)); + if (search) { + conditions.push(or(ilike(deals.title, `%${search}%`))!); + } + + const whereClause = and(...conditions); + + const orderBy = (() => { + switch (sortBy) { + case "value": + return sortOrder === "desc" ? desc(deals.value) : asc(deals.value); + case "currency": + return sortOrder === "desc" ? desc(deals.currency) : asc(deals.currency); + case "stage": + return sortOrder === "desc" ? desc(deals.stage) : asc(deals.stage); + case "closeDate": + return sortOrder === "desc" ? desc(deals.closeDate) : asc(deals.closeDate); + case "createdAt": + return sortOrder === "desc" ? desc(deals.createdAt) : asc(deals.createdAt); + case "updatedAt": + return sortOrder === "desc" ? desc(deals.updatedAt) : asc(deals.updatedAt); + case "title": + default: + return sortOrder === "desc" ? desc(deals.title) : asc(deals.title); + } + })(); + + const [results, totalCountRows] = await Promise.all([ + db + .select({ + id: deals.id, + workspaceId: deals.workspaceId, + orgId: deals.orgId, + personId: deals.personId, + ownerId: deals.ownerId, + title: deals.title, + value: deals.value, + currency: deals.currency, + stage: deals.stage, + closeDate: deals.closeDate, + createdAt: deals.createdAt, + updatedAt: deals.updatedAt, + orgName: orgs.name, + personName: people.name, + ownerName: user.name, + }) + .from(deals) + .leftJoin(orgs, eq(deals.orgId, orgs.id)) + .leftJoin(people, eq(deals.personId, people.id)) + .leftJoin(user, eq(deals.ownerId, user.id)) + .where(whereClause) + .orderBy(orderBy) + .limit(pageSize) + .offset(offset), + db.select({ totalCount: count() }).from(deals).where(whereClause), + ]); + + const totalCount = Number(totalCountRows[0]?.totalCount ?? 0); + const totalPages = totalCount === 0 ? 0 : Math.ceil(totalCount / pageSize); + + return sendSuccess( + c, + { + deals: results.map((deal) => ({ + ...deal, + orgName: deal.orgName ?? null, + personName: deal.personName ?? null, + ownerName: deal.ownerName ?? null, + })), + meta: { + page, + pageSize, + totalCount, + totalPages, + }, + }, + STATUS_CODES.OK, + ); } export async function getDeal(c: Context, id: string) { @@ -33,8 +113,15 @@ export async function getDeal(c: Context, id: string) { name: true, }, }, + owner: { + columns: { + id: true, + name: true, + }, + }, }, }); + if (!deal) { throw new AppError("Deal not found", STATUS_CODES.NOT_FOUND); } @@ -81,8 +168,10 @@ export async function deleteDeal(c: Context, id: string) { .delete(deals) .where(and(eq(deals.id, id), eq(deals.workspaceId, workspaceId))) .returning(); + if (!deal) { throw new AppError("Deal not found", STATUS_CODES.NOT_FOUND); } + return sendSuccess(c, { deal }, STATUS_CODES.OK); } diff --git a/apps/api/src/controllers/orgs.controller.ts b/apps/api/src/controllers/orgs.controller.ts index 3d956f6..138fc08 100644 --- a/apps/api/src/controllers/orgs.controller.ts +++ b/apps/api/src/controllers/orgs.controller.ts @@ -1,18 +1,89 @@ import type { Context } from "hono"; -import type { CreateOrg, UpdateOrg } from "@workspace/validators/schemas/crm"; -import { and, eq } from "drizzle-orm"; +import type { + BulkDeleteInput, + CreateOrg, + ListOrgsQuery, + UpdateOrg, +} from "@workspace/validators/schemas/crm"; +import { and, asc, count, desc, eq, ilike, inArray, or } from "drizzle-orm"; import { db } from "@/db/client.js"; -import { orgs } from "@/db/schema/index.js"; +import { orgs, people } from "@/db/schema/index.js"; import { STATUS_CODES } from "@/constants/status-codes.js"; import { sendSuccess } from "@/lib/api-response.js"; import { AppError } from "@/lib/app-error.js"; import { getSessionWorkspaceId } from "@/lib/workspace.js"; -export async function listOrgs(c: Context) { +export async function listOrgs(c: Context, query: ListOrgsQuery) { const workspaceId = getSessionWorkspaceId(c); - const results = await db.select().from(orgs).where(eq(orgs.workspaceId, workspaceId)); + const { page, pageSize, sortOrder, sortBy, search, industry, size } = query; - return sendSuccess(c, { orgs: results }, STATUS_CODES.OK); + const conditions = [eq(orgs.workspaceId, workspaceId)]; + if (industry) conditions.push(eq(orgs.industry, industry)); + if (size) conditions.push(eq(orgs.size, size)); + if (search) { + const searchTerm = `%${search}%`; + conditions.push( + or( + ilike(orgs.name, searchTerm), + ilike(orgs.domain, searchTerm), + ilike(orgs.industry, searchTerm), + ilike(orgs.size, searchTerm), + ilike(orgs.location, searchTerm), + )!, + ); + } + + const whereClause = and(...conditions); + + const direction = sortOrder === "desc" ? desc : asc; + const orderBy = (() => { + switch (sortBy) { + case "domain": + return direction(orgs.domain); + case "industry": + return direction(orgs.industry); + case "size": + return direction(orgs.size); + case "location": + return direction(orgs.location); + case "createdAt": + return direction(orgs.createdAt); + case "updatedAt": + return direction(orgs.updatedAt); + case "name": + default: + return direction(orgs.name); + } + })(); + + const [rows, totalCountResult, peopleCounts] = await Promise.all([ + db + .select() + .from(orgs) + .where(whereClause) + .orderBy(orderBy) + .limit(pageSize) + .offset((page - 1) * pageSize), + db.select({ totalCount: count() }).from(orgs).where(whereClause), + db + .select({ orgId: people.orgId, count: count() }) + .from(people) + .where(eq(people.workspaceId, workspaceId)) + .groupBy(people.orgId), + ]); + + const totalCount = Number(totalCountResult[0]?.totalCount ?? 0); + const totalPages = totalCount === 0 ? 0 : Math.ceil(totalCount / pageSize); + const peopleCountMap = new Map(peopleCounts.map((r) => [r.orgId, r.count])); + + return sendSuccess( + c, + { + orgs: rows.map((org) => ({ ...org, peopleCount: peopleCountMap.get(org.id) ?? 0 })), + meta: { page, pageSize, totalCount, totalPages }, + }, + STATUS_CODES.OK, + ); } export async function getOrg(c: Context, id: string) { @@ -80,3 +151,13 @@ export async function deleteOrg(c: Context, id: string) { return sendSuccess(c, { org }, STATUS_CODES.OK); } + +export async function bulkDeleteOrgs(c: Context, payload: BulkDeleteInput) { + const workspaceId = getSessionWorkspaceId(c); + const deletedOrgs = await db + .delete(orgs) + .where(and(eq(orgs.workspaceId, workspaceId), inArray(orgs.id, payload.ids))) + .returning({ id: orgs.id }); + + return sendSuccess(c, { deleted: deletedOrgs.length }, STATUS_CODES.OK); +} diff --git a/apps/api/src/controllers/people.controller.ts b/apps/api/src/controllers/people.controller.ts index f422041..cd1842c 100644 --- a/apps/api/src/controllers/people.controller.ts +++ b/apps/api/src/controllers/people.controller.ts @@ -1,27 +1,122 @@ import type { Context } from "hono"; -import type { CreatePerson, UpdatePerson } from "@workspace/validators/schemas/crm"; -import { and, eq } from "drizzle-orm"; +import type { + BulkDeleteInput, + CreatePerson, + ListPeopleQuery, + UpdatePerson, +} from "@workspace/validators/schemas/crm"; +import { and, asc, count, desc, eq, ilike, or, inArray } from "drizzle-orm"; import { db } from "@/db/client.js"; -import { people } from "@/db/schema/index.js"; +import { orgs, people, user } from "@/db/schema/index.js"; import { STATUS_CODES } from "@/constants/status-codes.js"; import { sendSuccess } from "@/lib/api-response.js"; import { AppError } from "@/lib/app-error.js"; import { toDate } from "@/lib/date.js"; import { getSessionWorkspaceId } from "@/lib/workspace.js"; -export async function listPeople(c: Context) { +export async function listPeople(c: Context, query: ListPeopleQuery) { const workspaceId = getSessionWorkspaceId(c); - const results = await db.select().from(people).where(eq(people.workspaceId, workspaceId)); + const { page, pageSize, sortBy, sortOrder, status, source, ownerId, search } = query; + const offset = (page - 1) * pageSize; - return sendSuccess(c, { people: results }, STATUS_CODES.OK); -} + const conditions = [eq(people.workspaceId, workspaceId)]; + if (status) conditions.push(eq(people.status, status)); + if (source) conditions.push(eq(people.source, source)); + if (ownerId) conditions.push(eq(people.ownerId, ownerId)); + if (search) { + conditions.push( + or( + ilike(people.name, `%${search}%`), + ilike(people.email, `%${search}%`), + ilike(people.phone, `%${search}%`), + ilike(people.jobTitle, `%${search}%`), + )!, + ); + } + + const whereClause = and(...conditions); + + const sortColumnMap = { + name: people.name, + email: people.email, + phone: people.phone, + jobTitle: people.jobTitle, + status: people.status, + source: people.source, + lastContactedAt: people.lastContactedAt, + createdAt: people.createdAt, + updatedAt: people.updatedAt, + } as const; + + const orderColumn = sortColumnMap[sortBy] ?? people.createdAt; + const orderBy = sortOrder === "desc" ? desc(orderColumn) : asc(orderColumn); + + const [rows, totalCountResult] = await Promise.all([ + db + .select({ + id: people.id, + workspaceId: people.workspaceId, + orgId: people.orgId, + ownerId: people.ownerId, + name: people.name, + email: people.email, + phone: people.phone, + jobTitle: people.jobTitle, + linkedinUrl: people.linkedinUrl, + status: people.status, + source: people.source, + lastContactedAt: people.lastContactedAt, + customFields: people.customFields, + createdAt: people.createdAt, + updatedAt: people.updatedAt, + orgName: orgs.name, + ownerName: user.name, + }) + .from(people) + .leftJoin(orgs, eq(people.orgId, orgs.id)) + .leftJoin(user, eq(people.ownerId, user.id)) + .where(whereClause) + .orderBy(orderBy) + .limit(pageSize) + .offset(offset), + db.select({ totalCount: count() }).from(people).where(whereClause), + ]); + + const totalCount = Number(totalCountResult[0]?.totalCount ?? 0); + const totalPages = totalCount === 0 ? 0 : Math.ceil(totalCount / pageSize); + return sendSuccess( + c, + { + people: rows.map((row) => ({ + ...row, + orgName: row.orgName ?? null, + ownerName: row.ownerName ?? null, + })), + meta: { page, pageSize, totalCount, totalPages }, + }, + STATUS_CODES.OK, + ); +} export async function getPerson(c: Context, id: string) { const workspaceId = getSessionWorkspaceId(c); - const [person] = await db - .select() - .from(people) - .where(and(eq(people.id, id), eq(people.workspaceId, workspaceId))); + const person = await db.query.people.findFirst({ + where: and(eq(people.id, id), eq(people.workspaceId, workspaceId)), + with: { + org: { + columns: { + id: true, + name: true, + }, + }, + owner: { + columns: { + id: true, + name: true, + }, + }, + }, + }); if (!person) { throw new AppError("Person not found", STATUS_CODES.NOT_FOUND); @@ -76,3 +171,13 @@ export async function deletePerson(c: Context, id: string) { return sendSuccess(c, { person }, STATUS_CODES.OK); } + +export async function bulkDeletePeople(c: Context, payload: BulkDeleteInput) { + const workspaceId = getSessionWorkspaceId(c); + const deletedPeople = await db + .delete(people) + .where(and(eq(people.workspaceId, workspaceId), inArray(people.id, payload.ids))) + .returning({ id: people.id }); + + return sendSuccess(c, { deleted: deletedPeople.length }, STATUS_CODES.OK); +} diff --git a/apps/api/src/routes/deals.route.ts b/apps/api/src/routes/deals.route.ts index d83860a..815730f 100644 --- a/apps/api/src/routes/deals.route.ts +++ b/apps/api/src/routes/deals.route.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { createDealSchema, dealParamsSchema, + listDealsQuerySchema, updateDealSchema, } from "@workspace/validators/schemas/crm"; import { @@ -17,7 +18,9 @@ import { validateRequest } from "@/middlewares/validate-request.js"; export const dealRoutes = new Hono() .use("*", authMiddleware) - .get("/", listDeals) + .get("/", validateRequest(VALIDATION_TARGET.QUERY, listDealsQuerySchema), (c) => + listDeals(c, c.req.valid(VALIDATION_TARGET.QUERY)), + ) .get("/:id", validateRequest(VALIDATION_TARGET.PARAM, dealParamsSchema), (c) => { const { id } = c.req.valid(VALIDATION_TARGET.PARAM); return getDeal(c, id); diff --git a/apps/api/src/routes/orgs.route.ts b/apps/api/src/routes/orgs.route.ts index 87326f7..bf46c98 100644 --- a/apps/api/src/routes/orgs.route.ts +++ b/apps/api/src/routes/orgs.route.ts @@ -1,10 +1,13 @@ import { Hono } from "hono"; import { + bulkDeleteSchema, createOrgSchema, + listOrgsQuerySchema, orgParamsSchema, updateOrgSchema, } from "@workspace/validators/schemas/crm"; import { + bulkDeleteOrgs, createOrg, deleteOrg, getOrg, @@ -17,7 +20,9 @@ import { validateRequest } from "@/middlewares/validate-request.js"; export const orgRoutes = new Hono() .use("*", authMiddleware) - .get("/", listOrgs) + .get("/", validateRequest(VALIDATION_TARGET.QUERY, listOrgsQuerySchema), (c) => + listOrgs(c, c.req.valid(VALIDATION_TARGET.QUERY)), + ) .get("/:id", validateRequest(VALIDATION_TARGET.PARAM, orgParamsSchema), (c) => { const { id } = c.req.valid(VALIDATION_TARGET.PARAM); return getOrg(c, id); @@ -34,6 +39,9 @@ export const orgRoutes = new Hono() return updateOrg(c, id, c.req.valid(VALIDATION_TARGET.JSON)); }, ) + .delete("/bulk", validateRequest(VALIDATION_TARGET.JSON, bulkDeleteSchema), (c) => + bulkDeleteOrgs(c, c.req.valid(VALIDATION_TARGET.JSON)), + ) .delete("/:id", validateRequest(VALIDATION_TARGET.PARAM, orgParamsSchema), (c) => { const { id } = c.req.valid(VALIDATION_TARGET.PARAM); return deleteOrg(c, id); diff --git a/apps/api/src/routes/people.route.ts b/apps/api/src/routes/people.route.ts index f8752c3..67011b3 100644 --- a/apps/api/src/routes/people.route.ts +++ b/apps/api/src/routes/people.route.ts @@ -1,10 +1,13 @@ import { Hono } from "hono"; import { + bulkDeleteSchema, createPersonSchema, + listPeopleQuerySchema, personParamsSchema, updatePersonSchema, } from "@workspace/validators/schemas/crm"; import { + bulkDeletePeople, createPerson, deletePerson, getPerson, @@ -17,7 +20,9 @@ import { validateRequest } from "@/middlewares/validate-request.js"; export const peopleRoutes = new Hono() .use("*", authMiddleware) - .get("/", listPeople) + .get("/", validateRequest(VALIDATION_TARGET.QUERY, listPeopleQuerySchema), (c) => + listPeople(c, c.req.valid(VALIDATION_TARGET.QUERY)), + ) .get("/:id", validateRequest(VALIDATION_TARGET.PARAM, personParamsSchema), (c) => { const { id } = c.req.valid(VALIDATION_TARGET.PARAM); return getPerson(c, id); @@ -25,6 +30,9 @@ export const peopleRoutes = new Hono() .post("/", validateRequest(VALIDATION_TARGET.JSON, createPersonSchema), (c) => createPerson(c, c.req.valid(VALIDATION_TARGET.JSON)), ) + .delete("/bulk", validateRequest(VALIDATION_TARGET.JSON, bulkDeleteSchema), (c) => + bulkDeletePeople(c, c.req.valid(VALIDATION_TARGET.JSON)), + ) .patch( "/:id", validateRequest(VALIDATION_TARGET.PARAM, personParamsSchema), diff --git a/apps/web/app/(crm)/deals/page.tsx b/apps/web/app/(crm)/deals/page.tsx index f078b6d..20ae2be 100644 --- a/apps/web/app/(crm)/deals/page.tsx +++ b/apps/web/app/(crm)/deals/page.tsx @@ -1,35 +1,84 @@ -import { Search, Filter, LayoutGrid, List } from "lucide-react"; -import { Input } from "@workspace/ui/components/ui/input"; +"use client"; + +import { useState } from "react"; +import { Plus } from "lucide-react"; import { Button } from "@workspace/ui/components/ui/button"; -import { ToggleGroup, ToggleGroupItem } from "@workspace/ui/components/ui/toggle-group"; import { PageHeader } from "@/components/layout/page-header"; +import { DealsKanban, type DrawerState } from "@/components/crm/deals/deals-kanban"; +import { DealsDrawer } from "@/components/crm/deals/deals-drawer"; +import { ConfirmDialog } from "@/components/shared/confirm-dialog"; +import { useDeleteDeal } from "@/hooks/queries/use-deals"; +import type { EntitySheetMode } from "@/components/shared/entity-sheet"; +import type { Deal } from "@/types/crm"; export default function DealsPage() { + const [drawer, setDrawer] = useState({ + open: false, + mode: "view", + }); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { mutate: deleteDeal, isPending: isDeleting } = useDeleteDeal(); + + function openDrawer(mode: EntitySheetMode, deal?: Deal, initialStage?: string) { + setDrawer({ open: true, mode, deal, initialStage }); + } + + function closeDrawer() { + setDrawer((prev) => ({ ...prev, open: false })); + } + + function handleDeleteConfirm() { + if (!deleteTarget) return; + deleteDeal(deleteTarget.id, { + onSuccess: () => setDeleteTarget(null), + }); + } + return (
-
- - -
- - - - - - - - - - + } /> + + + + { + if (!open) closeDrawer(); + }} + mode={drawer.mode} + onModeChange={(mode) => + setDrawer((prev) => ({ ...prev, mode })) + } + deal={drawer.deal} + initialStage={drawer.initialStage} + onDeleteSuccess={() => setDeleteTarget(null)} + /> + + { + if (!open) setDeleteTarget(null); + }} + title={`Delete "${deleteTarget?.title}"?`} + description="This will permanently remove this deal from your pipeline. This action cannot be undone." + confirmLabel={isDeleting ? "Deleting…" : "Delete"} + variant="destructive" + isPending={isDeleting} + onConfirm={handleDeleteConfirm} + />
); } diff --git a/apps/web/app/(crm)/organizations/page.tsx b/apps/web/app/(crm)/organizations/page.tsx index d1fee2a..4e31d64 100644 --- a/apps/web/app/(crm)/organizations/page.tsx +++ b/apps/web/app/(crm)/organizations/page.tsx @@ -1,65 +1,5 @@ -import { Search, Upload, Plus } from "lucide-react"; -import { Input } from "@workspace/ui/components/ui/input"; -import { Button } from "@workspace/ui/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@workspace/ui/components/ui/select"; -import { PageHeader } from "@/components/layout/page-header"; +import { OrgsDataTable } from "@/components/crm/orgs/orgs-data-table"; export default function OrganizationsPage() { - return ( -
- -
- - -
- - - - - - } - /> -
- ); + return ; } diff --git a/apps/web/app/(crm)/people/page.tsx b/apps/web/app/(crm)/people/page.tsx index fdca925..57c1e03 100644 --- a/apps/web/app/(crm)/people/page.tsx +++ b/apps/web/app/(crm)/people/page.tsx @@ -1,72 +1,5 @@ -import { Search, Upload, Plus } from "lucide-react"; -import { Input } from "@workspace/ui/components/ui/input"; -import { Button } from "@workspace/ui/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@workspace/ui/components/ui/select"; -import { PageHeader } from "@/components/layout/page-header"; +import { PeopleDataTable } from "@/components/crm/people/people-data-table"; export default function PeoplePage() { - return ( -
- -
- - -
- - - - - - - } - /> -
- ); + return ; } diff --git a/apps/web/components/crm/crm-options.ts b/apps/web/components/crm/crm-options.ts new file mode 100644 index 0000000..78d9ce7 --- /dev/null +++ b/apps/web/components/crm/crm-options.ts @@ -0,0 +1,58 @@ +import type { PersonSource, PersonStatus } from "@workspace/validators/schemas/crm"; + +export const PERSON_STATUS_OPTIONS = [ + { + value: "lead" satisfies PersonStatus, + label: "Lead", + badgeClassName: + "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300 border-transparent", + }, + { + value: "prospect" satisfies PersonStatus, + label: "Prospect", + badgeClassName: + "bg-blue-100 text-blue-700 dark:bg-blue-950/70 dark:text-blue-300 border-transparent", + }, + { + value: "qualified" satisfies PersonStatus, + label: "Qualified", + badgeClassName: + "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/70 dark:text-emerald-300 border-transparent", + }, + { + value: "customer" satisfies PersonStatus, + label: "Customer", + badgeClassName: + "bg-violet-100 text-violet-700 dark:bg-violet-950/70 dark:text-violet-300 border-transparent", + }, + { + value: "churned" satisfies PersonStatus, + label: "Churned", + badgeClassName: + "bg-rose-100 text-rose-700 dark:bg-rose-950/70 dark:text-rose-300 border-transparent", + }, +]; + +export const PERSON_SOURCE_OPTIONS = [ + { value: "manual" satisfies PersonSource, label: "Manual" }, + { value: "csv" satisfies PersonSource, label: "CSV import" }, + { value: "api" satisfies PersonSource, label: "API" }, +]; + +export const ORG_INDUSTRY_OPTIONS = [ + { label: "Technology", value: "technology" }, + { label: "Finance", value: "finance" }, + { label: "Healthcare", value: "healthcare" }, + { label: "Manufacturing", value: "manufacturing" }, + { label: "Retail", value: "retail" }, + { label: "Consulting", value: "consulting" }, + { label: "Other", value: "other" }, +]; + +export const ORG_SIZE_OPTIONS = [ + { label: "1–10", value: "1-10" }, + { label: "11–50", value: "11-50" }, + { label: "51–200", value: "51-200" }, + { label: "201–500", value: "201-500" }, + { label: "500+", value: "500+" }, +]; diff --git a/apps/web/components/crm/crm-row-actions.tsx b/apps/web/components/crm/crm-row-actions.tsx new file mode 100644 index 0000000..14afeaa --- /dev/null +++ b/apps/web/components/crm/crm-row-actions.tsx @@ -0,0 +1,50 @@ +import { Eye, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { Button } from "@workspace/ui/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@workspace/ui/components/ui/dropdown-menu"; + +interface CrmRowActionsProps { + onView: () => void; + onEdit: () => void; + onDelete: () => void; + triggerLabel: string; + contentClassName?: string; +} + +export function CrmRowActions({ + onView, + onEdit, + onDelete, + triggerLabel, + contentClassName, +}: CrmRowActionsProps) { + return ( + + + + + + + + View + + + + Edit + + + + + Delete + + + + ); +} diff --git a/apps/web/components/crm/crm-view.tsx b/apps/web/components/crm/crm-view.tsx new file mode 100644 index 0000000..b8a95a6 --- /dev/null +++ b/apps/web/components/crm/crm-view.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +export function CrmViewField({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +export function CrmViewSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} diff --git a/apps/web/components/crm/deals/deals-drawer.tsx b/apps/web/components/crm/deals/deals-drawer.tsx new file mode 100644 index 0000000..2e2519b --- /dev/null +++ b/apps/web/components/crm/deals/deals-drawer.tsx @@ -0,0 +1,454 @@ +"use client"; + +import { useMemo } from "react"; +import dayjs from "dayjs"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createDealSchema, type CreateDeal } from "@workspace/validators/schemas/crm"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@workspace/ui/components/ui/form"; +import { Input } from "@workspace/ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/ui/select"; +import { Separator } from "@workspace/ui/components/ui/separator"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { cn } from "@workspace/ui/lib/utils"; +import { EntitySheet, type EntitySheetMode } from "@/components/shared/entity-sheet"; +import { CrmViewField, CrmViewSection } from "@/components/crm/crm-view"; +import { useCreateDeal, useUpdateDeal, useDeleteDeal } from "@/hooks/queries/use-deals"; +import { usePeople } from "@/hooks/queries/use-people"; +import { useOrganizations } from "@/hooks/queries/use-orgs"; +import { useActiveWorkspace } from "@/hooks/queries/use-workspace"; +import type { Deal } from "@/types/crm"; +import type { WorkspaceMember } from "@/types/workspace-settings"; +import { DEAL_STAGE_OPTIONS, DEAL_STAGE_MAP } from "@/components/crm/deals/deals-options"; + +// ─── View content ───────────────────────────────────────────────────────────── + +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; + + return ( +
+ + + {deal.title} + + + {stageConfig ? ( + + {stageConfig.label} + + ) : null} + +
+ + {deal.value ? ( + + {deal.value} {deal.currency} + + ) : ( + Not set + )} + + + + {deal.closeDate ? dayjs(deal.closeDate).format("MMMM D, YYYY") : "—"} + + +
+
+ + + + + + {personName ?? Not linked} + + + {orgName ?? Not linked} + + + {ownerName ?? Not assigned} + + + +
+
+ + + {dayjs(deal.createdAt).format("MMMM D, YYYY")} + + + + + {dayjs(deal.updatedAt).format("MMMM D, YYYY")} + + +
+
+
+ ); +} + +// ─── Form content ───────────────────────────────────────────────────────────── + +function DealForm({ + form, + isPending, +}: { + form: ReturnType>; + isPending: boolean; +}) { + const { data: peopleData } = usePeople({ pageSize: 100 }); + const { data: orgsData } = useOrganizations({ pageSize: 100 }); + const { data: workspace } = useActiveWorkspace(); + + const people = peopleData?.people ?? []; + const orgs = orgsData?.orgs ?? []; + const members = (workspace?.members ?? []) as Pick[]; + + return ( +
+
+ {/* Title */} + ( + + + Title * + + + + + + + )} + /> + + {/* Stage */} + ( + + Stage + + + + )} + /> + + {/* Value + Currency */} +
+
+ ( + + Value + + + + + + )} + /> +
+ ( + + Currency + + + + + + )} + /> +
+ + {/* Close Date */} + ( + + Close Date + + + field.onChange( + e.target.value ? dayjs(e.target.value, "YYYY-MM-DD").toDate() : undefined, + ) + } + disabled={isPending} + /> + + + + )} + /> + + {/* Contact */} + ( + + Contact + + + + )} + /> + + {/* Organization */} + ( + + Organization + + + + )} + /> + + {/* Owner */} + ( + + Owner + + + + )} + /> +
+
+ ); +} + +// ─── Main drawer ────────────────────────────────────────────────────────────── + +interface DealsDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: EntitySheetMode; + onModeChange: (mode: EntitySheetMode) => void; + deal?: Deal | null; + initialStage?: string; + onDeleteSuccess?: () => void; +} + +export function DealsDrawer({ + open, + onOpenChange, + mode, + onModeChange, + deal, + initialStage, + onDeleteSuccess, +}: DealsDrawerProps) { + const { mutate: createDeal, isPending: isCreating } = useCreateDeal(); + const { mutate: updateDeal, isPending: isUpdating } = useUpdateDeal(); + const { mutate: deleteDeal, isPending: isDeleting } = useDeleteDeal(); + + const isSaving = isCreating || isUpdating || isDeleting; + + const formValues = useMemo( + () => ({ + title: mode === "create" ? "" : (deal?.title ?? ""), + stage: + mode === "create" + ? ((initialStage as CreateDeal["stage"]) ?? "new") + : (deal?.stage ?? "new"), + value: mode === "create" ? undefined : (deal?.value ?? undefined), + currency: mode === "create" ? "USD" : (deal?.currency ?? "USD"), + closeDate: + mode === "create" + ? undefined + : deal?.closeDate + ? dayjs(deal.closeDate).toDate() + : undefined, + personId: mode === "create" ? null : (deal?.personId ?? null), + orgId: mode === "create" ? null : (deal?.orgId ?? null), + ownerId: mode === "create" ? null : (deal?.ownerId ?? null), + }), + [mode, deal, initialStage], + ); + + const form = useForm({ + resolver: zodResolver(createDealSchema), + values: formValues, + }); + + function onSubmit(values: CreateDeal) { + const payload: CreateDeal = { + ...values, + value: values.value || undefined, + currency: values.currency || "USD", + }; + + if (mode === "create") { + createDeal(payload, { onSuccess: () => onOpenChange(false) }); + } else if (deal) { + updateDeal({ dealId: deal.id, input: payload }, { onSuccess: () => onOpenChange(false) }); + } + } + + function handleDelete() { + if (!deal) return; + deleteDeal(deal.id, { + onSuccess: () => { + onOpenChange(false); + onDeleteSuccess?.(); + }, + }); + } + + const title = + mode === "create" + ? "New Deal" + : mode === "edit" + ? `Edit — ${deal?.title ?? "Deal"}` + : (deal?.title ?? "Deal"); + + const description = + mode === "create" + ? "Add a new deal to your pipeline." + : mode === "edit" + ? "Update the details for this deal." + : undefined; + + return ( + onModeChange("edit") : undefined} + onSave={form.handleSubmit(onSubmit)} + onDelete={mode !== "create" && deal ? handleDelete : undefined} + > + {mode === "view" && deal ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/web/components/crm/deals/deals-filters.ts b/apps/web/components/crm/deals/deals-filters.ts new file mode 100644 index 0000000..9c8ec8b --- /dev/null +++ b/apps/web/components/crm/deals/deals-filters.ts @@ -0,0 +1,10 @@ +import type { FilterConfig } from "@/components/shared/data-table"; +import { DEAL_STAGE_OPTIONS } from "./deals-options"; + +export const DEALS_FILTER_CONFIG: FilterConfig[] = [ + { + columnId: "stage", + label: "Stage", + options: DEAL_STAGE_OPTIONS.map((o) => ({ label: o.label, value: o.value })), + }, +]; diff --git a/apps/web/components/crm/deals/deals-kanban.tsx b/apps/web/components/crm/deals/deals-kanban.tsx new file mode 100644 index 0000000..d53a125 --- /dev/null +++ b/apps/web/components/crm/deals/deals-kanban.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import { Plus, Calendar, User, Building2, DollarSign } from "lucide-react"; +import dayjs from "dayjs"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { cn } from "@workspace/ui/lib/utils"; +import { + Kanban, + KanbanBoard, + KanbanColumn, + KanbanColumnContent, + KanbanItem, + KanbanItemHandle, + KanbanOverlay, + type KanbanMoveEvent, +} from "@/components/reui/kanban"; +import { useDeals, useUpdateDeal } from "@/hooks/queries/use-deals"; +import type { Deal } from "@/types/crm"; +import type { DealStage } from "@workspace/validators/schemas/crm"; +import { DEAL_STAGE_OPTIONS } from "./deals-options"; +import type { EntitySheetMode } from "@/components/shared/entity-sheet"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface DrawerState { + open: boolean; + mode: EntitySheetMode; + deal?: Deal; + initialStage?: string; +} + +interface DealsKanbanProps { + drawerState: DrawerState; + onDrawerStateChange: (state: DrawerState) => void; +} + +// ─── Deal Card ──────────────────────────────────────────────────────────────── + +function DealCard({ deal, onClick }: { deal: Deal; onClick: () => void }) { + const personName = deal.person?.name; + const orgName = deal.org?.name; + const ownerName = deal.owner?.name; + + return ( +
+ {/* Invisible click layer — sits above content but below drag handle */} +
+ ); +} + +// ─── Overlay ghost card ─────────────────────────────────────────────────────── + +function DealCardGhost({ deal }: { deal: Deal }) { + return ( +
+

{deal.title}

+ {deal.value && ( +
+ + + {Number(deal.value).toLocaleString()} {deal.currency} + +
+ )} +
+ ); +} + +// ─── Kanban board ───────────────────────────────────────────────────────────── + +export function DealsKanban({ onDrawerStateChange }: DealsKanbanProps) { + const { data, isLoading, isError } = useDeals({ pageSize: 100 }); + const { mutate: updateDeal } = useUpdateDeal(); + + const deals = useMemo(() => data?.deals ?? [], [data]); + + // reui Kanban state: Record + // Initialised from server data; re-sync when server data updates. + const serverColumns = useMemo>(() => { + const map: Record = {}; + for (const stage of DEAL_STAGE_OPTIONS) { + map[stage.value] = []; + } + for (const deal of deals) { + const bucket = map[deal.stage]; + if (bucket) bucket.push(deal); + } + return map; + }, [deals]); + + // Local optimistic state — drives the reui Kanban + const [columns, setColumns] = useState>(serverColumns); + + // Keep local state in sync with server (after refetch) + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + setColumns(serverColumns); + }, [serverColumns]); + + // Lookup map for overlay rendering (must be before early returns) + const allDealsMap = useMemo(() => { + const m = new Map(); + for (const deal of deals) m.set(deal.id, deal); + return m; + }, [deals]); + + // Called by reui only when an item crosses a column boundary at drop time + function handleMove({ activeContainer, overContainer, activeIndex }: KanbanMoveEvent) { + if (activeContainer === overContainer) return; + + const movedDeal = columns[activeContainer]?.[activeIndex]; + if (!movedDeal) return; + + const newStage = overContainer as DealStage; + updateDeal({ dealId: movedDeal.id, input: { stage: newStage } }); + } + + function openDrawer(mode: EntitySheetMode, deal?: Deal, initialStage?: string) { + onDrawerStateChange({ open: true, mode, deal, initialStage }); + } + + // ─── Loading ──────────────────────────────────────────────────────────────── + + if (isLoading) { + return ( +
+ {DEAL_STAGE_OPTIONS.map((stage) => ( +
+
+
+
+
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+ ))} +
+ ); + } + + // ─── Error ────────────────────────────────────────────────────────────────── + + if (isError) { + return ( +
+ Failed to load deals. Please refresh the page. +
+ ); + } + + // ─── Render ───────────────────────────────────────────────────────────────── + + return ( + deal.id} + onMove={handleMove} + > + + {DEAL_STAGE_OPTIONS.map((stage) => { + const stageDeals = columns[stage.value] ?? []; + const totalValue = stageDeals.reduce((acc, d) => { + const v = d.value ? Number(d.value) : 0; + return acc + (isNaN(v) ? 0 : v); + }, 0); + + return ( + + {/* Column header — not a drag handle (columns are fixed order) */} +
+
+
+ + {stage.label} + + + {stageDeals.length} + +
+ {totalValue > 0 && ( + + ${totalValue.toLocaleString()} + + )} +
+
+ + {/* Drop zone */} + + {stageDeals.map((deal) => ( + + + openDrawer("view", deal)} /> + + + ))} + + {/* Add deal */} + + +
+ ); + })} +
+ + {/* Drag overlay ghost */} + + {({ value }) => { + const deal = allDealsMap.get(value as string); + return deal ? : null; + }} + +
+ ); +} diff --git a/apps/web/components/crm/deals/deals-options.ts b/apps/web/components/crm/deals/deals-options.ts new file mode 100644 index 0000000..6b8fa1d --- /dev/null +++ b/apps/web/components/crm/deals/deals-options.ts @@ -0,0 +1,55 @@ +import type { DealStage } from "@workspace/validators/schemas/crm"; + +export const DEAL_STAGE_OPTIONS: { + value: DealStage; + label: string; + badgeClassName: string; + columnClassName: string; +}[] = [ + { + value: "new", + label: "New", + badgeClassName: + "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300 border-transparent", + columnClassName: "border-t-slate-400 dark:border-t-slate-500", + }, + { + value: "contacted", + label: "Contacted", + badgeClassName: + "bg-blue-100 text-blue-700 dark:bg-blue-950/70 dark:text-blue-300 border-transparent", + columnClassName: "border-t-blue-400 dark:border-t-blue-500", + }, + { + value: "demo", + label: "Demo", + badgeClassName: + "bg-violet-100 text-violet-700 dark:bg-violet-950/70 dark:text-violet-300 border-transparent", + columnClassName: "border-t-violet-400 dark:border-t-violet-500", + }, + { + value: "proposal", + label: "Proposal", + badgeClassName: + "bg-amber-100 text-amber-700 dark:bg-amber-950/70 dark:text-amber-300 border-transparent", + columnClassName: "border-t-amber-400 dark:border-t-amber-500", + }, + { + value: "won", + label: "Won", + badgeClassName: + "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/70 dark:text-emerald-300 border-transparent", + columnClassName: "border-t-emerald-400 dark:border-t-emerald-500", + }, + { + value: "lost", + label: "Lost", + badgeClassName: + "bg-rose-100 text-rose-700 dark:bg-rose-950/70 dark:text-rose-300 border-transparent", + columnClassName: "border-t-rose-400 dark:border-t-rose-500", + }, +]; + +export const DEAL_STAGE_MAP = Object.fromEntries( + DEAL_STAGE_OPTIONS.map((o) => [o.value, o]), +) as Record; diff --git a/apps/web/components/crm/orgs/orgs-columns.tsx b/apps/web/components/crm/orgs/orgs-columns.tsx new file mode 100644 index 0000000..22bc9d1 --- /dev/null +++ b/apps/web/components/crm/orgs/orgs-columns.tsx @@ -0,0 +1,106 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { Building2 } from "lucide-react"; +import { CrmRowActions } from "@/components/crm/crm-row-actions"; +import type { Organization } from "@/types/crm"; + +interface GetOrgsColumnsProps { + onView: (org: Organization) => void; + onEdit: (org: Organization) => void; + onDelete: (org: Organization) => void; +} + +export function getOrgsColumns({ + onView, + onEdit, + onDelete, +}: GetOrgsColumnsProps): ColumnDef[] { + const emptyCell = ; + + return [ + { + id: "name", + accessorKey: "name", + header: "Name", + enableSorting: true, + cell: ({ row }) => ( + + ), + }, + { + id: "domain", + accessorKey: "domain", + header: "Domain", + enableSorting: true, + cell: ({ row }) => + row.original.domain ? ( + {row.original.domain} + ) : ( + emptyCell + ), + }, + { + id: "industry", + accessorKey: "industry", + header: "Industry", + enableSorting: true, + cell: ({ row }) => + row.original.industry ? ( + {row.original.industry} + ) : ( + emptyCell + ), + }, + { + id: "size", + accessorKey: "size", + header: "Size", + enableSorting: true, + cell: ({ row }) => (row.original.size ? {row.original.size} : emptyCell), + }, + { + id: "location", + accessorKey: "location", + header: "Location", + enableSorting: true, + cell: ({ row }) => + row.original.location ? ( + {row.original.location} + ) : ( + emptyCell + ), + }, + { + id: "peopleCount", + header: "People", + enableSorting: false, + cell: ({ row }) => { + const count = row.original.peopleCount ?? 0; + return {count}; + }, + }, + { + id: "__actions", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( + onView(row.original)} + onEdit={() => onEdit(row.original)} + onDelete={() => onDelete(row.original)} + /> + ), + }, + ]; +} diff --git a/apps/web/components/crm/orgs/orgs-data-table.tsx b/apps/web/components/crm/orgs/orgs-data-table.tsx new file mode 100644 index 0000000..2a9b19d --- /dev/null +++ b/apps/web/components/crm/orgs/orgs-data-table.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState } from "react"; +import type { ColumnFiltersState, RowSelectionState, SortingState } from "@tanstack/react-table"; +import { Plus, Trash2 } from "lucide-react"; +import { Button } from "@workspace/ui/components/ui/button"; +import { PageHeader } from "@/components/layout/page-header"; +import { DataTable } from "@/components/shared/data-table"; +import { ConfirmDialog } from "@/components/shared/confirm-dialog"; +import { getOrgsColumns } from "./orgs-columns"; +import { ORGS_FILTER_CONFIG } from "./orgs-filters"; +import { OrgDrawer } from "./orgs-drawer"; +import { useBulkDeleteOrgs, useDeleteOrg, useOrganizations } from "@/hooks/queries/use-orgs"; +import { useDebounceValue } from "usehooks-ts"; +import type { Organization, OrganizationsListParams } from "@/types/crm"; +import type { EntitySheetMode } from "@/components/shared/entity-sheet"; + +type DrawerState = { + open: boolean; + mode: EntitySheetMode; + org?: Organization; +}; + +type OrgsTableState = { + pagination: { pageIndex: number; pageSize: number }; + sorting: SortingState; + columnFilters: ColumnFiltersState; + searchInput: string; + rowSelection: RowSelectionState; +}; + +type OrgsUiState = { + drawer: DrawerState; + deleteTarget: Organization | "bulk" | null; +}; + +export function OrgsDataTable() { + const [table, setTable] = useState({ + pagination: { pageIndex: 0, pageSize: 25 }, + sorting: [], + columnFilters: [], + searchInput: "", + rowSelection: {}, + }); + const [ui, setUi] = useState({ + drawer: { open: false, mode: "view" }, + deleteTarget: null, + }); + + // Debounced search value drives the query; raw input drives the input element + const [debouncedSearch] = useDebounceValue(table.searchInput, 300); + + // Derive filter values from TanStack columnFilters + const industryFilter = table.columnFilters.find((f) => f.id === "industry")?.value as + | string + | undefined; + const sizeFilter = table.columnFilters.find((f) => f.id === "size")?.value as string | undefined; + + // Build server query params + const queryParams: OrganizationsListParams = { + page: table.pagination.pageIndex + 1, + pageSize: table.pagination.pageSize, + ...(table.sorting[0] && { + sortBy: table.sorting[0].id as OrganizationsListParams["sortBy"], + sortOrder: table.sorting[0].desc ? "desc" : "asc", + }), + ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), + ...(industryFilter && { industry: industryFilter }), + ...(sizeFilter && { size: sizeFilter }), + }; + + // Data + mutations + const { data, isLoading, isError, refetch } = useOrganizations(queryParams); + const { mutate: bulkDeleteMutate, isPending: isBulkDeleting } = useBulkDeleteOrgs(); + const { mutate: deleteOrgMutate, isPending: isDeleting } = useDeleteOrg(); + + const orgs = data?.orgs ?? []; + const totalCount = data?.meta.totalCount ?? 0; + const pageCount = data?.meta.totalPages ?? 0; + + const selectedIds = Object.keys(table.rowSelection).filter((id) => table.rowSelection[id]); + const selectedCount = selectedIds.length; + const isDeletePending = isBulkDeleting || isDeleting; + + function updateTable(next: Partial) { + setTable((current) => ({ ...current, ...next })); + } + + function openDrawer(mode: EntitySheetMode, org?: Organization) { + setUi((current) => ({ + ...current, + drawer: { open: true, mode, org }, + })); + } + + const columns = getOrgsColumns({ + onView: (org) => openDrawer("view", org), + onEdit: (org) => openDrawer("edit", org), + onDelete: (org) => setUi((current) => ({ ...current, deleteTarget: org })), + }); + + // Handlers + function handleSortingChange(next: SortingState) { + setTable((current) => ({ + ...current, + sorting: next, + pagination: { ...current.pagination, pageIndex: 0 }, + })); + } + + function handleConfirmDelete() { + if (ui.deleteTarget === "bulk") { + bulkDeleteMutate( + { ids: selectedIds }, + { + onSuccess: () => { + setTable((current) => ({ ...current, rowSelection: {} })); + setUi((current) => ({ ...current, deleteTarget: null })); + }, + }, + ); + } else if (ui.deleteTarget) { + deleteOrgMutate(ui.deleteTarget.id, { + onSuccess: () => setUi((current) => ({ ...current, deleteTarget: null })), + }); + } + } + + // Toolbar: only shows when rows are selected + const toolbarActions = + selectedCount > 0 ? ( + + ) : null; + + // Confirm dialog copy + const isBulkTarget = ui.deleteTarget === "bulk"; + const confirmTitle = isBulkTarget + ? `Delete ${selectedCount} organization${selectedCount === 1 ? "" : "s"}?` + : `Delete "${(ui.deleteTarget as Organization | null)?.name}"?`; + + const confirmDescription = isBulkTarget + ? `This will permanently remove ${selectedCount} organization${ + selectedCount === 1 ? "" : "s" + }. People linked to ${selectedCount === 1 ? "it" : "them"} will have their organization cleared. This action cannot be undone.` + : `This will permanently delete "${(ui.deleteTarget as Organization | null)?.name}". People linked to this organization will have their organization cleared. This action cannot be undone.`; + + return ( + <> + openDrawer("create")}> + + Add Organization + + } + /> + + updateTable({ pagination })} + sorting={table.sorting} + onSortingChange={handleSortingChange} + columnFilters={table.columnFilters} + onColumnFiltersChange={(columnFilters) => updateTable({ columnFilters })} + searchValue={table.searchInput} + onSearchChange={(searchInput) => updateTable({ searchInput })} + searchPlaceholder="Search organizations…" + filterConfig={ORGS_FILTER_CONFIG} + isLoading={isLoading} + isError={isError} + errorTitle="Failed to load organizations" + errorDescription="There was a problem fetching your organizations." + onRetry={refetch} + enableRowSelection + rowSelection={table.rowSelection} + onRowSelectionChange={(rowSelection) => updateTable({ rowSelection })} + getRowId={(row) => row.id} + onRowClick={(org) => openDrawer("view", org)} + emptyTitle="No organizations yet" + emptyDescription="Add your first organization to start tracking companies in your CRM." + toolbarActions={toolbarActions} + /> + + + setUi((current) => ({ ...current, drawer: { ...current.drawer, open } })) + } + mode={ui.drawer.mode} + onModeChange={(mode) => + setUi((current) => ({ ...current, drawer: { ...current.drawer, mode } })) + } + org={ui.drawer.org} + /> + + { + if (!open) setUi((current) => ({ ...current, deleteTarget: null })); + }} + title={confirmTitle} + description={confirmDescription} + confirmLabel="Delete" + variant="destructive" + isPending={isDeletePending} + onConfirm={handleConfirmDelete} + /> + + ); +} diff --git a/apps/web/components/crm/orgs/orgs-drawer.tsx b/apps/web/components/crm/orgs/orgs-drawer.tsx new file mode 100644 index 0000000..3ec3d60 --- /dev/null +++ b/apps/web/components/crm/orgs/orgs-drawer.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Building2 } from "lucide-react"; +import { createOrgSchema, type CreateOrg } from "@workspace/validators/schemas/crm"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@workspace/ui/components/ui/form"; +import { Input } from "@workspace/ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/ui/select"; +import { Separator } from "@workspace/ui/components/ui/separator"; +import { EntitySheet, type EntitySheetMode } from "@/components/shared/entity-sheet"; +import { CrmViewField, CrmViewSection } from "@/components/crm/crm-view"; +import { useCreateOrg, useUpdateOrg, useDeleteOrg } from "@/hooks/queries/use-orgs"; +import type { Organization } from "@/types/crm"; +import { ORG_INDUSTRY_OPTIONS, ORG_SIZE_OPTIONS } from "@/components/crm/crm-options"; + +function capitalize(str: string | null | undefined): string | null | undefined { + if (!str) return str; + return str.charAt(0).toUpperCase() + str.slice(1); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function OrgAvatar() { + return ( +
+ +
+ ); +} + +function ViewContent({ org }: { org: Organization }) { + return ( +
+ {/* Identity */} +
+ +
+

{org.name}

+ {org.domain &&

{org.domain}

} +
+
+ + + + + + {capitalize(org.industry) ?? } + + + {org.size ?? } + + + {org.location ?? } + + + + {org.peopleCount !== undefined && ( + <> + + + + {`${org.peopleCount} ${org.peopleCount === 1 ? "person" : "people"}`} + + + + )} +
+ ); +} + +function OrgForm({ + form, + isPending, +}: { + form: ReturnType>; + isPending: boolean; +}) { + return ( +
+
+ {/* Name */} + ( + + + Name * + + + + + + + )} + /> + + {/* Domain */} + ( + + Domain + + + + + + )} + /> + + {/* Industry + Size */} +
+ ( + + Industry + + + + )} + /> + + ( + + Company Size + + + + )} + /> +
+ + {/* Location */} + ( + + Location + + + + + + )} + /> +
+
+ ); +} + +// ─── Drawer ─────────────────────────────────────────────────────────────────── + +interface OrgDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: EntitySheetMode; + onModeChange: (mode: EntitySheetMode) => void; + org?: Organization; +} + +export function OrgDrawer({ open, onOpenChange, mode, onModeChange, org }: OrgDrawerProps) { + const { mutate: createOrgMutate, isPending: isCreating } = useCreateOrg(); + const { mutate: updateOrgMutate, isPending: isUpdating } = useUpdateOrg(org?.id ?? ""); + const { mutate: deleteOrgMutate, isPending: isDeleting } = useDeleteOrg(); + + const isPending = isCreating || isUpdating || isDeleting; + + const formValues = useMemo( + () => ({ + name: mode === "create" ? "" : (org?.name ?? ""), + domain: mode === "create" ? "" : (org?.domain ?? ""), + industry: mode === "create" ? "" : (org?.industry ?? ""), + size: mode === "create" ? "" : (org?.size ?? ""), + location: mode === "create" ? "" : (org?.location ?? ""), + }), + [mode, org], + ); + + const form = useForm({ + resolver: zodResolver(createOrgSchema), + values: formValues, + }); + + function onSubmit(values: CreateOrg) { + // Strip empty optional strings to undefined + const payload: CreateOrg = { + name: values.name, + domain: values.domain || undefined, + industry: values.industry || undefined, + size: values.size || undefined, + location: values.location || undefined, + }; + + if (mode === "create") { + createOrgMutate(payload, { onSuccess: () => onOpenChange(false) }); + } else { + updateOrgMutate(payload, { onSuccess: () => onOpenChange(false) }); + } + } + + function handleDelete() { + if (!org) return; + deleteOrgMutate(org.id, { onSuccess: () => onOpenChange(false) }); + } + + const title = + mode === "create" + ? "New Organization" + : mode === "edit" + ? "Edit Organization" + : (org?.name ?? "Organization"); + + const description = + mode === "create" + ? "Add a new organization to your CRM." + : mode === "edit" + ? "Update the organization's information." + : (org?.domain ?? undefined); + + return ( + onModeChange("edit") : undefined} + onSave={mode !== "view" ? form.handleSubmit(onSubmit) : undefined} + onDelete={mode === "view" && org ? handleDelete : undefined} + deleteLabel={isDeleting ? "Deleting…" : "Delete"} + > + {mode === "view" && org ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/web/components/crm/orgs/orgs-filters.tsx b/apps/web/components/crm/orgs/orgs-filters.tsx new file mode 100644 index 0000000..1430c13 --- /dev/null +++ b/apps/web/components/crm/orgs/orgs-filters.tsx @@ -0,0 +1,17 @@ +import type { FilterConfig } from "@/components/shared/data-table"; +import { ORG_INDUSTRY_OPTIONS, ORG_SIZE_OPTIONS } from "@/components/crm/crm-options"; + +export const ORGS_FILTER_CONFIG: FilterConfig[] = [ + { + columnId: "industry", + label: "Industry", + allLabel: "All Industries", + options: ORG_INDUSTRY_OPTIONS, + }, + { + columnId: "size", + label: "Size", + allLabel: "Any Size", + options: ORG_SIZE_OPTIONS, + }, +]; diff --git a/apps/web/components/crm/people/people-columns.tsx b/apps/web/components/crm/people/people-columns.tsx new file mode 100644 index 0000000..82201bd --- /dev/null +++ b/apps/web/components/crm/people/people-columns.tsx @@ -0,0 +1,151 @@ +"use client"; + +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { cn } from "@workspace/ui/lib/utils"; +import { CrmRowActions } from "@/components/crm/crm-row-actions"; +import type { Person } from "@/types/crm"; +import { PERSON_STATUS_OPTIONS } from "@/components/crm/crm-options"; + +// ─── Column factory ────────────────────────────────────────────────────────── + +interface GetPeopleColumnsProps { + onView: (person: Person) => void; + onEdit: (person: Person) => void; + onDelete: (person: Person) => void; +} + +export function getPeopleColumns({ + onView, + onEdit, + onDelete, +}: GetPeopleColumnsProps): ColumnDef[] { + const emptyCell = ; + + return [ + { + id: "name", + accessorKey: "name", + header: "Name", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => ( + + ), + }, + { + id: "email", + accessorKey: "email", + header: "Email", + enableSorting: true, + cell: ({ row }) => + row.original.email ? ( + + {row.original.email} + + ) : ( + emptyCell + ), + }, + { + id: "phone", + accessorKey: "phone", + header: "Phone", + enableSorting: false, + cell: ({ row }) => + row.original.phone ? ( + {row.original.phone} + ) : ( + emptyCell + ), + }, + { + id: "jobTitle", + accessorKey: "jobTitle", + header: "Job Title", + enableSorting: true, + cell: ({ row }) => + row.original.jobTitle ? ( + {row.original.jobTitle} + ) : ( + emptyCell + ), + }, + { + id: "status", + accessorKey: "status", + header: "Status", + enableSorting: true, + cell: ({ row }) => { + const config = PERSON_STATUS_OPTIONS.find((option) => option.value === row.original.status); + if (!config) return null; + return ( + + {config.label} + + ); + }, + }, + { + id: "source", + accessorKey: "source", + header: "Source", + enableSorting: true, + cell: ({ row }) => ( + {row.original.source} + ), + }, + { + id: "orgName", + header: "Organization", + enableSorting: false, + cell: ({ row }) => { + const name = row.original.orgName ?? row.original.org?.name; + return name ? {name} : emptyCell; + }, + }, + { + id: "ownerName", + header: "Owner", + enableSorting: false, + cell: ({ row }) => { + const name = row.original.ownerName ?? row.original.owner?.name; + return name ? {name} : emptyCell; + }, + }, + { + id: "lastContactedAt", + accessorKey: "lastContactedAt", + header: "Last Contacted", + enableSorting: true, + cell: ({ row }) => ( + + {row.original.lastContactedAt + ? dayjs(row.original.lastContactedAt).format("MMM D, YYYY") + : "—"} + + ), + }, + { + id: "__actions", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( + onView(row.original)} + onEdit={() => onEdit(row.original)} + onDelete={() => onDelete(row.original)} + /> + ), + }, + ]; +} diff --git a/apps/web/components/crm/people/people-data-table.tsx b/apps/web/components/crm/people/people-data-table.tsx new file mode 100644 index 0000000..afd245a --- /dev/null +++ b/apps/web/components/crm/people/people-data-table.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useState } from "react"; +import type { ColumnFiltersState, RowSelectionState, SortingState } from "@tanstack/react-table"; +import { Plus, Trash2 } from "lucide-react"; +import { Button } from "@workspace/ui/components/ui/button"; +import { DataTable } from "@/components/shared/data-table"; +import { ConfirmDialog } from "@/components/shared/confirm-dialog"; +import { PageHeader } from "@/components/layout/page-header"; +import { getPeopleColumns } from "./people-columns"; +import { PEOPLE_FILTER_CONFIG } from "./people-filters"; +import { PeopleDrawer } from "./people-drawer"; +import { usePeople, useDeletePerson, useBulkDeletePeople } from "@/hooks/queries/use-people"; +import { useDebounceValue } from "usehooks-ts"; +import type { EntitySheetMode } from "@/components/shared/entity-sheet"; +import type { Person, PeopleListParams } from "@/types/crm"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type DrawerState = { + open: boolean; + mode: EntitySheetMode; + person?: Person; +}; + +// A Person = single delete, "bulk" = bulk delete +type DeleteTarget = Person | "bulk" | null; + +type PeopleTableState = { + pagination: { pageIndex: number; pageSize: number }; + sorting: SortingState; + columnFilters: ColumnFiltersState; + rowSelection: RowSelectionState; + searchInput: string; +}; + +type PeopleUiState = { + drawer: DrawerState; + deleteTarget: DeleteTarget; +}; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function PeopleDataTable() { + const [table, setTable] = useState({ + pagination: { pageIndex: 0, pageSize: 25 }, + sorting: [], + columnFilters: [], + rowSelection: {}, + searchInput: "", + }); + const [ui, setUi] = useState({ + drawer: { open: false, mode: "view" }, + deleteTarget: null, + }); + + // Debounce search to avoid a query on every keystroke + const [debouncedSearch] = useDebounceValue(table.searchInput, 350); + + // Extract individual filter values from the TanStack ColumnFiltersState + const statusFilter = table.columnFilters.find((f) => f.id === "status")?.value as + | string + | undefined; + const sourceFilter = table.columnFilters.find((f) => f.id === "source")?.value as + | string + | undefined; + + const queryParams: PeopleListParams = { + page: table.pagination.pageIndex + 1, + pageSize: table.pagination.pageSize, + ...(table.sorting[0] && { + sortBy: table.sorting[0].id as PeopleListParams["sortBy"], + sortOrder: table.sorting[0].desc ? "desc" : "asc", + }), + ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), + ...(statusFilter && { status: statusFilter as PeopleListParams["status"] }), + ...(sourceFilter && { source: sourceFilter as PeopleListParams["source"] }), + }; + + // Data + const { data, isLoading, isError, refetch } = usePeople(queryParams); + const people = data?.people ?? []; + const totalCount = data?.meta.totalCount ?? 0; + const pageCount = data?.meta.totalPages ?? 0; + + // Mutations + const { mutate: deletePerson, isPending: isDeleting } = useDeletePerson(); + const { mutate: bulkDelete, isPending: isBulkDeleting } = useBulkDeletePeople(); + + // Selected row IDs (row keys come from getRowId which returns person.id) + const selectedIds = Object.entries(table.rowSelection) + .filter(([, value]) => value) + .map(([id]) => id); + const selectedCount = selectedIds.length; + + function updateTable(next: Partial) { + setTable((current) => ({ ...current, ...next })); + } + + function openDrawer(mode: EntitySheetMode, person?: Person) { + setUi((current) => ({ + ...current, + drawer: { open: true, mode, person }, + })); + } + + function closeDrawer() { + setUi((current) => ({ + ...current, + drawer: { ...current.drawer, open: false }, + })); + } + + const columns = getPeopleColumns({ + onView: (person) => openDrawer("view", person), + onEdit: (person) => openDrawer("edit", person), + onDelete: (person) => setUi((current) => ({ ...current, deleteTarget: person })), + }); + + // ─── Delete ────────────────────────────────────────────────────────────────── + + function handleDeleteConfirm() { + if (ui.deleteTarget === "bulk") { + bulkDelete( + { ids: selectedIds }, + { + onSuccess: () => { + setTable((current) => ({ ...current, rowSelection: {} })); + setUi((current) => ({ ...current, deleteTarget: null })); + }, + }, + ); + } else if (ui.deleteTarget) { + deletePerson(ui.deleteTarget.id, { + onSuccess: () => setUi((current) => ({ ...current, deleteTarget: null })), + }); + } + } + + const isDeletePending = isDeleting || isBulkDeleting; + + const confirmDialogCopy = + ui.deleteTarget === "bulk" + ? { + title: `Delete ${selectedCount} ${selectedCount === 1 ? "person" : "people"}?`, + description: `This will permanently remove ${ + selectedCount === 1 ? "this person" : `these ${selectedCount} people` + } from your CRM. This action cannot be undone.`, + } + : ui.deleteTarget + ? { + title: `Delete "${ui.deleteTarget.name}"?`, + description: `This will permanently remove ${ui.deleteTarget.name} from your CRM. This action cannot be undone.`, + } + : { title: "", description: "" }; + + // ─── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ openDrawer("create")}> + + Add Person + + } + /> + + updateTable({ pagination })} + sorting={table.sorting} + onSortingChange={(next) => { + setTable((current) => ({ + ...current, + sorting: next, + pagination: { ...current.pagination, pageIndex: 0 }, + })); + }} + columnFilters={table.columnFilters} + onColumnFiltersChange={(columnFilters) => updateTable({ columnFilters })} + searchValue={table.searchInput} + onSearchChange={(searchInput) => updateTable({ searchInput })} + searchPlaceholder="Search people…" + filterConfig={PEOPLE_FILTER_CONFIG} + isLoading={isLoading} + isError={isError} + errorTitle="Failed to load people" + errorDescription="There was a problem loading your contacts. Please try again." + onRetry={refetch} + enableRowSelection + rowSelection={table.rowSelection} + onRowSelectionChange={(rowSelection) => updateTable({ rowSelection })} + getRowId={(row) => row.id} + onRowClick={(person) => openDrawer("view", person)} + emptyTitle="No people yet" + emptyDescription="Add your first contact to get started." + toolbarActions={ + selectedCount > 0 ? ( + + ) : null + } + /> + + { + if (!open) closeDrawer(); + }} + mode={ui.drawer.mode} + onModeChange={(mode) => + setUi((current) => ({ ...current, drawer: { ...current.drawer, mode } })) + } + person={ui.drawer.person} + /> + + { + if (!open) setUi((current) => ({ ...current, deleteTarget: null })); + }} + title={confirmDialogCopy.title} + description={confirmDialogCopy.description} + confirmLabel={isDeletePending ? "Deleting…" : "Delete"} + variant="destructive" + isPending={isDeletePending} + onConfirm={handleDeleteConfirm} + /> +
+ ); +} diff --git a/apps/web/components/crm/people/people-drawer.tsx b/apps/web/components/crm/people/people-drawer.tsx new file mode 100644 index 0000000..36adfcd --- /dev/null +++ b/apps/web/components/crm/people/people-drawer.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { useMemo } from "react"; +import dayjs from "dayjs"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createPersonSchema, type CreatePerson } from "@workspace/validators/schemas/crm"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@workspace/ui/components/ui/form"; +import { Input } from "@workspace/ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/ui/select"; +import { Separator } from "@workspace/ui/components/ui/separator"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { cn } from "@workspace/ui/lib/utils"; +import { EntitySheet, type EntitySheetMode } from "@/components/shared/entity-sheet"; +import { CrmViewField, CrmViewSection } from "@/components/crm/crm-view"; +import { useCreatePerson, useUpdatePerson, useDeletePerson } from "@/hooks/queries/use-people"; +import { useOrganizations } from "@/hooks/queries/use-orgs"; +import { useActiveWorkspace } from "@/hooks/queries/use-workspace"; +import type { Person } from "@/types/crm"; +import type { WorkspaceMember } from "@/types/workspace-settings"; +import { PERSON_STATUS_OPTIONS } from "@/components/crm/crm-options"; + +// ─── View content ───────────────────────────────────────────────────────────── + +function PersonViewContent({ person }: { person: Person }) { + const statusConfig = PERSON_STATUS_OPTIONS.find((option) => option.value === person.status); + const orgName = person.orgName ?? person.org?.name; + const ownerName = person.ownerName ?? person.owner?.name; + + return ( +
+ + + {person.name} + + + {person.email ? ( + + {person.email} + + ) : ( + Not provided + )} + + + {person.phone ? ( + + {person.phone} + + ) : ( + Not provided + )} + + {person.linkedinUrl && ( + + + {person.linkedinUrl} + + + )} + + + + + + + {person.jobTitle ?? Not set} + + + {statusConfig ? ( + + {statusConfig.label} + + ) : null} + + + {person.source} + + + + {person.lastContactedAt ? dayjs(person.lastContactedAt).format("MMMM D, YYYY") : "—"} + + + + + + + + + {orgName ?? Not assigned} + + + {ownerName ?? Not assigned} + + + +
+
+ + + {dayjs(person.createdAt).format("MMMM D, YYYY")} + + + + + {dayjs(person.updatedAt).format("MMMM D, YYYY")} + + +
+
+
+ ); +} + +// ─── Form content ───────────────────────────────────────────────────────────── + +function PersonForm({ + form, + isPending, +}: { + form: ReturnType>; + isPending: boolean; +}) { + const { data: orgsData } = useOrganizations({ pageSize: 100 }); + const { data: workspace } = useActiveWorkspace(); + + const orgs = orgsData?.orgs ?? []; + const members = (workspace?.members ?? []) as Pick[]; + + return ( +
+
+ {/* Name */} + ( + + + Name * + + + + + + + )} + /> + + {/* Email + Phone */} +
+ ( + + Email + + + + + + )} + /> + ( + + Phone + + + + + + )} + /> +
+ + {/* Job Title */} + ( + + Job Title + + + + + + )} + /> + + {/* Status + Source */} +
+ ( + + Status + + + + )} + /> +
+ + {/* Organization */} + ( + + Organization + + + + )} + /> + + {/* Owner */} + ( + + Owner + + + + )} + /> + + {/* LinkedIn */} + ( + + LinkedIn URL + + + + + + )} + /> + + {/* Last Contacted */} + ( + + Last Contacted + + + field.onChange( + e.target.value ? dayjs(e.target.value, "YYYY-MM-DD").toDate() : undefined, + ) + } + disabled={isPending} + /> + + + + )} + /> +
+
+ ); +} + +// ─── Main drawer ────────────────────────────────────────────────────────────── + +interface PeopleDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: EntitySheetMode; + onModeChange: (mode: EntitySheetMode) => void; + person?: Person | null; + onDeleteSuccess?: () => void; +} + +export function PeopleDrawer({ + open, + onOpenChange, + mode, + onModeChange, + person, + onDeleteSuccess, +}: PeopleDrawerProps) { + const { mutate: createPerson, isPending: isCreating } = useCreatePerson(); + const { mutate: updatePerson, isPending: isUpdating } = useUpdatePerson(person?.id ?? ""); + const { mutate: deletePerson, isPending: isDeleting } = useDeletePerson(); + + const isSaving = isCreating || isUpdating || isDeleting; + + const formValues = useMemo( + () => ({ + name: mode === "create" ? "" : (person?.name ?? ""), + email: mode === "create" ? undefined : (person?.email ?? undefined), + phone: mode === "create" ? undefined : (person?.phone ?? undefined), + jobTitle: mode === "create" ? undefined : (person?.jobTitle ?? undefined), + linkedinUrl: mode === "create" ? undefined : (person?.linkedinUrl ?? undefined), + status: mode === "create" ? "lead" : (person?.status ?? "lead"), + source: mode === "create" ? "manual" : (person?.source ?? "manual"), + orgId: mode === "create" ? null : (person?.orgId ?? null), + ownerId: mode === "create" ? null : (person?.ownerId ?? null), + lastContactedAt: + mode === "create" + ? undefined + : person?.lastContactedAt + ? dayjs(person.lastContactedAt).toDate() + : undefined, + }), + [mode, person], + ); + + const form = useForm({ + resolver: zodResolver(createPersonSchema), + values: formValues, + }); + + function onSubmit(values: CreatePerson) { + const payload: CreatePerson = { + ...values, + email: values.email || undefined, + phone: values.phone || undefined, + jobTitle: values.jobTitle || undefined, + linkedinUrl: values.linkedinUrl || undefined, + }; + + if (mode === "create") { + createPerson(payload, { onSuccess: () => onOpenChange(false) }); + } else { + updatePerson(payload, { onSuccess: () => onOpenChange(false) }); + } + } + + function handleDelete() { + if (!person) return; + deletePerson(person.id, { + onSuccess: () => { + onOpenChange(false); + onDeleteSuccess?.(); + }, + }); + } + + const title = + mode === "create" + ? "New Person" + : mode === "edit" + ? `Edit — ${person?.name ?? "Person"}` + : (person?.name ?? "Person"); + + const description = + mode === "create" + ? "Add a new person to your CRM." + : mode === "edit" + ? "Update the details for this person." + : undefined; + + return ( + onModeChange("edit") : undefined} + onSave={form.handleSubmit(onSubmit)} + onDelete={mode !== "create" && person ? handleDelete : undefined} + > + {mode === "view" && person ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/web/components/crm/people/people-filters.tsx b/apps/web/components/crm/people/people-filters.tsx new file mode 100644 index 0000000..bedec4a --- /dev/null +++ b/apps/web/components/crm/people/people-filters.tsx @@ -0,0 +1,17 @@ +import type { FilterConfig } from "@/components/shared/data-table"; +import { PERSON_SOURCE_OPTIONS, PERSON_STATUS_OPTIONS } from "@/components/crm/crm-options"; + +export const PEOPLE_FILTER_CONFIG: FilterConfig[] = [ + { + columnId: "status", + label: "Status", + allLabel: "All Statuses", + options: PERSON_STATUS_OPTIONS.map(({ label, value }) => ({ label, value })), + }, + { + columnId: "source", + label: "Source", + allLabel: "All Sources", + options: PERSON_SOURCE_OPTIONS.map(({ label, value }) => ({ label, value })), + }, +]; diff --git a/apps/web/components/reui/kanban.tsx b/apps/web/components/reui/kanban.tsx new file mode 100644 index 0000000..2545b28 --- /dev/null +++ b/apps/web/components/reui/kanban.tsx @@ -0,0 +1,744 @@ +// @ts-nocheck +"use client" + +import * as React from "react" +import { + createContext, + CSSProperties, + HTMLAttributes, + ReactNode, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useState, +} from "react" +import { + defaultDropAnimationSideEffects, + DndContext, + DragEndEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + DropAnimation, + KeyboardSensor, + MeasuringStrategy, + Modifiers, + MouseSensor, + TouchSensor, + UniqueIdentifier, + useSensor, + useSensors, + type DraggableAttributes, + type DraggableSyntheticListeners, +} from "@dnd-kit/core" +import { + arrayMove, + defaultAnimateLayoutChanges, + rectSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + type AnimateLayoutChanges, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { Slot } from "radix-ui" +import { createPortal } from "react-dom" + +import { cn } from "@workspace/ui/lib/utils" + +interface KanbanContextProps { + columns: Record + setColumns: (columns: Record) => void + getItemId: (item: T) => string + columnIds: string[] + activeId: UniqueIdentifier | null + setActiveId: (id: UniqueIdentifier | null) => void + findContainer: (id: UniqueIdentifier) => string | undefined + isColumn: (id: UniqueIdentifier) => boolean + modifiers?: Modifiers +} + +const KanbanContext = createContext>({ + columns: {}, + setColumns: () => {}, + getItemId: () => "", + columnIds: [], + activeId: null, + setActiveId: () => {}, + findContainer: () => undefined, + isColumn: () => false, + modifiers: undefined, +}) + +const ColumnContext = createContext<{ + attributes: DraggableAttributes + listeners: DraggableSyntheticListeners | undefined + isDragging?: boolean + disabled?: boolean +}>({ + attributes: {} as DraggableAttributes, + listeners: undefined, + isDragging: false, + disabled: false, +}) + +const ItemContext = createContext<{ + listeners: DraggableSyntheticListeners | undefined + isDragging?: boolean + disabled?: boolean +}>({ + listeners: undefined, + isDragging: false, + disabled: false, +}) + +const IsOverlayContext = createContext(false) + +const animateLayoutChanges: AnimateLayoutChanges = (args) => + defaultAnimateLayoutChanges({ ...args, wasDragging: true }) + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + +export interface KanbanMoveEvent { + event: DragEndEvent + activeContainer: string + activeIndex: number + overContainer: string + overIndex: number +} + +export interface KanbanRootProps extends HTMLAttributes { + value: Record + onValueChange: (value: Record) => void + getItemValue: (item: T) => string + children: ReactNode + onMove?: (event: KanbanMoveEvent) => void + asChild?: boolean + modifiers?: Modifiers +} + +function Kanban({ + value, + onValueChange, + getItemValue, + children, + className, + asChild = false, + onMove, + modifiers, + ...props +}: KanbanRootProps) { + const columns = value + const setColumns = onValueChange + const [activeId, setActiveId] = useState(null) + + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const columnIds = useMemo(() => Object.keys(columns), [columns]) + + const isColumn = useCallback( + (id: UniqueIdentifier) => columnIds.includes(id as string), + [columnIds] + ) + + const findContainer = useCallback( + (id: UniqueIdentifier) => { + if (isColumn(id)) return id as string + return columnIds.find((key) => + columns[key].some((item) => getItemValue(item) === id) + ) + }, + [columns, columnIds, getItemValue, isColumn] + ) + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(event.active.id) + }, []) + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + if (onMove) { + return + } + + const { active, over } = event + if (!over) return + + if (isColumn(active.id)) return + + const activeContainer = findContainer(active.id) + const overContainer = findContainer(over.id) + + if (!activeContainer || !overContainer) { + return + } + + if (activeContainer !== overContainer) { + const activeItems = columns[activeContainer] + const overItems = columns[overContainer] + + const activeIndex = activeItems.findIndex( + (item: T) => getItemValue(item) === active.id + ) + let overIndex = overItems.findIndex( + (item: T) => getItemValue(item) === over.id + ) + + // If dropping on the column itself, not an item + if (isColumn(over.id)) { + overIndex = overItems.length + } + + const newActiveItems = [...activeItems] + const newOverItems = [...overItems] + const [movedItem] = newActiveItems.splice(activeIndex, 1) + newOverItems.splice(overIndex, 0, movedItem) + + setColumns({ + ...columns, + [activeContainer]: newActiveItems, + [overContainer]: newOverItems, + }) + } else { + const container = activeContainer + const activeIndex = columns[container].findIndex( + (item: T) => getItemValue(item) === active.id + ) + const overIndex = columns[container].findIndex( + (item: T) => getItemValue(item) === over.id + ) + + if (activeIndex !== overIndex) { + setColumns({ + ...columns, + [container]: arrayMove(columns[container], activeIndex, overIndex), + }) + } + } + }, + [findContainer, getItemValue, isColumn, setColumns, columns, onMove] + ) + + const handleDragCancel = useCallback(() => { + setActiveId(null) + }, []) + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + setActiveId(null) + + if (!over) return + + // Handle item move callback + if (onMove && !isColumn(active.id)) { + const activeContainer = findContainer(active.id) + const overContainer = findContainer(over.id) + + if (activeContainer && overContainer) { + const activeIndex = columns[activeContainer].findIndex( + (item: T) => getItemValue(item) === active.id + ) + const overIndex = isColumn(over.id) + ? columns[overContainer].length + : columns[overContainer].findIndex( + (item: T) => getItemValue(item) === over.id + ) + + onMove({ + event, + activeContainer, + activeIndex, + overContainer, + overIndex, + }) + } + return + } + + // Handle column reordering + if (isColumn(active.id) && isColumn(over.id)) { + const activeIndex = columnIds.indexOf(active.id as string) + const overIndex = columnIds.indexOf(over.id as string) + if (activeIndex !== overIndex) { + const newOrder = arrayMove( + Object.keys(columns), + activeIndex, + overIndex + ) + const newColumns: Record = {} + newOrder.forEach((key) => { + newColumns[key] = columns[key] + }) + setColumns(newColumns) + } + return + } + + const activeContainer = findContainer(active.id) + const overContainer = findContainer(over.id) + + // Handle item reordering within the same column + if ( + activeContainer && + overContainer && + activeContainer === overContainer + ) { + const container = activeContainer + const activeIndex = columns[container].findIndex( + (item: T) => getItemValue(item) === active.id + ) + const overIndex = columns[container].findIndex( + (item: T) => getItemValue(item) === over.id + ) + + if (activeIndex !== overIndex) { + setColumns({ + ...columns, + [container]: arrayMove(columns[container], activeIndex, overIndex), + }) + } + } + }, + [ + columnIds, + columns, + findContainer, + getItemValue, + isColumn, + setColumns, + onMove, + ] + ) + + const contextValue = useMemo( + () => ({ + columns, + setColumns, + getItemId: getItemValue, + columnIds, + activeId, + setActiveId, + findContainer, + isColumn, + modifiers, + }), + [ + columns, + setColumns, + getItemValue, + columnIds, + activeId, + findContainer, + isColumn, + modifiers, + ] + ) + + const Comp = asChild ? Slot.Root : "div" + + return ( + + + + {children} + + + + ) +} + +export interface KanbanBoardProps extends HTMLAttributes { + asChild?: boolean +} + +function KanbanBoard({ + className, + asChild = false, + children, + ...props +}: KanbanBoardProps) { + const { columnIds } = useContext(KanbanContext) + const Comp = asChild ? Slot.Root : "div" + + return ( + + + {children} + + + ) +} + +export interface KanbanColumnProps extends HTMLAttributes { + value: string + disabled?: boolean + asChild?: boolean +} + +function KanbanColumn({ + value, + className, + asChild = false, + disabled, + children, + ...props +}: KanbanColumnProps) { + const isOverlay = useContext(IsOverlayContext) + + const { + setNodeRef, + transform, + transition, + attributes, + listeners, + isDragging: isSortableDragging, + } = useSortable({ + id: value, + disabled: disabled || isOverlay, + animateLayoutChanges, + }) + + const { activeId, isColumn } = useContext(KanbanContext) + const isColumnDragging = activeId ? isColumn(activeId) : false + + const style = { + transition, + transform: CSS.Transform.toString(transform), + } as CSSProperties + + const Comp = asChild ? Slot.Root : "div" + + if (isOverlay) { + return ( + + + {children} + + + ) + } + + return ( + + + {children} + + + ) +} + +export interface KanbanColumnHandleProps extends HTMLAttributes { + cursor?: boolean + asChild?: boolean +} + +function KanbanColumnHandle({ + className, + asChild = false, + cursor = true, + children, + ...props +}: KanbanColumnHandleProps) { + const { attributes, listeners, isDragging, disabled } = + useContext(ColumnContext) + + const Comp = asChild ? Slot.Root : "div" + + return ( + + {children} + + ) +} + +export interface KanbanItemProps extends HTMLAttributes { + value: string + disabled?: boolean + asChild?: boolean +} + +function KanbanItem({ + value, + className, + asChild = false, + disabled, + children, + ...props +}: KanbanItemProps) { + const isOverlay = useContext(IsOverlayContext) + + const { + setNodeRef, + transform, + transition, + attributes, + listeners, + isDragging: isSortableDragging, + } = useSortable({ + id: value, + disabled: disabled || isOverlay, + animateLayoutChanges, + }) + + const { activeId, isColumn } = useContext(KanbanContext) + const isItemDragging = activeId ? !isColumn(activeId) : false + + const style = { + transition, + transform: CSS.Transform.toString(transform), + } as CSSProperties + + const Comp = asChild ? Slot.Root : "div" + + if (isOverlay) { + return ( + + + {children} + + + ) + } + + return ( + + + {children} + + + ) +} + +export interface KanbanItemHandleProps extends HTMLAttributes { + cursor?: boolean + asChild?: boolean +} + +function KanbanItemHandle({ + className, + asChild = false, + cursor = true, + children, + ...props +}: KanbanItemHandleProps) { + const { listeners, isDragging, disabled } = useContext(ItemContext) + + const Comp = asChild ? Slot.Root : "div" + + return ( + + {children} + + ) +} + +export interface KanbanColumnContentProps extends HTMLAttributes { + value: string + asChild?: boolean +} + +function KanbanColumnContent({ + value, + className, + asChild = false, + children, + ...props +}: KanbanColumnContentProps) { + const { columns, getItemId } = useContext(KanbanContext) + + const itemIds = useMemo( + () => columns[value].map(getItemId), + [columns, getItemId, value] + ) + + const Comp = asChild ? Slot.Root : "div" + + return ( + + + {children} + + + ) +} + +export interface KanbanOverlayProps extends Omit< + React.ComponentProps, + "children" +> { + children?: + | ReactNode + | ((params: { + value: UniqueIdentifier + variant: "column" | "item" + }) => ReactNode) +} + +function KanbanOverlay({ children, className, ...props }: KanbanOverlayProps) { + const { activeId, isColumn, modifiers } = useContext(KanbanContext) + const [mounted, setMounted] = useState(false) + + useLayoutEffect(() => setMounted(true), []) + + const variant = activeId ? (isColumn(activeId) ? "column" : "item") : "item" + + const content = + activeId && children + ? typeof children === "function" + ? children({ value: activeId, variant }) + : children + : null + + if (!mounted) return null + + return createPortal( + + + {content} + + , + document.body + ) +} + +export { + Kanban, + KanbanBoard, + KanbanColumn, + KanbanColumnHandle, + KanbanItem, + KanbanItemHandle, + KanbanColumnContent, + KanbanOverlay, +} \ No newline at end of file diff --git a/apps/web/components/shared/data-table.tsx b/apps/web/components/shared/data-table.tsx new file mode 100644 index 0000000..d64a26d --- /dev/null +++ b/apps/web/components/shared/data-table.tsx @@ -0,0 +1,524 @@ +"use client"; + +import * as React from "react"; +import { + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef, + type ColumnFiltersState, + type PaginationState, + type Row, + type RowSelectionState, + type SortingState, + type VisibilityState, +} from "@tanstack/react-table"; +import { + ChevronDown, + ChevronsLeft, + ChevronsRight, + Inbox, + MoreHorizontal, + Search, +} from "lucide-react"; +import { Button } from "@workspace/ui/components/ui/button"; +import { Checkbox } from "@workspace/ui/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@workspace/ui/components/ui/dropdown-menu"; +import { Input } from "@workspace/ui/components/ui/input"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, +} from "@workspace/ui/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/ui/select"; +import { Skeleton } from "@workspace/ui/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@workspace/ui/components/ui/table"; +import { cn } from "@workspace/ui/lib/utils"; +import { EmptyState } from "@/components/shared/empty-state"; +import { ErrorState } from "@/components/shared/error-state"; + +export interface DataTableFilterOption { + label: string; + value: string; +} + +export interface FilterConfig { + columnId: string; + label: string; + options: DataTableFilterOption[]; + allLabel?: string; +} + +export interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + pageCount: number; + pageIndex: number; + pageSize: number; + onPaginationChange: (pagination: PaginationState) => void; + sorting?: SortingState; + onSortingChange?: (sorting: SortingState) => void; + columnFilters?: ColumnFiltersState; + onColumnFiltersChange?: (filters: ColumnFiltersState) => void; + searchPlaceholder?: string; + searchValue?: string; + onSearchChange?: (search: string) => void; + filterConfig?: FilterConfig[]; + isLoading?: boolean; + isError?: boolean; + errorTitle?: string; + errorDescription?: string; + onRetry?: () => void; + enableRowSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (rowSelection: RowSelectionState) => void; + getRowId?: (originalRow: TData, index: number, parent?: Row) => string; + onRowClick?: (row: TData) => void; + emptyTitle?: string; + emptyDescription?: string; + toolbarActions?: React.ReactNode; + className?: string; +} + +export function DataTable({ + columns, // the column definitions, memoized by the parent component + data, // the current page of data to display, memoized by the parent component + pageCount, // total number of pages, calculated by the parent component based on the total row count and page size + pageIndex, // the current page index (0-based), controlled by the parent component + pageSize, // the number of rows per page, controlled by the parent component + onPaginationChange, // callback to update the pagination state in the parent component + sorting = [], // the current sorting state, controlled by the parent component + onSortingChange, // callback to update the sorting state in the parent component + columnFilters = [], // the current column filters state, controlled by the parent component + onColumnFiltersChange, // callback to update the column filters state in the parent component + searchPlaceholder = "Search...", + searchValue = "", // the current global search value, controlled by the parent component + onSearchChange, // callback to update the global search value in the parent component + filterConfig = [], // configuration for the filter dropdowns, memoized by the parent component + isLoading = false, + isError = false, + errorTitle, + errorDescription, + onRetry, + enableRowSelection = false, + rowSelection = {}, // the current row selection state, controlled by the parent component + onRowSelectionChange, + getRowId, // optional function to generate unique row IDs, useful when your data doesn't have a stable ID field + onRowClick, + emptyTitle = "No results found", + emptyDescription = "Try adjusting your filters or search to find what you're looking for.", + toolbarActions, // optional additional actions to show in the toolbar, memoized by the parent component + className, +}: DataTableProps) { + // The only local state — column visibility doesn't affect server queries + const [columnVisibility, setColumnVisibility] = React.useState({}); + const pagination = { pageIndex, pageSize }; + const currentPage = pageCount === 0 ? 0 : pageIndex + 1; + const canGoToPreviousPage = !isLoading && pageIndex > 0; + const canGoToNextPage = !isLoading && pageIndex < pageCount - 1 && pageCount > 0; + + // adds a selection column to the left of the table when row selection is enabled + const selectionColumn = React.useMemo>( + () => ({ + id: "__select", + enableSorting: false, + enableHiding: false, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!checked)} + aria-label="Select all rows" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!checked)} + aria-label="Select row" + onClick={(e) => e.stopPropagation()} + /> + ), + size: 36, + }), + [], + ); + + // when row selection is enabled, add the selection column to the beginning of the columns array + const resolvedColumns = React.useMemo( + () => (enableRowSelection ? [selectionColumn, ...columns] : columns), + [columns, enableRowSelection, selectionColumn], + ); + + // useReactTable manages the state and logic of the table, while we control the server interactions via the on*Change handlers + const table = useReactTable({ + data, + columns: resolvedColumns, + pageCount, + manualPagination: true, + manualSorting: true, + manualFiltering: true, + enableRowSelection, + state: { + pagination, + sorting, + columnFilters, + rowSelection, + columnVisibility, + }, + onPaginationChange: (updater) => { + const next = typeof updater === "function" ? updater(pagination) : updater; + onPaginationChange(next); + }, + onSortingChange: (updater) => { + const next = typeof updater === "function" ? updater(sorting) : updater; + onSortingChange?.(next); + }, + onColumnFiltersChange: (updater) => { + const next = typeof updater === "function" ? updater(columnFilters) : updater; + onColumnFiltersChange?.(next); + }, + onRowSelectionChange: (updater) => { + const next = typeof updater === "function" ? updater(rowSelection) : updater; + onRowSelectionChange?.(next); + }, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getRowId, + }); + + const selectedCount = table.getSelectedRowModel().rows.length; + const visibleColumnsCount = table.getVisibleLeafColumns().length || resolvedColumns.length || 1; + const hasRows = table.getRowModel().rows.length > 0; + const pageNumbers = getVisiblePageNumbers(pageIndex, pageCount); + + // helper to get the current filter value for a column, used to set the value of the filter dropdowns + function getFilterValue(columnId: string) { + const filter = columnFilters.find((f) => f.id === columnId); + return typeof filter?.value === "string" ? filter.value : ""; + } + + function updateFilter(columnId: string, value: string) { + const next = columnFilters.filter((f) => f.id !== columnId); + if (value !== "__all") next.push({ id: columnId, value }); + onColumnFiltersChange?.(next); + onPaginationChange({ ...pagination, pageIndex: 0 }); + } + + // when the search input changes, update the search state and reset to the first page + function handleSearchChange(e: React.ChangeEvent) { + onSearchChange?.(e.target.value); + onPaginationChange({ ...pagination, pageIndex: 0 }); + } + + function handleRowClick(row: Row, e: React.MouseEvent) { + if (!onRowClick) return; + const target = e.target as HTMLElement; + if ( + target.closest("button") || + target.closest("[role='checkbox']") || + target.closest("a") || + target.closest("[data-row-action='true']") + ) { + return; + } + onRowClick(row.original); + } + + return ( +
+ {/* Toolbar */} +
+
+ {onSearchChange && ( +
+ + +
+ )} + + {filterConfig.map((filter) => ( + + ))} + + {selectedCount > 0 && ( + + {selectedCount} row{selectedCount === 1 ? "" : "s"} selected + + )} +
+ +
+ {toolbarActions} + + + + + + + {table + .getAllColumns() + .filter((col) => col.getCanHide()) + .map((col) => ( + col.toggleVisibility(!!checked)} + onSelect={(e) => e.preventDefault()} + className="capitalize" + > + {formatColumnLabel(col.id)} + + ))} + + +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort(); + const sortDir = header.column.getIsSorted(); + + return ( + + {header.isPlaceholder ? null : canSort ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + + ); + })} + + ))} + + + + {isLoading ? ( + Array.from({ length: Math.min(pageSize, 8) || 5 }).map((_, i) => ( + + {Array.from({ length: visibleColumnsCount }).map((__, j) => ( + + + + ))} + + )) + ) : isError ? ( + + + + + + ) : hasRows ? ( + table.getRowModel().rows.map((row) => ( + handleRowClick(row, e)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + + + + )} + +
+
+ + {/* Pagination */} +
+

+ Page {currentPage} of {pageCount} +

+ + + + + + + + + + + + {pageNumbers.map((num, i) => + num === "ellipsis" ? ( + + + + ) : ( + + { + e.preventDefault(); + onPaginationChange({ ...pagination, pageIndex: num - 1 }); + }} + > + {num} + + + ), + )} + + + + + + + + + + +
+
+ ); +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function formatColumnLabel(id: string) { + return id + .replace(/^_+/, "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[-_]/g, " ") + .trim(); +} + +function getVisiblePageNumbers(pageIndex: number, pageCount: number): Array { + if (pageCount <= 0) return []; + if (pageCount <= 7) return Array.from({ length: pageCount }, (_, i) => i + 1); + + const current = pageIndex + 1; + const pages: Array = [1]; + + if (current > 3) pages.push("ellipsis"); + + const start = Math.max(2, current - 1); + const end = Math.min(pageCount - 1, current + 1); + for (let p = start; p <= end; p++) pages.push(p); + + if (current < pageCount - 2) pages.push("ellipsis"); + + pages.push(pageCount); + return pages; +} diff --git a/apps/web/components/shared/entity-sheet.tsx b/apps/web/components/shared/entity-sheet.tsx new file mode 100644 index 0000000..0b7986a --- /dev/null +++ b/apps/web/components/shared/entity-sheet.tsx @@ -0,0 +1,130 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@workspace/ui/components/ui/sheet"; +import { Button } from "@workspace/ui/components/ui/button"; +import { Separator } from "@workspace/ui/components/ui/separator"; +import { LoadingState } from "@/components/shared/loading-state"; + +type EntitySheetMode = "view" | "edit" | "create"; + +interface EntitySheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + mode: EntitySheetMode; + isLoading?: boolean; + isSaving?: boolean; + children: ReactNode; + onEdit?: () => void; + onSave?: () => void; + onDelete?: () => void; + saveLabel?: string; + deleteLabel?: string; + className?: string; +} + +export function EntitySheet({ + open, + onOpenChange, + title, + description, + mode, + isLoading = false, + isSaving = false, + children, + onEdit, + onSave, + onDelete, + saveLabel, + deleteLabel = "Delete", + className, +}: EntitySheetProps) { + const computedSaveLabel = saveLabel ?? (mode === "create" ? "Create" : "Save"); + const isBlocked = isLoading || isSaving; + const isViewMode = mode === "view"; + const isEditableMode = mode === "edit" || mode === "create"; + + return ( + + + +
+
+ {title} + {description ? {description} : null} +
+ + {isViewMode && onEdit ? ( + + ) : null} +
+
+ + + +
+ {isLoading ? ( + + ) : ( + children + )} +
+ + {isEditableMode || onDelete ? ( + <> + + +
+ {onDelete ? ( + + ) : null} +
+ + {isEditableMode ? ( +
+ + +
+ ) : null} +
+ + ) : null} +
+
+ ); +} + +export type { EntitySheetMode, EntitySheetProps }; diff --git a/apps/web/hooks/queries/use-deals.ts b/apps/web/hooks/queries/use-deals.ts new file mode 100644 index 0000000..3f343b5 --- /dev/null +++ b/apps/web/hooks/queries/use-deals.ts @@ -0,0 +1,80 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import type { CreateDeal, UpdateDeal } from "@workspace/validators/schemas/crm"; +import { + createDeal, + deleteDeal, + getDeal, + listDeals, + updateDeal, +} from "@/services/crm/deals.service"; +import { QUERY_KEYS } from "@/lib/query-keys"; +import { useAuthSession } from "@/hooks/queries/use-auth"; +import type { DealsListParams } from "@/types/crm"; + +export function useDeals(params: DealsListParams = {}) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.DEALS, QUERY_KEYS.DEALS_LIST, params], + queryFn: () => listDeals(params), + enabled: !!session?.user, + placeholderData: (prev) => prev, + }); +} + +export function useDeal(dealId?: string | null) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.DEALS, QUERY_KEYS.DEALS_DETAIL, dealId ?? ""], + queryFn: () => getDeal(dealId!), + enabled: !!session?.user && !!dealId, + }); +} + +export function useCreateDeal() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateDeal) => createDeal(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.DEALS] }); + toast.success("Deal created", { + description: "The deal has been added successfully.", + }); + }, + }); +} + +export function useUpdateDeal() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ dealId, input }: { dealId: string; input: UpdateDeal }) => + updateDeal(dealId, input), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.DEALS] }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.DEALS, QUERY_KEYS.DEALS_DETAIL, variables.dealId], + }); + toast.success("Deal updated", { + description: "The deal has been updated successfully.", + }); + }, + }); +} + +export function useDeleteDeal() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (dealId: string) => deleteDeal(dealId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.DEALS] }); + toast.success("Deal deleted", { + description: "The deal has been removed successfully.", + }); + }, + }); +} diff --git a/apps/web/hooks/queries/use-orgs.ts b/apps/web/hooks/queries/use-orgs.ts new file mode 100644 index 0000000..e5c1654 --- /dev/null +++ b/apps/web/hooks/queries/use-orgs.ts @@ -0,0 +1,92 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { QUERY_KEYS } from "@/lib/query-keys"; +import { useAuthSession } from "@/hooks/queries/use-auth"; +import { + bulkDeleteOrganizations, + createOrganization, + deleteOrganization, + getOrganization, + listOrganizations, + updateOrganization, +} from "@/services/crm/orgs.service"; +import type { + BulkDeleteInput, + CreateOrganizationInput, + OrganizationsListParams, + UpdateOrganizationInput, +} from "@/types/crm"; + +export function useOrganizations(params: OrganizationsListParams = {}) { + const { data: session } = useAuthSession(); + return useQuery({ + queryKey: [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_LIST, params], + queryFn: () => listOrganizations(params), + enabled: !!session?.user, + placeholderData: (previousData) => previousData, + }); +} + +export function useOrg(orgId?: string | null) { + const { data: session } = useAuthSession(); + return useQuery({ + queryKey: [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_DETAIL, orgId ?? ""], + queryFn: () => getOrganization(orgId!), + enabled: !!session?.user && !!orgId, + }); +} + +export function useCreateOrg() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateOrganizationInput) => createOrganization(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + toast.success("Organization created", { + description: "The organization has been created successfully.", + }); + }, + }); +} + +export function useUpdateOrg(orgId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: UpdateOrganizationInput) => updateOrganization(orgId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + toast.success("Organization updated", { + description: "The organization has been updated successfully.", + }); + }, + }); +} + +export function useDeleteOrg() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (orgId: string) => deleteOrganization(orgId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + toast.success("Organization deleted", { + description: "The organization has been deleted successfully.", + }); + }, + }); +} + +export function useBulkDeleteOrgs() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: BulkDeleteInput) => bulkDeleteOrganizations(input), + onSuccess: (deletedCount) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + toast.success("Organizations deleted", { + description: + deletedCount === 1 + ? "1 organization was deleted successfully." + : `${deletedCount} organizations were deleted successfully.`, + }); + }, + }); +} diff --git a/apps/web/hooks/queries/use-people.ts b/apps/web/hooks/queries/use-people.ts new file mode 100644 index 0000000..0353d11 --- /dev/null +++ b/apps/web/hooks/queries/use-people.ts @@ -0,0 +1,102 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { QUERY_KEYS } from "@/lib/query-keys"; +import { useAuthSession } from "@/hooks/queries/use-auth"; +import { + bulkDeletePeople, + createPerson, + deletePerson, + getPerson, + listPeople, + updatePerson, +} from "@/services/crm/people.service"; +import type { + BulkDeleteInput, + CreatePersonInput, + PeopleListParams, + UpdatePersonInput, +} from "@/types/crm"; + +export function usePeople(params: PeopleListParams = {}) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.PEOPLE, QUERY_KEYS.PEOPLE_LIST, params], + queryFn: () => listPeople(params), + enabled: !!session?.user, + placeholderData: (previousData) => previousData, + }); +} + +export function usePerson(personId?: string | null) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: [QUERY_KEYS.PEOPLE, QUERY_KEYS.PEOPLE_DETAIL, personId ?? ""], + queryFn: () => getPerson(personId!), + enabled: !!session?.user && !!personId, + }); +} + +export function useCreatePerson() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreatePersonInput) => createPerson(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + toast.success("Person created", { + description: "The person has been added to your CRM.", + }); + }, + }); +} + +export function useUpdatePerson(personId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpdatePersonInput) => updatePerson(personId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + toast.success("Person updated", { + description: "The person has been updated.", + }); + }, + }); +} + +export function useDeletePerson() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (personId: string) => deletePerson(personId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + toast.success("Person deleted", { + description: "The person has been removed from your CRM.", + }); + }, + }); +} + +export function useBulkDeletePeople() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: BulkDeleteInput) => bulkDeletePeople(input), + onSuccess: (deletedCount) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + toast.success("People deleted", { + description: + deletedCount === 1 + ? "1 person has been removed from your CRM." + : `${deletedCount} people have been removed from your CRM.`, + }); + }, + }); +} diff --git a/apps/web/hooks/queries/use-workspace.ts b/apps/web/hooks/queries/use-workspace.ts index 6be4c2e..357c22c 100644 --- a/apps/web/hooks/queries/use-workspace.ts +++ b/apps/web/hooks/queries/use-workspace.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { acceptInvitation, @@ -74,6 +74,7 @@ export function useCreateWorkspace() { return useMutation({ mutationFn: (input: CreateWorkspace) => createWorkspace(input), onSuccess: () => { + clearCrmQueries(qc); qc.invalidateQueries({ queryKey: [QUERY_KEYS.AUTH] }); qc.invalidateQueries({ queryKey: [QUERY_KEYS.WORKSPACES] }); toast.success("Workspace created", { description: "Your new workspace is ready." }); @@ -101,6 +102,7 @@ export function useDeleteWorkspace() { return useMutation({ mutationFn: (organizationId: string) => deleteWorkspace(organizationId), onSuccess: () => { + clearCrmQueries(qc); qc.invalidateQueries({ queryKey: [QUERY_KEYS.AUTH] }); qc.invalidateQueries({ queryKey: [QUERY_KEYS.WORKSPACES] }); toast.success("Workspace deleted", { @@ -118,6 +120,7 @@ export function useSetActiveWorkspace(opts?: { showToast?: boolean }) { mutationFn: (opts: { organizationId?: string | null; organizationSlug?: string }) => setActiveWorkspace(opts), onSuccess: () => { + clearCrmQueries(qc); qc.invalidateQueries({ queryKey: [QUERY_KEYS.AUTH] }); qc.invalidateQueries({ queryKey: [QUERY_KEYS.WORKSPACES] }); @@ -159,6 +162,7 @@ export function useAcceptInvitation() { return useMutation({ mutationFn: (invitationId: string) => acceptInvitation(invitationId), onSuccess: () => { + clearCrmQueries(qc); qc.invalidateQueries({ queryKey: [QUERY_KEYS.AUTH] }); qc.invalidateQueries({ queryKey: [QUERY_KEYS.WORKSPACES] }); toast.success("Invitation accepted", { @@ -217,6 +221,7 @@ export function useLeaveWorkspace() { return useMutation({ mutationFn: (organizationId: string) => leaveWorkspace(organizationId), onSuccess: () => { + clearCrmQueries(qc); qc.invalidateQueries({ queryKey: [QUERY_KEYS.AUTH] }); qc.invalidateQueries({ queryKey: [QUERY_KEYS.WORKSPACES] }); toast.success("Left workspace", { @@ -273,3 +278,9 @@ export function useRestoreActiveWorkspace(opts: { setActiveWorkspace, ]); } + +function clearCrmQueries(queryClient: QueryClient) { + queryClient.removeQueries({ queryKey: [QUERY_KEYS.PEOPLE] }); + queryClient.removeQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.removeQueries({ queryKey: [QUERY_KEYS.DEALS] }); +} diff --git a/apps/web/lib/query-keys.ts b/apps/web/lib/query-keys.ts index 4e6adac..8603e84 100644 --- a/apps/web/lib/query-keys.ts +++ b/apps/web/lib/query-keys.ts @@ -6,4 +6,15 @@ export const QUERY_KEYS = { ACTIVE_WORKSPACE: "active-workspace", WORKSPACE_INVITATIONS: "workspace-invitations", WORKSPACE_INVITATION: "workspace-invitation", + + // CRM + PEOPLE: "people", + PEOPLE_LIST: "people-list", + PEOPLE_DETAIL: "people-detail", + ORGS: "orgs", + ORGS_LIST: "orgs-list", + ORGS_DETAIL: "orgs-detail", + DEALS: "deals", + DEALS_LIST: "deals-list", + DEALS_DETAIL: "deals-detail", } as const; diff --git a/apps/web/package.json b/apps/web/package.json index 7b1d220..ab715e0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "class-variance-authority": "catalog:", "clsx": "catalog:", "cmdk": "catalog:", + "dayjs": "catalog:", "lucide-react": "catalog:", "next": "catalog:", "next-themes": "catalog:", @@ -36,6 +37,7 @@ "sonner": "catalog:", "tailwind-merge": "catalog:", "tw-animate-css": "^1.4.0", + "usehooks-ts": "^3.1.1", "zustand": "catalog:" }, "devDependencies": { diff --git a/apps/web/services/crm/deals.service.ts b/apps/web/services/crm/deals.service.ts new file mode 100644 index 0000000..d4c1582 --- /dev/null +++ b/apps/web/services/crm/deals.service.ts @@ -0,0 +1,41 @@ +import { apiClient } from "@/lib/axios-client"; +import type { CreateDeal, ListDealsQuery, UpdateDeal } from "@workspace/validators/schemas/crm"; +import type { ApiSuccessResponse } from "@workspace/validators/types/auth"; +import type { Deal, DealsListResponse } from "@/types/crm"; +import { cleanQueryParams } from "./utils"; + +type DealResponse = ApiSuccessResponse<{ deal: Deal }>; +type DealsResponse = ApiSuccessResponse; + +export async function listDeals(params: Partial = {}) { + const response = await apiClient.get("/deals", { + params: cleanQueryParams(params), + }); + + const { data } = response.data; + return data; +} + +export async function getDeal(id: string) { + const response = await apiClient.get(`/deals/${id}`); + const { data } = response.data; + return data.deal; +} + +export async function createDeal(input: CreateDeal) { + const response = await apiClient.post("/deals", input); + const { data } = response.data; + return data.deal; +} + +export async function updateDeal(id: string, input: UpdateDeal) { + const response = await apiClient.patch(`/deals/${id}`, input); + const { data } = response.data; + return data.deal; +} + +export async function deleteDeal(id: string) { + const response = await apiClient.delete(`/deals/${id}`); + const { data } = response.data; + return data.deal; +} diff --git a/apps/web/services/crm/orgs.service.ts b/apps/web/services/crm/orgs.service.ts new file mode 100644 index 0000000..8e76443 --- /dev/null +++ b/apps/web/services/crm/orgs.service.ts @@ -0,0 +1,57 @@ +import { apiClient } from "@/lib/axios-client"; +import { cleanQueryParams } from "@/services/crm/utils"; +import type { + BulkDeleteInput, + CreateOrg, + ListOrgsQuery, + OrgParams, + UpdateOrg, +} from "@workspace/validators/schemas/crm"; +import type { ApiSuccessResponse } from "@workspace/validators/types/auth"; +import type { BulkDeleteResponse, Organization, OrganizationsListResponse } from "@/types/crm"; + +type OrganizationResponse = ApiSuccessResponse<{ + org: Organization; +}>; + +export async function listOrganizations(params: Partial = {}) { + const response = await apiClient.get>("/orgs", { + params: cleanQueryParams(params), + }); + + const { data } = response.data; + return data; +} + +export async function getOrganization(id: OrgParams["id"]) { + const response = await apiClient.get(`/orgs/${id}`); + const { data } = response.data; + return data.org; +} + +export async function createOrganization(input: CreateOrg) { + const response = await apiClient.post("/orgs", input); + const { data } = response.data; + return data.org; +} + +export async function updateOrganization(id: OrgParams["id"], input: UpdateOrg) { + const response = await apiClient.patch(`/orgs/${id}`, input); + const { data } = response.data; + return data.org; +} + +export async function deleteOrganization(id: OrgParams["id"]) { + const response = await apiClient.delete(`/orgs/${id}`); + const { data } = response.data; + return data.org; +} + +export async function bulkDeleteOrganizations(input: BulkDeleteInput) { + const response = await apiClient.delete>("/orgs/bulk", { + data: input, + }); + + const { data } = response.data; + return data.deleted; +} diff --git a/apps/web/services/crm/people.service.ts b/apps/web/services/crm/people.service.ts new file mode 100644 index 0000000..9b21fba --- /dev/null +++ b/apps/web/services/crm/people.service.ts @@ -0,0 +1,57 @@ +import { apiClient } from "@/lib/axios-client"; +import type { ApiSuccessResponse } from "@workspace/validators/types/auth"; +import type { + BulkDeleteInput, + CreatePerson, + ListPeopleQuery, + PersonParams, + UpdatePerson, +} from "@workspace/validators/schemas/crm"; +import type { Person, PeopleListResponse } from "@/types/crm"; +import { cleanQueryParams } from "./utils"; + +type PersonResponse = ApiSuccessResponse<{ person: Person }>; +type PeopleResponse = ApiSuccessResponse; +type BulkDeletePeopleResponse = ApiSuccessResponse<{ deleted: number }>; + +export async function listPeople(params: Partial = {}) { + const response = await apiClient.get("/people", { + params: cleanQueryParams(params), + }); + + const { data } = response.data; + return data; +} + +export async function getPerson(id: PersonParams["id"]) { + const response = await apiClient.get(`/people/${id}`); + const { data } = response.data; + return data.person; +} + +export async function createPerson(input: CreatePerson) { + const response = await apiClient.post("/people", input); + const { data } = response.data; + return data.person; +} + +export async function updatePerson(id: PersonParams["id"], input: UpdatePerson) { + const response = await apiClient.patch(`/people/${id}`, input); + const { data } = response.data; + return data.person; +} + +export async function deletePerson(id: PersonParams["id"]) { + const response = await apiClient.delete(`/people/${id}`); + const { data } = response.data; + return data.person; +} + +export async function bulkDeletePeople(input: BulkDeleteInput) { + const response = await apiClient.delete("/people/bulk", { + data: input, + }); + + const { data } = response.data; + return data.deleted; +} diff --git a/apps/web/services/crm/utils.ts b/apps/web/services/crm/utils.ts new file mode 100644 index 0000000..6f76783 --- /dev/null +++ b/apps/web/services/crm/utils.ts @@ -0,0 +1,10 @@ +type NullableParamValue = string | number | boolean | null | undefined; +type ParamRecord = Record; + +export function cleanQueryParams(params: TParams): Partial { + return Object.fromEntries( + Object.entries(params).filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ), + ) as Partial; +} diff --git a/apps/web/types/crm.ts b/apps/web/types/crm.ts new file mode 100644 index 0000000..ec73422 --- /dev/null +++ b/apps/web/types/crm.ts @@ -0,0 +1,129 @@ +import type { + CreateDeal, + CreateOrg, + CreatePerson, + DealStage, + ListDealsQuery, + ListOrgsQuery, + ListPeopleQuery, + PersonSource, + PersonStatus, + UpdateDeal, + UpdateOrg, + UpdatePerson, +} from "@workspace/validators/schemas/crm"; + +export interface PaginationMeta { + page: number; + pageSize: number; + totalCount: number; + totalPages: number; +} + +export interface RelatedEntityRef { + id: string; + name: string; +} + +export interface Person { + id: string; + workspaceId: string; + orgId: string | null; + ownerId: string | null; + name: string; + email: string | null; + phone: string | null; + jobTitle: string | null; + linkedinUrl: string | null; + status: PersonStatus; + source: PersonSource; + lastContactedAt: string | null; + customFields: Record | null; + createdAt: string; + updatedAt: string; + org?: RelatedEntityRef | null; + owner?: RelatedEntityRef | null; + orgName?: string | null; + ownerName?: string | null; +} + +export interface Organization { + id: string; + workspaceId: string; + name: string; + domain: string | null; + industry: string | null; + size: string | null; + location: string | null; + customFields: Record | null; + createdAt: string; + updatedAt: string; + peopleCount?: number; + people?: RelatedEntityRef[]; +} + +export interface Deal { + id: string; + workspaceId: string; + personId: string | null; + orgId: string | null; + ownerId: string | null; + title: string; + value: string | null; + currency: string; + stage: DealStage; + closeDate: string | null; + createdAt: string; + updatedAt: string; + person?: RelatedEntityRef | null; + org?: RelatedEntityRef | null; + owner?: RelatedEntityRef | null; +} + +export interface PeopleListResponse { + people: Person[]; + meta: PaginationMeta; +} + +export interface OrganizationsListResponse { + orgs: Organization[]; + meta: PaginationMeta; +} + +export interface DealsListResponse { + deals: Deal[]; + meta: PaginationMeta; +} + +export interface PersonDetailResponse { + person: Person; +} + +export interface OrganizationDetailResponse { + org: Organization; +} + +export interface DealDetailResponse { + deal: Deal; +} + +export interface BulkDeleteResponse { + deleted: number; +} + +export type PeopleListParams = Partial; +export type OrganizationsListParams = Partial; +export type DealsListParams = Partial; + +export type CreatePersonInput = CreatePerson; +export type UpdatePersonInput = UpdatePerson; + +export type CreateOrganizationInput = CreateOrg; +export type UpdateOrganizationInput = UpdateOrg; + +export type CreateDealInput = CreateDeal; +export type UpdateDealInput = UpdateDeal; + +export interface BulkDeleteInput { + ids: string[]; +} diff --git a/packages/validators/src/schemas/common.validator.ts b/packages/validators/src/schemas/common.validator.ts index 185e451..d72bcea 100644 --- a/packages/validators/src/schemas/common.validator.ts +++ b/packages/validators/src/schemas/common.validator.ts @@ -2,10 +2,6 @@ import { z } from "zod"; export const idSchema = z.string().uuid(); -export const paginationSchema = z.object({ - page: z.coerce.number().int().positive().default(1), - limit: z.coerce.number().int().positive().max(100).default(20), -}); export const dateLikeSchema = z.coerce.date(); export const nullableUuidSchema = z.string().uuid().nullable().optional(); @@ -20,4 +16,3 @@ export const ASSIGNABLE_WORKSPACE_ROLE = assignableWorkspaceRoleSchema.enum; export type WorkspaceRole = z.infer; export type AssignableWorkspaceRole = z.infer; -export type Pagination = z.infer; diff --git a/packages/validators/src/schemas/crm.validator.ts b/packages/validators/src/schemas/crm.validator.ts index d3ef4aa..d6d8234 100644 --- a/packages/validators/src/schemas/crm.validator.ts +++ b/packages/validators/src/schemas/crm.validator.ts @@ -1,6 +1,60 @@ import { z } from "zod"; import { dateLikeSchema, idSchema, nullableUuidSchema } from "./common.validator.js"; +import { + DEAL_SORT_BY_VALUES, + DEAL_STAGE_VALUES, + ORG_SORT_BY_VALUES, + PERSON_SORT_BY_VALUES, + PERSON_SOURCE_VALUES, + PERSON_STATUS_VALUES, + SORT_ORDER_VALUES, +} from "../types/crm.types.js"; +const optionalTrimmedString = (max: number) => z.string().trim().min(1).max(max).optional(); +const optionalUuidFilter = z.string().uuid().optional(); + +// Enums +export const personStatusSchema = z.enum(PERSON_STATUS_VALUES); +export const personSourceSchema = z.enum(PERSON_SOURCE_VALUES); +export const dealStageSchema = z.enum(DEAL_STAGE_VALUES); +export const sortOrderSchema = z.enum(SORT_ORDER_VALUES); +export const orgSortBySchema = z.enum(ORG_SORT_BY_VALUES); +export const personSortBySchema = z.enum(PERSON_SORT_BY_VALUES); +export const dealSortBySchema = z.enum(DEAL_SORT_BY_VALUES); + +// Base query schema for listing orgs, people, and deals +export const crmListQueryBaseSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + sortOrder: sortOrderSchema.default("asc"), + search: optionalTrimmedString(255), +}); + +export const listOrgsQuerySchema = crmListQueryBaseSchema.extend({ + sortBy: orgSortBySchema.default("name"), + industry: optionalTrimmedString(100), + size: optionalTrimmedString(50), +}); + +export const listPeopleQuerySchema = crmListQueryBaseSchema.extend({ + sortBy: personSortBySchema.default("name"), + status: personStatusSchema.optional(), + source: personSourceSchema.optional(), + ownerId: optionalUuidFilter, +}); + +export const listDealsQuerySchema = crmListQueryBaseSchema.extend({ + sortBy: dealSortBySchema.default("title"), + stage: dealStageSchema.optional(), + ownerId: optionalUuidFilter, +}); + +// Bulk delete schema +export const bulkDeleteSchema = z.object({ + ids: z.array(idSchema).min(1), +}); + +// Create and update schemas export const createOrgSchema = z.object({ name: z.string().min(1).max(255), domain: z.string().max(255).optional(), @@ -21,8 +75,8 @@ export const createPersonSchema = z.object({ phone: z.string().max(50).optional(), jobTitle: z.string().max(255).optional(), linkedinUrl: z.string().url().max(500).optional(), - status: z.enum(["lead", "prospect", "qualified", "customer", "churned"]).optional(), - source: z.enum(["manual", "csv", "api"]).optional(), + status: personStatusSchema.optional(), + source: personSourceSchema.optional(), lastContactedAt: dateLikeSchema.optional(), customFields: z.record(z.unknown()).optional(), }); @@ -37,21 +91,31 @@ export const createDealSchema = z.object({ ownerId: nullableUuidSchema, value: z.string().optional(), currency: z.string().length(3).optional(), - stage: z.enum(["new", "contacted", "demo", "proposal", "won", "lost"]).optional(), + stage: dealStageSchema.optional(), closeDate: dateLikeSchema.optional(), }); export const updateDealSchema = createDealSchema.partial(); export const dealParamsSchema = z.object({ id: idSchema }); +export type PersonStatus = z.infer; +export type PersonSource = z.infer; +export type DealStage = z.infer; +export type SortOrder = z.infer; + export type CreateOrg = z.infer; export type UpdateOrg = z.infer; export type OrgParams = z.infer; +export type ListOrgsQuery = z.infer; export type CreatePerson = z.infer; export type UpdatePerson = z.infer; export type PersonParams = z.infer; +export type ListPeopleQuery = z.infer; export type CreateDeal = z.infer; export type UpdateDeal = z.infer; export type DealParams = z.infer; +export type ListDealsQuery = z.infer; + +export type BulkDeleteInput = z.infer; diff --git a/packages/validators/src/types/crm.types.ts b/packages/validators/src/types/crm.types.ts new file mode 100644 index 0000000..bb03010 --- /dev/null +++ b/packages/validators/src/types/crm.types.ts @@ -0,0 +1,45 @@ +export const PERSON_STATUS_VALUES = [ + "lead", + "prospect", + "qualified", + "customer", + "churned", +] as const; + +export const PERSON_SOURCE_VALUES = ["manual", "csv", "api"] as const; + +export const DEAL_STAGE_VALUES = ["new", "contacted", "demo", "proposal", "won", "lost"] as const; + +export const ORG_SORT_BY_VALUES = [ + "name", + "domain", + "industry", + "size", + "location", + "createdAt", + "updatedAt", +] as const; + +export const PERSON_SORT_BY_VALUES = [ + "name", + "email", + "phone", + "jobTitle", + "status", + "source", + "lastContactedAt", + "createdAt", + "updatedAt", +] as const; + +export const DEAL_SORT_BY_VALUES = [ + "title", + "value", + "currency", + "stage", + "closeDate", + "createdAt", + "updatedAt", +] as const; + +export const SORT_ORDER_VALUES = ["asc", "desc"] as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d4d3f0..a05f123 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ catalogs: cmdk: specifier: ^1.1.1 version: 1.1.1 + dayjs: + specifier: ^1.11.19 + version: 1.11.20 lucide-react: specifier: ^0.563.0 version: 0.563.0 @@ -198,6 +201,9 @@ importers: cmdk: specifier: 'catalog:' version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + dayjs: + specifier: 'catalog:' + version: 1.11.20 lucide-react: specifier: 'catalog:' version: 0.563.0(react@19.2.4) @@ -231,6 +237,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + usehooks-ts: + specifier: ^3.1.1 + version: 3.1.1(react@19.2.4) zustand: specifier: 'catalog:' version: 5.0.11(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -2394,6 +2403,9 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3156,6 +3168,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3821,6 +3836,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + usehooks-ts@3.1.1: + resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==} + engines: {node: '>=16.15.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -5739,6 +5760,8 @@ snapshots: dateformat@4.6.3: {} + dayjs@1.11.20: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6531,6 +6554,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.debounce@4.0.8: {} + lodash.merge@4.6.2: {} lodash@4.17.23: {} @@ -7325,6 +7350,11 @@ snapshots: dependencies: react: 19.2.4 + usehooks-ts@3.1.1(react@19.2.4): + dependencies: + lodash.debounce: 4.0.8 + react: 19.2.4 + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f2dd5bc..57ec113 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,6 +30,7 @@ catalogs: "@tanstack/react-query-devtools": "^5.91.3" # Utils sonner: "^2.0.3" + dayjs: "^1.11.19" # DnD "@dnd-kit/core": "^6.3.1" "@dnd-kit/sortable": "^10.0.0"