From de48cdc8d74adc92aa5f73fb3cad21b1c72ebd26 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Fri, 10 Apr 2026 16:18:46 +0545 Subject: [PATCH 01/15] working --- CRM.md | 487 ++++++++++++++++ apps/api/src/controllers/deals.controller.ts | 108 +++- apps/api/src/controllers/orgs.controller.ts | 114 +++- apps/api/src/controllers/people.controller.ts | 121 +++- apps/api/src/routes/deals.route.ts | 5 +- apps/api/src/routes/orgs.route.ts | 10 +- apps/api/src/routes/people.route.ts | 10 +- apps/web/app/(crm)/organizations/page.tsx | 64 +- apps/web/app/(crm)/people/page.tsx | 71 +-- apps/web/components/crm/orgs/orgs-columns.tsx | 144 +++++ .../components/crm/orgs/orgs-data-table.tsx | 217 +++++++ apps/web/components/crm/orgs/orgs-drawer.tsx | 365 ++++++++++++ apps/web/components/crm/orgs/orgs-filters.tsx | 30 + .../components/crm/people/people-columns.tsx | 221 +++++++ .../crm/people/people-data-table.tsx | 230 ++++++++ .../components/crm/people/people-drawer.tsx | 547 ++++++++++++++++++ .../components/crm/people/people-filters.tsx | 26 + apps/web/components/shared/data-table.tsx | 515 +++++++++++++++++ apps/web/components/shared/entity-sheet.tsx | 130 +++++ apps/web/hooks/queries/use-deals.ts | 80 +++ apps/web/hooks/queries/use-orgs.ts | 110 ++++ apps/web/hooks/queries/use-people.ts | 98 ++++ apps/web/hooks/use-debounce.ts | 12 + apps/web/lib/query-keys.ts | 11 + apps/web/services/crm/deals.service.ts | 36 ++ apps/web/services/crm/orgs.service.ts | 51 ++ apps/web/services/crm/people.service.ts | 51 ++ apps/web/services/crm/utils.ts | 28 + apps/web/types/crm.ts | 129 +++++ .../validators/src/schemas/crm.validator.ts | 113 +++- 30 files changed, 3978 insertions(+), 156 deletions(-) create mode 100644 CRM.md create mode 100644 apps/web/components/crm/orgs/orgs-columns.tsx create mode 100644 apps/web/components/crm/orgs/orgs-data-table.tsx create mode 100644 apps/web/components/crm/orgs/orgs-drawer.tsx create mode 100644 apps/web/components/crm/orgs/orgs-filters.tsx create mode 100644 apps/web/components/crm/people/people-columns.tsx create mode 100644 apps/web/components/crm/people/people-data-table.tsx create mode 100644 apps/web/components/crm/people/people-drawer.tsx create mode 100644 apps/web/components/crm/people/people-filters.tsx create mode 100644 apps/web/components/shared/data-table.tsx create mode 100644 apps/web/components/shared/entity-sheet.tsx create mode 100644 apps/web/hooks/queries/use-deals.ts create mode 100644 apps/web/hooks/queries/use-orgs.ts create mode 100644 apps/web/hooks/queries/use-people.ts create mode 100644 apps/web/hooks/use-debounce.ts create mode 100644 apps/web/services/crm/deals.service.ts create mode 100644 apps/web/services/crm/orgs.service.ts create mode 100644 apps/web/services/crm/people.service.ts create mode 100644 apps/web/services/crm/utils.ts create mode 100644 apps/web/types/crm.ts diff --git a/CRM.md b/CRM.md new file mode 100644 index 0000000..0a0e52b --- /dev/null +++ b/CRM.md @@ -0,0 +1,487 @@ +# CRM Frontend Integration Plan + +## Overview + +Integrate CRM entities (People, Organizations, Deals) into the frontend using TanStack Table for data tables, ReUI Kanban for the deals board, and a shared EntitySheet for view/edit drawers. Server-side pagination, filtering, sorting, and search across all list endpoints. + +--- + +## Architecture Decisions + +| Decision | Choice | +| --------------- | -------------------------------------------------------------------------------------------------------------- | +| Pagination | Server-side, offset-based | +| Data table | Full-featured generic `DataTable` — owns toolbar, pagination, sorting, filtering, selection | +| Deals view | Kanban board only (no list toggle) — ReUI Kanban component | +| Row interaction | Click name → Sheet drawer in view mode; Edit button toggles edit mode; Add button → drawer in create/edit mode | +| Row actions | Dropdown menu column (View / Edit / Delete) + checkbox selection for bulk | +| Drawer | Shared `EntitySheet` component with per-entity form fields | +| Kanban data | Single `GET /deals` request, frontend groups by `stage` | +| Deal stage drag | Optimistic update with Tanstack Query `onMutate` + rollback on error | +| Import CSV | Deferred — remove buttons from initial build | +| Bulk delete | Single batch API call `DELETE /people/bulk { ids }` | +| Query keys | Flat string keys following existing pattern | +| File structure | Grouped by `crm/` subfolder, organized per entity | + +--- + +## Columns + +### People Table + +| Column | Source | Notes | +| -------------- | ---------------------- | ----------------------------------------------- | +| ☐ Checkbox | TanStack row selection | Bulk selection | +| Name | `name` | Clickable, opens drawer | +| Email | `email` | | +| Phone | `phone` | | +| Job Title | `jobTitle` | | +| Status | `status` | Badge: lead/prospect/qualified/customer/churned | +| Source | `source` | | +| Organization | `orgId` → org name | Relation, requires API join | +| Owner | `ownerId` → user name | Relation, requires API join | +| Last Contacted | `lastContactedAt` | Date formatting | +| ⋯ Actions | — | DropdownMenu: View / Edit / Delete | + +### Organizations Table + +| Column | Source | Notes | +| ---------- | ---------------------- | ---------------------------------- | +| ☐ Checkbox | TanStack row selection | Bulk selection | +| Name | `name` | Clickable, opens drawer | +| Domain | `domain` | | +| Industry | `industry` | | +| Size | `size` | | +| Location | `location` | | +| People | count via relation | Requires API join | +| ⋯ Actions | — | DropdownMenu: View / Edit / Delete | + +### Deals Kanban + +Stages (columns): `new` → `contacted` → `demo` → `proposal` → `won` → `lost` + +Card shows: Title, Value+Currency, Person name, Org name, Owner, Close date + +--- + +## File Structure + +``` +apps/web/ +├── services/crm/ +│ ├── people.service.ts # API calls for people CRUD + list +│ ├── orgs.service.ts # API calls for orgs CRUD + list +│ └── deals.service.ts # API calls for deals CRUD + list +│ +├── hooks/queries/ +│ ├── use-people.ts # usePeople, usePerson, useCreatePerson, useUpdatePerson, useDeletePerson, useBulkDeletePeople +│ ├── use-orgs.ts # useOrganizations, useOrg, useCreateOrg, useUpdateOrg, useDeleteOrg, useBulkDeleteOrgs +│ └── use-deals.ts # useDeals, useDeal, useCreateDeal, useUpdateDeal, useDeleteDeal +│ +├── components/ +│ ├── shared/ +│ │ ├── data-table.tsx # Generic DataTable with toolbar, pagination, sorting, filtering, selection +│ │ └── entity-sheet.tsx # Shared Sheet drawer (view/edit modes, action buttons, loading) +│ │ +│ └── crm/ +│ ├── people/ +│ │ ├── people-columns.tsx # Column definitions for TanStack Table +│ │ ├── people-data-table.tsx # People page wiring (DataTable + usePeople hook) +│ │ ├── people-drawer.tsx # People EntitySheet with person form fields +│ │ └── people-filters.tsx # Filter config for status, source, owner selects +│ │ +│ ├── orgs/ +│ │ ├── orgs-columns.tsx # Column definitions +│ │ ├── orgs-data-table.tsx # Orgs page wiring +│ │ ├── orgs-drawer.tsx # Orgs EntitySheet with org form fields +│ │ └── orgs-filters.tsx # Filter config for industry, size selects +│ │ +│ └── deals/ +│ ├── deal-kanban.tsx # Deal Kanban board using ReUI Kanban +│ ├── deal-card.tsx # Single deal card component +│ └── deal-drawer.tsx # Deals EntitySheet with deal form fields +│ +├── lib/ +│ └── query-keys.ts # Add CRM keys (see below) +│ +└── app/(crm)/ + ├── people/page.tsx # Updated: uses PeopleDataTable + ├── organizations/page.tsx # Updated: uses OrgsDataTable + └── deals/page.tsx # Updated: uses DealKanban +``` + +--- + +## Query Keys + +Add to existing `apps/web/lib/query-keys.ts`: + +```ts +// 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", +``` + +Hooks append params: `queryKey: [QUERY_KEYS.PEOPLE_LIST, params]` + +--- + +## API Contract Changes + +### All CRM List Endpoints — Add Pagination + Filtering + Search + Sorting + +Current: `GET /people` → `{ success: true, data: { people: [...] } }` + +New: + +``` +GET /people?page=1&pageSize=25&sortBy=name&sortOrder=asc&status=lead&search=john +GET /orgs?page=1&pageSize=25&sortBy=name&sortOrder=asc&industry=technology&search=acme +GET /deals?page=1&pageSize=25&sortBy=title&sortOrder=asc&stage=new&search=project +``` + +Response: + +```json +{ + "success": true, + "data": { + "people": [...], + "meta": { + "page": 1, + "pageSize": 25, + "totalCount": 142, + "totalPages": 6 + } + } +} +``` + +### List Endpoint Query Params + +| Param | Type | Example | Description | +| ----------------------- | ------ | -------------- | ------------------------------------------------------------------- | +| `page` | number | `1` | Page number (1-indexed) | +| `pageSize` | number | `25` | Items per page (default 25) | +| `sortBy` | string | `name` | Column name to sort by | +| `sortOrder` | string | `asc` / `desc` | Sort direction | +| `search` | string | `john` | Search term (searches name, email, etc.) | +| Entity-specific filters | | `status=lead` | People: status, source, ownerId. Orgs: industry, size. Deals: stage | + +### People List — Include Relations + +```sql +SELECT people.*, orgs.name AS org_name, users.name AS owner_name +FROM people +LEFT JOIN orgs ON people.org_id = orgs.id +LEFT JOIN users ON people.owner_id = users.id +WHERE people.workspace_id = ? +``` + +Response adds `orgName` and `ownerName` to each person object (or nested `org: { id, name }` and `owner: { id, name }`). + +### Orgs List — Include People Count + +```sql +SELECT orgs.*, COUNT(people.id) AS people_count +FROM orgs +LEFT JOIN people ON people.org_id = orgs.id +WHERE orgs.workspace_id = ? +GROUP BY orgs.id +``` + +### Deals List — Include Relations (already exists in `getDeal`) + +Extend `listDeals` to include relations like `getDeal` does: + +```ts +const results = await db.query.deals.findMany({ + where: eq(deals.workspaceId, workspaceId), + with: { + org: { columns: { id: true, name: true } }, + person: { columns: { id: true, name: true } }, + owner: { columns: { id: true, name: true } }, + }, +}); +``` + +### Bulk Delete Endpoints (New) + +``` +DELETE /people/bulk { ids: ["id1", "id2", "id3"] } +DELETE /orgs/bulk { ids: ["id1", "id2", "id3"] } +``` + +Response: `{ success: true, data: { deleted: 3 } }` + +--- + +## Implementation Phases + +### Phase 1: Foundation (Backend + Shared Components) + +**1.1 Backend — Pagination, Filtering, Sorting, Search** + +- Update `listPeople` controller to accept query params (page, pageSize, sortBy, sortOrder, status, source, ownerId, search) +- Update `listOrgs` controller to accept query params (page, pageSize, sortBy, sortOrder, industry, size, search) +- Update `listDeals` controller to accept query params (page, pageSize, sortBy, sortOrder, stage, search) +- Add `meta` object to all list responses (page, pageSize, totalCount, totalPages) +- Add relation includes to people list (org name, owner name) +- Add people count to orgs list +- Add relation includes to deals list (org, person, owner names) +- Update Zod validators for query param validation +- Update route handlers to pass query params to controllers + +**1.2 Backend — Bulk Delete** + +- Add `DELETE /people/bulk` route + controller +- Add `DELETE /orgs/bulk` route + controller +- Add Zod validators for bulk delete + +**1.3 Frontend — Shared DataTable Component** + +- Create `apps/web/components/shared/data-table.tsx` +- Generic `DataTable` props: + - `columns: ColumnDef[]` + - `data: TData[]` + - `pageCount: number` + - `pageIndex: number` + - `pageSize: number` + - `onPaginationChange: (pagination: PaginationState) => void` + - `onSortingChange: (sorting: SortingState) => void` + - `onColumnFiltersChange: (filters: ColumnFiltersState) => void` + - `searchPlaceholder?: string` + - `filterConfig?: FilterConfig[]` (defines which columns get filter dropdowns and their options) + - `onSearchChange: (search: string) => void` + - `isLoading?: boolean` + - `enableRowSelection?: boolean` + - `onRowClick?: (row: TData) => void` +- Internal toolbar: search input + filter dropdowns (from filterConfig) + column visibility toggle +- Footer pagination using existing shadcn Pagination component +- Loading state (Skeleton rows), Empty state (EmptyState component), Error state (ErrorState component) +- Checkbox column for row selection +- Actions column for dropdown menu + +**1.4 Frontend — Shared EntitySheet Component** + +- Create `apps/web/components/shared/entity-sheet.tsx` +- Props: + - `open: boolean` + - `onOpenChange: (open: boolean) => void` + - `title: string` + - `description?: string` + - `mode: "view" | "edit" | "create"` + - `isLoading?: boolean` + - `children: React.ReactNode` (the form fields or view content) + - `onEdit?: () => void` + - `onSave?: () => void` + - `onDelete?: () => void` +- View mode: read-only fields, Edit button in header +- Edit/Create mode: editable form fields, Save/Cancel buttons +- Uses shadcn `Sheet` component (already in packages/ui) + +**1.5 Frontend — Install ReUI Kanban** + +- Run `pnpm dlx shadcn@latest add @reui/kanban` in `apps/web` +- Verify it installs to `apps/web/components/reui/kanban.tsx` (or appropriate path based on shadcn config) + +**1.6 Frontend — Services** + +- Create `apps/web/services/crm/people.service.ts` +- Create `apps/web/services/crm/orgs.service.ts` +- Create `apps/web/services/crm/deals.service.ts` +- Each service: list (with params), get, create, update, delete, bulkDelete + +**1.7 Frontend — Query Keys + Hooks** + +- Update `apps/web/lib/query-keys.ts` with CRM keys +- Create `apps/web/hooks/queries/use-people.ts` +- Create `apps/web/hooks/queries/use-orgs.ts` +- Create `apps/web/hooks/queries/use-deals.ts` +- Each hook file: list query hook (with pagination params), detail query hook, create/update/delete/bulkDelete mutation hooks + +### Phase 2: People Page + +**2.1 Column Definitions** + +- Create `apps/web/components/crm/people/people-columns.tsx` +- TanStack Table `ColumnDef[]` for all People columns +- Name column: clickable cell (opens drawer) +- Status column: Badge with color variants +- Actions column: DropdownMenu with View/Edit/Delete + +**2.2 Filter Config** + +- Create `apps/web/components/crm/people/people-filters.tsx` +- Define filter options for Status, Source, Owner selects + +**2.3 People Drawer** + +- Create `apps/web/components/crm/people/people-drawer.tsx` +- EntitySheet with person form fields (name, email, phone, jobTitle, status, source, orgId select, ownerId select) +- View mode: read-only display +- Edit mode: react-hook-form + Zod validation +- Create mode: empty form + +**2.4 People Data Table** + +- Create `apps/web/components/crm/people/people-data-table.tsx` +- Wire DataTable + usePeople hook + column defs + filter config + +**2.5 People Page** + +- Update `apps/web/app/(crm)/people/page.tsx` +- Replace stub with PeopleDataTable +- Add "Add Person" button in PageHeader actions +- Handle drawer open/close state + +### Phase 3: Organizations Page + +**3.1–3.5** — Mirror Phase 2 pattern for Organizations entity + +### Phase 4: Deals Kanban Page + +**4.1 Deal Card Component** + +- Create `apps/web/components/crm/deals/deal-card.tsx` +- Displays: title, value+currency, person/org names, owner, close date +- Clickable (opens drawer) + +**4.2 Deal Drawer** + +- Create `apps/web/components/crm/deals/deal-drawer.tsx` +- EntitySheet with deal form fields (title, value, currency, stage, personId, orgId, ownerId, closeDate) + +**4.3 Deal Kanban Board** + +- Create `apps/web/components/crm/deals/deal-kanban.tsx` +- Uses ReUI Kanban components +- `value` = deals grouped by stage +- `onValueChange` = optimistic update + `useUpdateDeal` mutation +- `onItemClick` = open drawer +- Validate stage enum order: new → contacted → demo → proposal → won → lost + +**4.4 Deals Page** + +- Update `apps/web/app/(crm)/deals/page.tsx` +- Replace stub with DealKanban +- Remove board/list ToggleGroup (kanban only) +- Add "Add Deal" button in PageHeader + +### Phase 5: Polish & Integration + +- Remove Import CSV buttons from all pages (deferred) +- Add "Delete selected" bulk action to People and Orgs table toolbars (appears when rows selected) +- Ensure all error states, loading states, and empty states are wired up +- Test optimistic update rollback on failed deal stage changes +- Test pagination, sorting, and filtering across all tables +- Verify drawer view/edit/create flows for all entities +- Ensure query invalidation works correctly after mutations (list queries refresh after create/update/delete) + +--- + +## Key Component Interfaces + +### DataTable + +```tsx +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + pageCount: number; + pageIndex: number; + pageSize: number; + onPaginationChange: (pagination: PaginationState) => void; + onSortingChange: (sorting: SortingState) => void; + onColumnFiltersChange: (filters: ColumnFiltersState) => void; + searchPlaceholder?: string; + onSearchChange: (search: string) => void; + filterConfig?: FilterConfig[]; + enableRowSelection?: boolean; + onRowClick?: (row: TData) => void; + isLoading?: boolean; +} +``` + +### EntitySheet + +```tsx +interface EntitySheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + mode: "view" | "edit" | "create"; + isLoading?: boolean; + children: React.ReactNode; + onEdit?: () => void; + onSave?: () => void; + onDelete?: () => void; +} +``` + +### Service Function Pattern + +```tsx +// services/crm/people.service.ts +interface PeopleListParams { + page: number; + pageSize: number; + sortBy?: string; + sortOrder?: "asc" | "desc"; + search?: string; + status?: string; + source?: string; + ownerId?: string; +} + +async function listPeople( + params: PeopleListParams, +): Promise<{ people: Person[]; meta: PaginationMeta }>; +async function getPerson(id: string): Promise; +async function createPerson(data: CreatePerson): Promise; +async function updatePerson(id: string, data: UpdatePerson): Promise; +async function deletePerson(id: string): Promise; +async function bulkDeletePeople(ids: string[]): Promise<{ deleted: number }>; +``` + +### Hook Pattern + +```tsx +// hooks/queries/use-people.ts +function usePeople(params: PeopleListParams); // useQuery with [PEOPLE_LIST, params] +function usePerson(id: string); // useQuery with [PEOPLE_DETAIL, id] +function useCreatePerson(); // useMutation + invalidate [PEOPLE, PEOPLE_LIST] +function useUpdatePerson(); // useMutation + invalidate [PEOPLE, PEOPLE_LIST, PEOPLE_DETAIL, id] +function useDeletePerson(); // useMutation + invalidate [PEOPLE] +function useBulkDeletePeople(); // useMutation + invalidate [PEOPLE] +``` + +--- + +## Dependencies to Install + +```bash +# In apps/web +pnpm dlx shadcn@latest add @reui/kanban +``` + +Note: `@tanstack/react-table` is already installed. `@dnd-kit` packages can be removed since ReUI Kanban handles DnD internally. + +--- + +## Import CSV — Deferred + +The "Import CSV" buttons should be removed from initial build. This feature will be added later with: + +- File upload modal +- Column mapping step +- Batch creation endpoint +- Error reporting for failed rows diff --git a/apps/api/src/controllers/deals.controller.ts b/apps/api/src/controllers/deals.controller.ts index c5746fc..9979cb0 100644 --- a/apps/api/src/controllers/deals.controller.ts +++ b/apps/api/src/controllers/deals.controller.ts @@ -1,6 +1,6 @@ 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, type SQL } from "drizzle-orm"; import { db } from "@/db/client.js"; import { deals } from "@/db/schema/index.js"; import { STATUS_CODES } from "@/constants/status-codes.js"; @@ -9,11 +9,100 @@ 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) { +const dealSortColumns = { + title: deals.title, + value: deals.value, + currency: deals.currency, + stage: deals.stage, + closeDate: deals.closeDate, + createdAt: deals.createdAt, + updatedAt: deals.updatedAt, +} as const; + +function buildDealFilters(workspaceId: string, query: ListDealsQuery): SQL[] { + const filters: SQL[] = [eq(deals.workspaceId, workspaceId)]; + + if (query.stage) { + filters.push(eq(deals.stage, query.stage)); + } + + if (query.ownerId) { + filters.push(eq(deals.ownerId, query.ownerId)); + } + + if (query.search) { + const searchTerm = `%${query.search}%`; + + filters.push(ilike(deals.title, searchTerm)); + } + + return filters; +} + +function buildDealOrderBy(query: ListDealsQuery) { + const column = dealSortColumns[query.sortBy] ?? deals.title; + return query.sortOrder === "desc" ? desc(column) : asc(column); +} + +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 = query.page; + const pageSize = query.pageSize; + const offset = (page - 1) * pageSize; + const filters = buildDealFilters(workspaceId, query); + const orderBy = buildDealOrderBy(query); - return sendSuccess(c, { deals: results }, STATUS_CODES.OK); + const [results, totalCountRows] = await Promise.all([ + db.query.deals.findMany({ + where: and(...filters), + with: { + org: { + columns: { + id: true, + name: true, + }, + }, + person: { + columns: { + id: true, + name: true, + }, + }, + owner: { + columns: { + id: true, + name: true, + }, + }, + }, + orderBy: [orderBy], + limit: pageSize, + offset, + }), + db + .select({ + totalCount: count(deals.id), + }) + .from(deals) + .where(and(...filters)), + ]); + + const totalCount = Number(totalCountRows[0]?.totalCount ?? 0); + const totalPages = totalCount === 0 ? 0 : Math.ceil(totalCount / pageSize); + + return sendSuccess( + c, + { + deals: results, + meta: { + page, + pageSize, + totalCount, + totalPages, + }, + }, + STATUS_CODES.OK, + ); } export async function getDeal(c: Context, id: string) { @@ -33,8 +122,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 +177,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..e2af3d2 100644 --- a/apps/api/src/controllers/orgs.controller.ts +++ b/apps/api/src/controllers/orgs.controller.ts @@ -1,6 +1,11 @@ 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 { STATUS_CODES } from "@/constants/status-codes.js"; @@ -8,11 +13,100 @@ 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) { +function buildOrgWhereClause(workspaceId: string, query: ListOrgsQuery) { + const conditions = [eq(orgs.workspaceId, workspaceId)]; + + if (query.industry) { + conditions.push(eq(orgs.industry, query.industry)); + } + + if (query.size) { + conditions.push(eq(orgs.size, query.size)); + } + + if (query.search) { + const searchTerm = `%${query.search}%`; + conditions.push( + or( + ilike(orgs.name, searchTerm), + ilike(orgs.domain, searchTerm), + ilike(orgs.industry, searchTerm), + ilike(orgs.size, searchTerm), + ilike(orgs.location, searchTerm), + )!, + ); + } + + return and(...conditions); +} + +function getOrgOrderBy(query: ListOrgsQuery) { + const direction = query.sortOrder === "desc" ? desc : asc; + + switch (query.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); + } +} + +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 whereClause = buildOrgWhereClause(workspaceId, query); + const page = query.page; + const pageSize = query.pageSize; + const offset = (page - 1) * pageSize; - return sendSuccess(c, { orgs: results }, STATUS_CODES.OK); + const [results, totalCountResult] = await Promise.all([ + db.query.orgs.findMany({ + where: whereClause, + with: { + people: { + columns: { + id: true, + }, + }, + }, + orderBy: [getOrgOrderBy(query)], + limit: pageSize, + offset, + }), + db.select({ totalCount: count() }).from(orgs).where(whereClause), + ]); + + const totalCount = Number(totalCountResult[0]?.totalCount ?? 0); + const totalPages = totalCount === 0 ? 0 : Math.ceil(totalCount / pageSize); + + const normalizedResults = results.map(({ people, ...org }) => ({ + ...org, + peopleCount: people.length, + })); + + return sendSuccess( + c, + { + orgs: normalizedResults, + meta: { + page, + pageSize, + totalCount, + totalPages, + }, + }, + STATUS_CODES.OK, + ); } export async function getOrg(c: Context, id: string) { @@ -80,3 +174,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..e00dfc2 100644 --- a/apps/api/src/controllers/people.controller.ts +++ b/apps/api/src/controllers/people.controller.ts @@ -1,6 +1,11 @@ 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 { STATUS_CODES } from "@/constants/status-codes.js"; @@ -9,19 +14,107 @@ 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; - return sendSuccess(c, { people: results }, STATUS_CODES.OK); + const filters = [ + eq(people.workspaceId, workspaceId), + status ? eq(people.status, status) : undefined, + source ? eq(people.source, source) : undefined, + ownerId ? eq(people.ownerId, ownerId) : undefined, + search + ? or( + ilike(people.name, `%${search}%`), + ilike(people.email, `%${search}%`), + ilike(people.phone, `%${search}%`), + ilike(people.jobTitle, `%${search}%`), + ) + : undefined, + ].filter((value): value is NonNullable => value !== undefined); + + const whereClause = and(...filters); + + 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]; + const offset = (page - 1) * pageSize; + + const [results, totalCountResult] = await Promise.all([ + db.query.people.findMany({ + where: whereClause, + with: { + org: { + columns: { + id: true, + name: true, + }, + }, + owner: { + columns: { + id: true, + name: true, + }, + }, + }, + orderBy: [sortOrder === "desc" ? desc(orderColumn) : asc(orderColumn)], + limit: pageSize, + 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: results.map((person) => ({ + ...person, + orgName: person.org?.name ?? null, + ownerName: person.owner?.name ?? 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 +169,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)/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/orgs/orgs-columns.tsx b/apps/web/components/crm/orgs/orgs-columns.tsx new file mode 100644 index 0000000..0dbec83 --- /dev/null +++ b/apps/web/components/crm/orgs/orgs-columns.tsx @@ -0,0 +1,144 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { Building2, 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"; +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[] { + 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} + ) : ( + + ), + }, + { + id: "industry", + accessorKey: "industry", + header: "Industry", + enableSorting: true, + cell: ({ row }) => + row.original.industry ? ( + {row.original.industry} + ) : ( + + ), + }, + { + id: "size", + accessorKey: "size", + header: "Size", + enableSorting: true, + cell: ({ row }) => + row.original.size ? ( + {row.original.size} + ) : ( + + ), + }, + { + id: "location", + accessorKey: "location", + header: "Location", + enableSorting: true, + cell: ({ row }) => + row.original.location ? ( + {row.original.location} + ) : ( + + ), + }, + { + 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)}> + + View + + onEdit(row.original)}> + + Edit + + + onDelete(row.original)} + > + + Delete + + + + ), + }, + ]; +} 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..5cc4455 --- /dev/null +++ b/apps/web/components/crm/orgs/orgs-data-table.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useCallback, useMemo, 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 { useDebounce } from "@/hooks/use-debounce"; +import type { Organization, OrganizationsListParams } from "@/types/crm"; +import type { EntitySheetMode } from "@/components/shared/entity-sheet"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +type DrawerState = { + open: boolean; + mode: EntitySheetMode; + org?: Organization; +}; + +// ─── Component ────────────────────────────────────────────────────────────── + +export function OrgsDataTable() { + // Table state + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [searchInput, setSearchInput] = useState(""); + const [rowSelection, setRowSelection] = useState({}); + + // Drawer / delete state + const [drawer, setDrawer] = useState({ open: false, mode: "view" }); + const [deleteTarget, setDeleteTarget] = useState(null); + + // Debounced search value drives the query; raw input drives the input element + const debouncedSearch = useDebounce(searchInput, 300); + + // Derive filter values from TanStack columnFilters + const industryFilter = columnFilters.find((f) => f.id === "industry")?.value as + | string + | undefined; + const sizeFilter = columnFilters.find((f) => f.id === "size")?.value as string | undefined; + + // Build server query params + const queryParams = useMemo( + () => ({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + ...(sorting[0] && { + sortBy: sorting[0].id as OrganizationsListParams["sortBy"], + sortOrder: sorting[0].desc ? "desc" : "asc", + }), + ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), + ...(industryFilter && { industry: industryFilter }), + ...(sizeFilter && { size: sizeFilter }), + }), + [pagination, sorting, debouncedSearch, industryFilter, 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(rowSelection).filter((id) => rowSelection[id]); + const selectedCount = selectedIds.length; + const isDeletePending = isBulkDeleting || isDeleting; + + // Stable column callbacks (setters are always stable) + const handleView = useCallback( + (org: Organization) => setDrawer({ open: true, mode: "view", org }), + [], + ); + const handleEdit = useCallback( + (org: Organization) => setDrawer({ open: true, mode: "edit", org }), + [], + ); + const handleDeleteRow = useCallback((org: Organization) => setDeleteTarget(org), []); + + const columns = useMemo( + () => getOrgsColumns({ onView: handleView, onEdit: handleEdit, onDelete: handleDeleteRow }), + [handleView, handleEdit, handleDeleteRow], + ); + + // Handlers + function handleSortingChange(next: SortingState) { + setSorting(next); + setPagination((p) => ({ ...p, pageIndex: 0 })); + } + + function handleSearchChange(value: string) { + setSearchInput(value); + setPagination((p) => ({ ...p, pageIndex: 0 })); + } + + function handleConfirmDelete() { + if (deleteTarget === "bulk") { + bulkDeleteMutate( + { ids: selectedIds }, + { + onSuccess: () => { + setRowSelection({}); + setDeleteTarget(null); + }, + }, + ); + } else if (deleteTarget) { + deleteOrgMutate(deleteTarget.id, { + onSuccess: () => setDeleteTarget(null), + }); + } + } + + // Toolbar: only shows when rows are selected + const toolbarActions = + selectedCount > 0 ? ( + + ) : null; + + // Confirm dialog copy + const isBulkTarget = deleteTarget === "bulk"; + const confirmTitle = isBulkTarget + ? `Delete ${selectedCount} organization${selectedCount === 1 ? "" : "s"}?` + : `Delete "${(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 "${(deleteTarget as Organization | null)?.name}". People linked to this organization will have their organization cleared. This action cannot be undone.`; + + return ( + <> + setDrawer({ open: true, mode: "create" })}> + + Add Organization + + } + /> + + row.id} + onRowClick={handleView} + emptyTitle="No organizations yet" + emptyDescription="Add your first organization to start tracking companies in your CRM." + toolbarActions={toolbarActions} + /> + + setDrawer((s) => ({ ...s, open }))} + initialMode={drawer.mode} + org={drawer.org} + /> + + { + if (!open) setDeleteTarget(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..f1b0b1b --- /dev/null +++ b/apps/web/components/crm/orgs/orgs-drawer.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { useEffect, useState } 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 { useCreateOrg, useUpdateOrg, useDeleteOrg } from "@/hooks/queries/use-orgs"; +import type { Organization } from "@/types/crm"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const 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" }, +] as const; + +const 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+" }, +] as const; + +// ─── View helpers ───────────────────────────────────────────────────────────── + +function ViewField({ + label, + value, + children, +}: { + label: string; + value?: string | null; + children?: React.ReactNode; +}) { + const content = children ?? value; + return ( +
+

{label}

+ {content ? ( +

{content}

+ ) : ( +

+ )} +
+ ); +} + +function ViewSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +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}

} +
+
+ + + + + + + + + + {org.peopleCount !== undefined && ( + <> + + + + + + )} +
+ ); +} + +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; + initialMode: EntitySheetMode; + org?: Organization; +} + +function getDefaultValues(org?: Organization): CreateOrg { + return { + name: org?.name ?? "", + domain: org?.domain ?? "", + industry: org?.industry ?? "", + size: org?.size ?? "", + location: org?.location ?? "", + }; +} + +export function OrgDrawer({ open, onOpenChange, initialMode, org }: OrgDrawerProps) { + const [mode, setMode] = useState(initialMode); + + 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 form = useForm({ + resolver: zodResolver(createOrgSchema), + defaultValues: getDefaultValues(org), + }); + + // Reset form and mode whenever the drawer opens + useEffect(() => { + if (!open) return; + setMode(initialMode); + form.reset(getDefaultValues(org)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, org?.id, initialMode]); + + 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 ( + setMode("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..8e3f2bc --- /dev/null +++ b/apps/web/components/crm/orgs/orgs-filters.tsx @@ -0,0 +1,30 @@ +import type { FilterConfig } from "@/components/shared/data-table"; + +export const ORGS_FILTER_CONFIG: FilterConfig[] = [ + { + columnId: "industry", + label: "Industry", + allLabel: "All Industries", + 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" }, + ], + }, + { + columnId: "size", + label: "Size", + allLabel: "Any 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/people/people-columns.tsx b/apps/web/components/crm/people/people-columns.tsx new file mode 100644 index 0000000..b15d04a --- /dev/null +++ b/apps/web/components/crm/people/people-columns.tsx @@ -0,0 +1,221 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { Eye, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { Badge } from "@workspace/ui/components/ui/badge"; +import { Button } from "@workspace/ui/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@workspace/ui/components/ui/dropdown-menu"; +import { cn } from "@workspace/ui/lib/utils"; +import type { PersonStatus } from "@workspace/validators/schemas/crm"; +import type { Person } from "@/types/crm"; + +// ─── Status config ─────────────────────────────────────────────────────────── + +export const PERSON_STATUS_CONFIG: Record = { + lead: { + label: "Lead", + className: + "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300 border-transparent", + }, + prospect: { + label: "Prospect", + className: + "bg-blue-100 text-blue-700 dark:bg-blue-950/70 dark:text-blue-300 border-transparent", + }, + qualified: { + label: "Qualified", + className: + "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/70 dark:text-emerald-300 border-transparent", + }, + customer: { + label: "Customer", + className: + "bg-violet-100 text-violet-700 dark:bg-violet-950/70 dark:text-violet-300 border-transparent", + }, + churned: { + label: "Churned", + className: + "bg-rose-100 text-rose-700 dark:bg-rose-950/70 dark:text-rose-300 border-transparent", + }, +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }).format(new Date(value)); +} + +// ─── Column factory ────────────────────────────────────────────────────────── + +interface GetPeopleColumnsProps { + onView: (person: Person) => void; + onEdit: (person: Person) => void; + onDelete: (person: Person) => void; +} + +export function getPeopleColumns({ + onView, + onEdit, + onDelete, +}: GetPeopleColumnsProps): ColumnDef[] { + 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} + + ) : ( + + ), + }, + { + id: "phone", + accessorKey: "phone", + header: "Phone", + enableSorting: false, + cell: ({ row }) => + row.original.phone ? ( + {row.original.phone} + ) : ( + + ), + }, + { + id: "jobTitle", + accessorKey: "jobTitle", + header: "Job Title", + enableSorting: true, + cell: ({ row }) => + row.original.jobTitle ? ( + {row.original.jobTitle} + ) : ( + + ), + }, + { + id: "status", + accessorKey: "status", + header: "Status", + enableSorting: true, + cell: ({ row }) => { + const { status } = row.original; + const config = PERSON_STATUS_CONFIG[status]; + 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} + ) : ( + + ); + }, + }, + { + id: "ownerName", + header: "Owner", + enableSorting: false, + cell: ({ row }) => { + const name = row.original.ownerName ?? row.original.owner?.name; + return name ? ( + {name} + ) : ( + + ); + }, + }, + { + id: "lastContactedAt", + accessorKey: "lastContactedAt", + header: "Last Contacted", + enableSorting: true, + cell: ({ row }) => ( + + {formatDate(row.original.lastContactedAt)} + + ), + }, + { + id: "__actions", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( + + + + + + onView(row.original)}> + + View + + onEdit(row.original)}> + + Edit + + + onDelete(row.original)}> + + Delete + + + + ), + }, + ]; +} 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..62b10a9 --- /dev/null +++ b/apps/web/components/crm/people/people-data-table.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useCallback, useMemo, 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 { useDebounce } from "@/hooks/use-debounce"; +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; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function PeopleDataTable() { + // Table state + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [rowSelection, setRowSelection] = useState({}); + const [searchInput, setSearchInput] = useState(""); + + // UI state + const [drawer, setDrawer] = useState({ open: false, mode: "view" }); + const [deleteTarget, setDeleteTarget] = useState(null); + + // Debounce search to avoid a query on every keystroke + const debouncedSearch = useDebounce(searchInput, 350); + + // Extract individual filter values from the TanStack ColumnFiltersState + const statusFilter = columnFilters.find((f) => f.id === "status")?.value as + | string + | undefined; + const sourceFilter = columnFilters.find((f) => f.id === "source")?.value as + | string + | undefined; + + // Build the API query params from all state slices + const queryParams = useMemo( + () => ({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + ...(sorting[0] && { + sortBy: sorting[0].id as PeopleListParams["sortBy"], + sortOrder: sorting[0].desc ? "desc" : "asc", + }), + ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), + ...(statusFilter && { status: statusFilter as PeopleListParams["status"] }), + ...(sourceFilter && { source: sourceFilter as PeopleListParams["source"] }), + }), + [pagination, sorting, debouncedSearch, statusFilter, sourceFilter], + ); + + // 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 = useMemo( + () => Object.entries(rowSelection).filter(([, v]) => v).map(([id]) => id), + [rowSelection], + ); + const selectedCount = selectedIds.length; + + // ─── Drawer helpers ────────────────────────────────────────────────────────── + + const openDrawer = useCallback((mode: EntitySheetMode, person?: Person) => { + setDrawer({ open: true, mode, person }); + }, []); + + const closeDrawer = useCallback(() => { + setDrawer((prev) => ({ ...prev, open: false })); + }, []); + + // ─── Columns (memoised so identity is stable across renders) ───────────────── + + const columns = useMemo( + () => + getPeopleColumns({ + onView: (person) => openDrawer("view", person), + onEdit: (person) => openDrawer("edit", person), + onDelete: (person) => setDeleteTarget(person), + }), + [openDrawer], + ); + + // ─── Delete ────────────────────────────────────────────────────────────────── + + function handleDeleteConfirm() { + if (deleteTarget === "bulk") { + bulkDelete( + { ids: selectedIds }, + { + onSuccess: () => { + setRowSelection({}); + setDeleteTarget(null); + }, + }, + ); + } else if (deleteTarget) { + deletePerson(deleteTarget.id, { + onSuccess: () => setDeleteTarget(null), + }); + } + } + + const isDeletePending = isDeleting || isBulkDeleting; + + const confirmDialogCopy = + 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.`, + } + : deleteTarget + ? { + title: `Delete "${deleteTarget.name}"?`, + description: `This will permanently remove ${deleteTarget.name} from your CRM. This action cannot be undone.`, + } + : { title: "", description: "" }; + + // ─── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ openDrawer("create")}> + + Add Person + + } + /> + + { + setSorting(next); + setPagination((p) => ({ ...p, pageIndex: 0 })); + }} + columnFilters={columnFilters} + onColumnFiltersChange={setColumnFilters} + searchValue={searchInput} + onSearchChange={setSearchInput} + 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={rowSelection} + onRowSelectionChange={setRowSelection} + 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(); + }} + initialMode={drawer.mode} + person={drawer.person} + /> + + { + if (!open) setDeleteTarget(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..bd71202 --- /dev/null +++ b/apps/web/components/crm/people/people-drawer.tsx @@ -0,0 +1,547 @@ +"use client"; + +import { useEffect, useState } from "react"; +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 { 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 { PERSON_STATUS_CONFIG } from "./people-columns"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }).format(new Date(value)); +} + +function toDateInputValue(value: Date | string | null | undefined): string { + if (!value) return ""; + const d = value instanceof Date ? value : new Date(value); + return d.toISOString().slice(0, 10); +} + +function getDefaultValues(person?: Person | null): Partial { + return { + name: person?.name ?? "", + email: person?.email ?? undefined, + phone: person?.phone ?? undefined, + jobTitle: person?.jobTitle ?? undefined, + linkedinUrl: person?.linkedinUrl ?? undefined, + status: person?.status ?? "lead", + source: person?.source ?? "manual", + orgId: person?.orgId ?? null, + ownerId: person?.ownerId ?? null, + lastContactedAt: person?.lastContactedAt ? new Date(person.lastContactedAt) : undefined, + }; +} + +// ─── View-mode field helpers ────────────────────────────────────────────────── + +function ViewField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +function ViewSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +// ─── View content ───────────────────────────────────────────────────────────── + +function PersonViewContent({ person }: { person: Person }) { + const statusConfig = PERSON_STATUS_CONFIG[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.label} + + + {person.source} + + + {formatDate(person.lastContactedAt)} + + + + + + + + {orgName ?? Not assigned} + + + {ownerName ?? Not assigned} + + + +
+
+ + {formatDate(person.createdAt)} + + + {formatDate(person.updatedAt)} + +
+
+
+ ); +} + +// ─── 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 as unknown as { + members?: Array<{ userId: string; user: { name: string } }>; + } + )?.members ?? []; + + return ( +
+
+ {/* Name */} + ( + + + Name * + + + + + + + )} + /> + + {/* Email + Phone */} +
+ ( + + Email + + + + + + )} + /> + ( + + Phone + + + + + + )} + /> +
+ + {/* Job Title */} + ( + + Job Title + + + + + + )} + /> + + {/* Status + Source */} +
+ ( + + Status + + + + )} + /> + ( + + Source + + + + )} + /> +
+ + {/* Organization */} + ( + + Organization + + + + )} + /> + + {/* Owner */} + ( + + Owner + + + + )} + /> + + {/* LinkedIn */} + ( + + LinkedIn URL + + + + + + )} + /> + + {/* Last Contacted */} + ( + + Last Contacted + + + field.onChange(e.target.value ? new Date(e.target.value) : undefined) + } + disabled={isPending} + /> + + + + )} + /> +
+
+ ); +} + +// ─── Main drawer ────────────────────────────────────────────────────────────── + +interface PeopleDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialMode: EntitySheetMode; + person?: Person | null; + onDeleteSuccess?: () => void; +} + +export function PeopleDrawer({ + open, + onOpenChange, + initialMode, + person, + onDeleteSuccess, +}: PeopleDrawerProps) { + const [mode, setMode] = useState(initialMode); + + 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 form = useForm({ + resolver: zodResolver(createPersonSchema), + defaultValues: getDefaultValues(person), + }); + + // Reset form and mode every time the drawer opens + useEffect(() => { + if (!open) return; + setMode(initialMode); + form.reset(getDefaultValues(initialMode === "create" ? null : person)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, person?.id, initialMode]); + + 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 ( + setMode("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..3fed404 --- /dev/null +++ b/apps/web/components/crm/people/people-filters.tsx @@ -0,0 +1,26 @@ +import type { FilterConfig } from "@/components/shared/data-table"; + +export const PEOPLE_FILTER_CONFIG: FilterConfig[] = [ + { + columnId: "status", + label: "Status", + allLabel: "All Statuses", + options: [ + { label: "Lead", value: "lead" }, + { label: "Prospect", value: "prospect" }, + { label: "Qualified", value: "qualified" }, + { label: "Customer", value: "customer" }, + { label: "Churned", value: "churned" }, + ], + }, + { + columnId: "source", + label: "Source", + allLabel: "All Sources", + options: [ + { label: "Manual", value: "manual" }, + { label: "CSV import", value: "csv" }, + { label: "API", value: "api" }, + ], + }, +]; diff --git a/apps/web/components/shared/data-table.tsx b/apps/web/components/shared/data-table.tsx new file mode 100644 index 0000000..29bcd58 --- /dev/null +++ b/apps/web/components/shared/data-table.tsx @@ -0,0 +1,515 @@ +"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, + data, + pageCount, + pageIndex, + pageSize, + onPaginationChange, + sorting = [], + onSortingChange, + columnFilters = [], + onColumnFiltersChange, + searchPlaceholder = "Search...", + searchValue = "", + onSearchChange, + filterConfig = [], + isLoading = false, + isError = false, + errorTitle, + errorDescription, + onRetry, + enableRowSelection = false, + rowSelection = {}, + onRowSelectionChange, + getRowId, + onRowClick, + emptyTitle = "No results found", + emptyDescription = "Try adjusting your filters or search to find what you're looking for.", + toolbarActions, + className, +}: DataTableProps) { + // The only local state — column visibility doesn't affect server queries + const [columnVisibility, setColumnVisibility] = React.useState({}); + + 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, + }), + [], + ); + + const resolvedColumns = React.useMemo( + () => (enableRowSelection ? [selectionColumn, ...columns] : columns), + [columns, enableRowSelection, selectionColumn], + ); + + const table = useReactTable({ + data, + columns: resolvedColumns, + pageCount, + manualPagination: true, + manualSorting: true, + manualFiltering: true, + enableRowSelection, + state: { + pagination: { pageIndex, pageSize }, + sorting, + columnFilters, + rowSelection, + columnVisibility, + }, + onPaginationChange: (updater) => { + const next = typeof updater === "function" ? updater({ pageIndex, pageSize }) : 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); + + 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({ pageIndex: 0, pageSize }); + } + + function handleSearchChange(e: React.ChangeEvent) { + onSearchChange?.(e.target.value); + onPaginationChange({ pageIndex: 0, pageSize }); + } + + 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 {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount} +

+ + + + + + + + + + + + {pageNumbers.map((num, i) => + num === "ellipsis" ? ( + + + + ) : ( + + { + e.preventDefault(); + onPaginationChange({ pageIndex: num - 1, pageSize }); + }} + > + {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..5140118 --- /dev/null +++ b/apps/web/hooks/queries/use-orgs.ts @@ -0,0 +1,110 @@ +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, + Organization, + OrganizationsListParams, + OrganizationsListResponse, + UpdateOrganizationInput, +} from "@/types/crm"; + +function getOrganizationsListQueryKey(params: OrganizationsListParams = {}) { + return [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_LIST, params] as const; +} + +function getOrganizationDetailQueryKey(orgId: string) { + return [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_DETAIL, orgId] as const; +} + +export function useOrganizations(params: OrganizationsListParams = {}) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: getOrganizationsListQueryKey(params), + queryFn: () => listOrganizations(params), + enabled: !!session?.user, + placeholderData: (prev) => prev, + }); +} + +export function useOrg(orgId?: string | null) { + const { data: session } = useAuthSession(); + + return useQuery({ + queryKey: getOrganizationDetailQueryKey(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: (org) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.setQueryData(getOrganizationDetailQueryKey(org.id), org); + 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: (_, orgId) => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); + queryClient.removeQueries({ queryKey: getOrganizationDetailQueryKey(orgId) }); + 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..1304fe8 --- /dev/null +++ b/apps/web/hooks/queries/use-people.ts @@ -0,0 +1,98 @@ +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] }); + 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] }); + 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] }); + 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] }); + 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/use-debounce.ts b/apps/web/hooks/use-debounce.ts new file mode 100644 index 0000000..d39df3f --- /dev/null +++ b/apps/web/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} 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/services/crm/deals.service.ts b/apps/web/services/crm/deals.service.ts new file mode 100644 index 0000000..7774096 --- /dev/null +++ b/apps/web/services/crm/deals.service.ts @@ -0,0 +1,36 @@ +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, unwrapApiResponse } 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), + }); + + return unwrapApiResponse(response.data); +} + +export async function getDeal(id: string) { + const response = await apiClient.get(`/deals/${id}`); + return unwrapApiResponse(response.data).deal; +} + +export async function createDeal(input: CreateDeal) { + const response = await apiClient.post("/deals", input); + return unwrapApiResponse(response.data).deal; +} + +export async function updateDeal(id: string, input: UpdateDeal) { + const response = await apiClient.patch(`/deals/${id}`, input); + return unwrapApiResponse(response.data).deal; +} + +export async function deleteDeal(id: string) { + const response = await apiClient.delete(`/deals/${id}`); + return unwrapApiResponse(response.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..e7c2809 --- /dev/null +++ b/apps/web/services/crm/orgs.service.ts @@ -0,0 +1,51 @@ +import { apiClient } from "@/lib/axios-client"; +import { cleanQueryParams, unwrapApiResponse } 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), + }); + + return unwrapApiResponse(response.data); +} + +export async function getOrganization(id: OrgParams["id"]) { + const response = await apiClient.get(`/orgs/${id}`); + return unwrapApiResponse(response.data).org; +} + +export async function createOrganization(input: CreateOrg) { + const response = await apiClient.post("/orgs", input); + return unwrapApiResponse(response.data).org; +} + +export async function updateOrganization(id: OrgParams["id"], input: UpdateOrg) { + const response = await apiClient.patch(`/orgs/${id}`, input); + return unwrapApiResponse(response.data).org; +} + +export async function deleteOrganization(id: OrgParams["id"]) { + const response = await apiClient.delete(`/orgs/${id}`); + return unwrapApiResponse(response.data).org; +} + +export async function bulkDeleteOrganizations(input: BulkDeleteInput) { + const response = await apiClient.delete>("/orgs/bulk", { + data: input, + }); + + return unwrapApiResponse(response.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..be3158a --- /dev/null +++ b/apps/web/services/crm/people.service.ts @@ -0,0 +1,51 @@ +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, unwrapApiResponse } 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), + }); + + return unwrapApiResponse(response.data); +} + +export async function getPerson(id: PersonParams["id"]) { + const response = await apiClient.get(`/people/${id}`); + return unwrapApiResponse(response.data).person; +} + +export async function createPerson(input: CreatePerson) { + const response = await apiClient.post("/people", input); + return unwrapApiResponse(response.data).person; +} + +export async function updatePerson(id: PersonParams["id"], input: UpdatePerson) { + const response = await apiClient.patch(`/people/${id}`, input); + return unwrapApiResponse(response.data).person; +} + +export async function deletePerson(id: PersonParams["id"]) { + const response = await apiClient.delete(`/people/${id}`); + return unwrapApiResponse(response.data).person; +} + +export async function bulkDeletePeople(input: BulkDeleteInput) { + const response = await apiClient.delete("/people/bulk", { + data: input, + }); + + return unwrapApiResponse(response.data).deleted; +} diff --git a/apps/web/services/crm/utils.ts b/apps/web/services/crm/utils.ts new file mode 100644 index 0000000..f8a7b8a --- /dev/null +++ b/apps/web/services/crm/utils.ts @@ -0,0 +1,28 @@ +import type { ApiSuccessResponse } from "@workspace/validators/types/auth"; + +type NullableParamValue = string | number | boolean | null | undefined; +type ParamRecord = Record; + +export function buildQueryParams(params: TParams): URLSearchParams { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") { + continue; + } + + searchParams.set(key, String(value)); + } + + return searchParams; +} + +export function cleanQueryParams(params: TParams): Partial { + return Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== ""), + ) as Partial; +} + +export function unwrapApiResponse(response: ApiSuccessResponse): TData { + return response.data; +} 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/crm.validator.ts b/packages/validators/src/schemas/crm.validator.ts index d3ef4aa..bcbbc9c 100644 --- a/packages/validators/src/schemas/crm.validator.ts +++ b/packages/validators/src/schemas/crm.validator.ts @@ -1,6 +1,103 @@ import { z } from "zod"; import { dateLikeSchema, idSchema, nullableUuidSchema } from "./common.validator.js"; +const emptyStringToUndefined = (value: unknown) => { + if (typeof value === "string" && value.trim() === "") { + return undefined; + } + + return value; +}; + +const optionalTrimmedString = (max: number) => + z.preprocess(emptyStringToUndefined, z.string().trim().min(1).max(max).optional()); + +const optionalUuidFilter = z.preprocess(emptyStringToUndefined, z.string().uuid().optional()); + +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; + +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); + +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: z.preprocess(emptyStringToUndefined, personStatusSchema.optional()), + source: z.preprocess(emptyStringToUndefined, personSourceSchema.optional()), + ownerId: optionalUuidFilter, +}); + +export const listDealsQuerySchema = crmListQueryBaseSchema.extend({ + sortBy: dealSortBySchema.default("title"), + stage: z.preprocess(emptyStringToUndefined, dealStageSchema.optional()), + ownerId: optionalUuidFilter, +}); + +export const bulkDeleteSchema = z.object({ + ids: z.array(idSchema).min(1), +}); + export const createOrgSchema = z.object({ name: z.string().min(1).max(255), domain: z.string().max(255).optional(), @@ -21,8 +118,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 +134,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; From 58f0527adad8024504c36617a91328d7164e1f68 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Fri, 10 Apr 2026 20:37:54 +0545 Subject: [PATCH 02/15] chore: remove outdated CRM integration documentation --- CRM.md | 487 --------------------------------------------------------- 1 file changed, 487 deletions(-) delete mode 100644 CRM.md diff --git a/CRM.md b/CRM.md deleted file mode 100644 index 0a0e52b..0000000 --- a/CRM.md +++ /dev/null @@ -1,487 +0,0 @@ -# CRM Frontend Integration Plan - -## Overview - -Integrate CRM entities (People, Organizations, Deals) into the frontend using TanStack Table for data tables, ReUI Kanban for the deals board, and a shared EntitySheet for view/edit drawers. Server-side pagination, filtering, sorting, and search across all list endpoints. - ---- - -## Architecture Decisions - -| Decision | Choice | -| --------------- | -------------------------------------------------------------------------------------------------------------- | -| Pagination | Server-side, offset-based | -| Data table | Full-featured generic `DataTable` — owns toolbar, pagination, sorting, filtering, selection | -| Deals view | Kanban board only (no list toggle) — ReUI Kanban component | -| Row interaction | Click name → Sheet drawer in view mode; Edit button toggles edit mode; Add button → drawer in create/edit mode | -| Row actions | Dropdown menu column (View / Edit / Delete) + checkbox selection for bulk | -| Drawer | Shared `EntitySheet` component with per-entity form fields | -| Kanban data | Single `GET /deals` request, frontend groups by `stage` | -| Deal stage drag | Optimistic update with Tanstack Query `onMutate` + rollback on error | -| Import CSV | Deferred — remove buttons from initial build | -| Bulk delete | Single batch API call `DELETE /people/bulk { ids }` | -| Query keys | Flat string keys following existing pattern | -| File structure | Grouped by `crm/` subfolder, organized per entity | - ---- - -## Columns - -### People Table - -| Column | Source | Notes | -| -------------- | ---------------------- | ----------------------------------------------- | -| ☐ Checkbox | TanStack row selection | Bulk selection | -| Name | `name` | Clickable, opens drawer | -| Email | `email` | | -| Phone | `phone` | | -| Job Title | `jobTitle` | | -| Status | `status` | Badge: lead/prospect/qualified/customer/churned | -| Source | `source` | | -| Organization | `orgId` → org name | Relation, requires API join | -| Owner | `ownerId` → user name | Relation, requires API join | -| Last Contacted | `lastContactedAt` | Date formatting | -| ⋯ Actions | — | DropdownMenu: View / Edit / Delete | - -### Organizations Table - -| Column | Source | Notes | -| ---------- | ---------------------- | ---------------------------------- | -| ☐ Checkbox | TanStack row selection | Bulk selection | -| Name | `name` | Clickable, opens drawer | -| Domain | `domain` | | -| Industry | `industry` | | -| Size | `size` | | -| Location | `location` | | -| People | count via relation | Requires API join | -| ⋯ Actions | — | DropdownMenu: View / Edit / Delete | - -### Deals Kanban - -Stages (columns): `new` → `contacted` → `demo` → `proposal` → `won` → `lost` - -Card shows: Title, Value+Currency, Person name, Org name, Owner, Close date - ---- - -## File Structure - -``` -apps/web/ -├── services/crm/ -│ ├── people.service.ts # API calls for people CRUD + list -│ ├── orgs.service.ts # API calls for orgs CRUD + list -│ └── deals.service.ts # API calls for deals CRUD + list -│ -├── hooks/queries/ -│ ├── use-people.ts # usePeople, usePerson, useCreatePerson, useUpdatePerson, useDeletePerson, useBulkDeletePeople -│ ├── use-orgs.ts # useOrganizations, useOrg, useCreateOrg, useUpdateOrg, useDeleteOrg, useBulkDeleteOrgs -│ └── use-deals.ts # useDeals, useDeal, useCreateDeal, useUpdateDeal, useDeleteDeal -│ -├── components/ -│ ├── shared/ -│ │ ├── data-table.tsx # Generic DataTable with toolbar, pagination, sorting, filtering, selection -│ │ └── entity-sheet.tsx # Shared Sheet drawer (view/edit modes, action buttons, loading) -│ │ -│ └── crm/ -│ ├── people/ -│ │ ├── people-columns.tsx # Column definitions for TanStack Table -│ │ ├── people-data-table.tsx # People page wiring (DataTable + usePeople hook) -│ │ ├── people-drawer.tsx # People EntitySheet with person form fields -│ │ └── people-filters.tsx # Filter config for status, source, owner selects -│ │ -│ ├── orgs/ -│ │ ├── orgs-columns.tsx # Column definitions -│ │ ├── orgs-data-table.tsx # Orgs page wiring -│ │ ├── orgs-drawer.tsx # Orgs EntitySheet with org form fields -│ │ └── orgs-filters.tsx # Filter config for industry, size selects -│ │ -│ └── deals/ -│ ├── deal-kanban.tsx # Deal Kanban board using ReUI Kanban -│ ├── deal-card.tsx # Single deal card component -│ └── deal-drawer.tsx # Deals EntitySheet with deal form fields -│ -├── lib/ -│ └── query-keys.ts # Add CRM keys (see below) -│ -└── app/(crm)/ - ├── people/page.tsx # Updated: uses PeopleDataTable - ├── organizations/page.tsx # Updated: uses OrgsDataTable - └── deals/page.tsx # Updated: uses DealKanban -``` - ---- - -## Query Keys - -Add to existing `apps/web/lib/query-keys.ts`: - -```ts -// 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", -``` - -Hooks append params: `queryKey: [QUERY_KEYS.PEOPLE_LIST, params]` - ---- - -## API Contract Changes - -### All CRM List Endpoints — Add Pagination + Filtering + Search + Sorting - -Current: `GET /people` → `{ success: true, data: { people: [...] } }` - -New: - -``` -GET /people?page=1&pageSize=25&sortBy=name&sortOrder=asc&status=lead&search=john -GET /orgs?page=1&pageSize=25&sortBy=name&sortOrder=asc&industry=technology&search=acme -GET /deals?page=1&pageSize=25&sortBy=title&sortOrder=asc&stage=new&search=project -``` - -Response: - -```json -{ - "success": true, - "data": { - "people": [...], - "meta": { - "page": 1, - "pageSize": 25, - "totalCount": 142, - "totalPages": 6 - } - } -} -``` - -### List Endpoint Query Params - -| Param | Type | Example | Description | -| ----------------------- | ------ | -------------- | ------------------------------------------------------------------- | -| `page` | number | `1` | Page number (1-indexed) | -| `pageSize` | number | `25` | Items per page (default 25) | -| `sortBy` | string | `name` | Column name to sort by | -| `sortOrder` | string | `asc` / `desc` | Sort direction | -| `search` | string | `john` | Search term (searches name, email, etc.) | -| Entity-specific filters | | `status=lead` | People: status, source, ownerId. Orgs: industry, size. Deals: stage | - -### People List — Include Relations - -```sql -SELECT people.*, orgs.name AS org_name, users.name AS owner_name -FROM people -LEFT JOIN orgs ON people.org_id = orgs.id -LEFT JOIN users ON people.owner_id = users.id -WHERE people.workspace_id = ? -``` - -Response adds `orgName` and `ownerName` to each person object (or nested `org: { id, name }` and `owner: { id, name }`). - -### Orgs List — Include People Count - -```sql -SELECT orgs.*, COUNT(people.id) AS people_count -FROM orgs -LEFT JOIN people ON people.org_id = orgs.id -WHERE orgs.workspace_id = ? -GROUP BY orgs.id -``` - -### Deals List — Include Relations (already exists in `getDeal`) - -Extend `listDeals` to include relations like `getDeal` does: - -```ts -const results = await db.query.deals.findMany({ - where: eq(deals.workspaceId, workspaceId), - with: { - org: { columns: { id: true, name: true } }, - person: { columns: { id: true, name: true } }, - owner: { columns: { id: true, name: true } }, - }, -}); -``` - -### Bulk Delete Endpoints (New) - -``` -DELETE /people/bulk { ids: ["id1", "id2", "id3"] } -DELETE /orgs/bulk { ids: ["id1", "id2", "id3"] } -``` - -Response: `{ success: true, data: { deleted: 3 } }` - ---- - -## Implementation Phases - -### Phase 1: Foundation (Backend + Shared Components) - -**1.1 Backend — Pagination, Filtering, Sorting, Search** - -- Update `listPeople` controller to accept query params (page, pageSize, sortBy, sortOrder, status, source, ownerId, search) -- Update `listOrgs` controller to accept query params (page, pageSize, sortBy, sortOrder, industry, size, search) -- Update `listDeals` controller to accept query params (page, pageSize, sortBy, sortOrder, stage, search) -- Add `meta` object to all list responses (page, pageSize, totalCount, totalPages) -- Add relation includes to people list (org name, owner name) -- Add people count to orgs list -- Add relation includes to deals list (org, person, owner names) -- Update Zod validators for query param validation -- Update route handlers to pass query params to controllers - -**1.2 Backend — Bulk Delete** - -- Add `DELETE /people/bulk` route + controller -- Add `DELETE /orgs/bulk` route + controller -- Add Zod validators for bulk delete - -**1.3 Frontend — Shared DataTable Component** - -- Create `apps/web/components/shared/data-table.tsx` -- Generic `DataTable` props: - - `columns: ColumnDef[]` - - `data: TData[]` - - `pageCount: number` - - `pageIndex: number` - - `pageSize: number` - - `onPaginationChange: (pagination: PaginationState) => void` - - `onSortingChange: (sorting: SortingState) => void` - - `onColumnFiltersChange: (filters: ColumnFiltersState) => void` - - `searchPlaceholder?: string` - - `filterConfig?: FilterConfig[]` (defines which columns get filter dropdowns and their options) - - `onSearchChange: (search: string) => void` - - `isLoading?: boolean` - - `enableRowSelection?: boolean` - - `onRowClick?: (row: TData) => void` -- Internal toolbar: search input + filter dropdowns (from filterConfig) + column visibility toggle -- Footer pagination using existing shadcn Pagination component -- Loading state (Skeleton rows), Empty state (EmptyState component), Error state (ErrorState component) -- Checkbox column for row selection -- Actions column for dropdown menu - -**1.4 Frontend — Shared EntitySheet Component** - -- Create `apps/web/components/shared/entity-sheet.tsx` -- Props: - - `open: boolean` - - `onOpenChange: (open: boolean) => void` - - `title: string` - - `description?: string` - - `mode: "view" | "edit" | "create"` - - `isLoading?: boolean` - - `children: React.ReactNode` (the form fields or view content) - - `onEdit?: () => void` - - `onSave?: () => void` - - `onDelete?: () => void` -- View mode: read-only fields, Edit button in header -- Edit/Create mode: editable form fields, Save/Cancel buttons -- Uses shadcn `Sheet` component (already in packages/ui) - -**1.5 Frontend — Install ReUI Kanban** - -- Run `pnpm dlx shadcn@latest add @reui/kanban` in `apps/web` -- Verify it installs to `apps/web/components/reui/kanban.tsx` (or appropriate path based on shadcn config) - -**1.6 Frontend — Services** - -- Create `apps/web/services/crm/people.service.ts` -- Create `apps/web/services/crm/orgs.service.ts` -- Create `apps/web/services/crm/deals.service.ts` -- Each service: list (with params), get, create, update, delete, bulkDelete - -**1.7 Frontend — Query Keys + Hooks** - -- Update `apps/web/lib/query-keys.ts` with CRM keys -- Create `apps/web/hooks/queries/use-people.ts` -- Create `apps/web/hooks/queries/use-orgs.ts` -- Create `apps/web/hooks/queries/use-deals.ts` -- Each hook file: list query hook (with pagination params), detail query hook, create/update/delete/bulkDelete mutation hooks - -### Phase 2: People Page - -**2.1 Column Definitions** - -- Create `apps/web/components/crm/people/people-columns.tsx` -- TanStack Table `ColumnDef[]` for all People columns -- Name column: clickable cell (opens drawer) -- Status column: Badge with color variants -- Actions column: DropdownMenu with View/Edit/Delete - -**2.2 Filter Config** - -- Create `apps/web/components/crm/people/people-filters.tsx` -- Define filter options for Status, Source, Owner selects - -**2.3 People Drawer** - -- Create `apps/web/components/crm/people/people-drawer.tsx` -- EntitySheet with person form fields (name, email, phone, jobTitle, status, source, orgId select, ownerId select) -- View mode: read-only display -- Edit mode: react-hook-form + Zod validation -- Create mode: empty form - -**2.4 People Data Table** - -- Create `apps/web/components/crm/people/people-data-table.tsx` -- Wire DataTable + usePeople hook + column defs + filter config - -**2.5 People Page** - -- Update `apps/web/app/(crm)/people/page.tsx` -- Replace stub with PeopleDataTable -- Add "Add Person" button in PageHeader actions -- Handle drawer open/close state - -### Phase 3: Organizations Page - -**3.1–3.5** — Mirror Phase 2 pattern for Organizations entity - -### Phase 4: Deals Kanban Page - -**4.1 Deal Card Component** - -- Create `apps/web/components/crm/deals/deal-card.tsx` -- Displays: title, value+currency, person/org names, owner, close date -- Clickable (opens drawer) - -**4.2 Deal Drawer** - -- Create `apps/web/components/crm/deals/deal-drawer.tsx` -- EntitySheet with deal form fields (title, value, currency, stage, personId, orgId, ownerId, closeDate) - -**4.3 Deal Kanban Board** - -- Create `apps/web/components/crm/deals/deal-kanban.tsx` -- Uses ReUI Kanban components -- `value` = deals grouped by stage -- `onValueChange` = optimistic update + `useUpdateDeal` mutation -- `onItemClick` = open drawer -- Validate stage enum order: new → contacted → demo → proposal → won → lost - -**4.4 Deals Page** - -- Update `apps/web/app/(crm)/deals/page.tsx` -- Replace stub with DealKanban -- Remove board/list ToggleGroup (kanban only) -- Add "Add Deal" button in PageHeader - -### Phase 5: Polish & Integration - -- Remove Import CSV buttons from all pages (deferred) -- Add "Delete selected" bulk action to People and Orgs table toolbars (appears when rows selected) -- Ensure all error states, loading states, and empty states are wired up -- Test optimistic update rollback on failed deal stage changes -- Test pagination, sorting, and filtering across all tables -- Verify drawer view/edit/create flows for all entities -- Ensure query invalidation works correctly after mutations (list queries refresh after create/update/delete) - ---- - -## Key Component Interfaces - -### DataTable - -```tsx -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - pageCount: number; - pageIndex: number; - pageSize: number; - onPaginationChange: (pagination: PaginationState) => void; - onSortingChange: (sorting: SortingState) => void; - onColumnFiltersChange: (filters: ColumnFiltersState) => void; - searchPlaceholder?: string; - onSearchChange: (search: string) => void; - filterConfig?: FilterConfig[]; - enableRowSelection?: boolean; - onRowClick?: (row: TData) => void; - isLoading?: boolean; -} -``` - -### EntitySheet - -```tsx -interface EntitySheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; - title: string; - description?: string; - mode: "view" | "edit" | "create"; - isLoading?: boolean; - children: React.ReactNode; - onEdit?: () => void; - onSave?: () => void; - onDelete?: () => void; -} -``` - -### Service Function Pattern - -```tsx -// services/crm/people.service.ts -interface PeopleListParams { - page: number; - pageSize: number; - sortBy?: string; - sortOrder?: "asc" | "desc"; - search?: string; - status?: string; - source?: string; - ownerId?: string; -} - -async function listPeople( - params: PeopleListParams, -): Promise<{ people: Person[]; meta: PaginationMeta }>; -async function getPerson(id: string): Promise; -async function createPerson(data: CreatePerson): Promise; -async function updatePerson(id: string, data: UpdatePerson): Promise; -async function deletePerson(id: string): Promise; -async function bulkDeletePeople(ids: string[]): Promise<{ deleted: number }>; -``` - -### Hook Pattern - -```tsx -// hooks/queries/use-people.ts -function usePeople(params: PeopleListParams); // useQuery with [PEOPLE_LIST, params] -function usePerson(id: string); // useQuery with [PEOPLE_DETAIL, id] -function useCreatePerson(); // useMutation + invalidate [PEOPLE, PEOPLE_LIST] -function useUpdatePerson(); // useMutation + invalidate [PEOPLE, PEOPLE_LIST, PEOPLE_DETAIL, id] -function useDeletePerson(); // useMutation + invalidate [PEOPLE] -function useBulkDeletePeople(); // useMutation + invalidate [PEOPLE] -``` - ---- - -## Dependencies to Install - -```bash -# In apps/web -pnpm dlx shadcn@latest add @reui/kanban -``` - -Note: `@tanstack/react-table` is already installed. `@dnd-kit` packages can be removed since ReUI Kanban handles DnD internally. - ---- - -## Import CSV — Deferred - -The "Import CSV" buttons should be removed from initial build. This feature will be added later with: - -- File upload modal -- Column mapping step -- Batch creation endpoint -- Error reporting for failed rows From 7c2c2b8a019e2641d9faa297c26fcfeb2346cf79 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Sun, 12 Apr 2026 16:25:30 +0545 Subject: [PATCH 03/15] feat: enhance orgs and people listing with additional data and improved query structure --- apps/api/src/controllers/orgs.controller.ts | 119 +++++++----------- apps/api/src/controllers/people.controller.ts | 106 ++++++++-------- 2 files changed, 102 insertions(+), 123 deletions(-) diff --git a/apps/api/src/controllers/orgs.controller.ts b/apps/api/src/controllers/orgs.controller.ts index e2af3d2..138fc08 100644 --- a/apps/api/src/controllers/orgs.controller.ts +++ b/apps/api/src/controllers/orgs.controller.ts @@ -7,25 +7,21 @@ import type { } 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"; -function buildOrgWhereClause(workspaceId: string, query: ListOrgsQuery) { - const conditions = [eq(orgs.workspaceId, workspaceId)]; - - if (query.industry) { - conditions.push(eq(orgs.industry, query.industry)); - } - - if (query.size) { - conditions.push(eq(orgs.size, query.size)); - } +export async function listOrgs(c: Context, query: ListOrgsQuery) { + const workspaceId = getSessionWorkspaceId(c); + const { page, pageSize, sortOrder, sortBy, search, industry, size } = query; - if (query.search) { - const searchTerm = `%${query.search}%`; + 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), @@ -37,73 +33,54 @@ function buildOrgWhereClause(workspaceId: string, query: ListOrgsQuery) { ); } - return and(...conditions); -} - -function getOrgOrderBy(query: ListOrgsQuery) { - const direction = query.sortOrder === "desc" ? desc : asc; - - switch (query.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); - } -} - -export async function listOrgs(c: Context, query: ListOrgsQuery) { - const workspaceId = getSessionWorkspaceId(c); - const whereClause = buildOrgWhereClause(workspaceId, query); - const page = query.page; - const pageSize = query.pageSize; - const offset = (page - 1) * pageSize; - - const [results, totalCountResult] = await Promise.all([ - db.query.orgs.findMany({ - where: whereClause, - with: { - people: { - columns: { - id: true, - }, - }, - }, - orderBy: [getOrgOrderBy(query)], - limit: pageSize, - offset, - }), + 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 normalizedResults = results.map(({ people, ...org }) => ({ - ...org, - peopleCount: people.length, - })); + const peopleCountMap = new Map(peopleCounts.map((r) => [r.orgId, r.count])); return sendSuccess( c, { - orgs: normalizedResults, - meta: { - page, - pageSize, - totalCount, - totalPages, - }, + orgs: rows.map((org) => ({ ...org, peopleCount: peopleCountMap.get(org.id) ?? 0 })), + meta: { page, pageSize, totalCount, totalPages }, }, STATUS_CODES.OK, ); diff --git a/apps/api/src/controllers/people.controller.ts b/apps/api/src/controllers/people.controller.ts index e00dfc2..cd1842c 100644 --- a/apps/api/src/controllers/people.controller.ts +++ b/apps/api/src/controllers/people.controller.ts @@ -7,7 +7,7 @@ import type { } 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"; @@ -17,23 +17,24 @@ import { getSessionWorkspaceId } from "@/lib/workspace.js"; export async function listPeople(c: Context, query: ListPeopleQuery) { const workspaceId = getSessionWorkspaceId(c); const { page, pageSize, sortBy, sortOrder, status, source, ownerId, search } = query; + const offset = (page - 1) * pageSize; + + 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 filters = [ - eq(people.workspaceId, workspaceId), - status ? eq(people.status, status) : undefined, - source ? eq(people.source, source) : undefined, - ownerId ? eq(people.ownerId, ownerId) : undefined, - search - ? or( - ilike(people.name, `%${search}%`), - ilike(people.email, `%${search}%`), - ilike(people.phone, `%${search}%`), - ilike(people.jobTitle, `%${search}%`), - ) - : undefined, - ].filter((value): value is NonNullable => value !== undefined); - - const whereClause = and(...filters); + const whereClause = and(...conditions); const sortColumnMap = { name: people.name, @@ -47,30 +48,37 @@ export async function listPeople(c: Context, query: ListPeopleQuery) { updatedAt: people.updatedAt, } as const; - const orderColumn = sortColumnMap[sortBy]; - const offset = (page - 1) * pageSize; - - const [results, totalCountResult] = await Promise.all([ - db.query.people.findMany({ - where: whereClause, - with: { - org: { - columns: { - id: true, - name: true, - }, - }, - owner: { - columns: { - id: true, - name: true, - }, - }, - }, - orderBy: [sortOrder === "desc" ? desc(orderColumn) : asc(orderColumn)], - limit: pageSize, - offset, - }), + 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), ]); @@ -80,22 +88,16 @@ export async function listPeople(c: Context, query: ListPeopleQuery) { return sendSuccess( c, { - people: results.map((person) => ({ - ...person, - orgName: person.org?.name ?? null, - ownerName: person.owner?.name ?? null, + people: rows.map((row) => ({ + ...row, + orgName: row.orgName ?? null, + ownerName: row.ownerName ?? null, })), - meta: { - page, - pageSize, - totalCount, - totalPages, - }, + meta: { page, pageSize, totalCount, totalPages }, }, STATUS_CODES.OK, ); } - export async function getPerson(c: Context, id: string) { const workspaceId = getSessionWorkspaceId(c); const person = await db.query.people.findFirst({ From 44f3f1ddaea331857c1c7208bdb6c8533b504f98 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Sun, 12 Apr 2026 16:34:24 +0545 Subject: [PATCH 04/15] feat: refactor CRM validation schemas and introduce new types for better organization --- .../src/schemas/common.validator.ts | 5 -- .../validators/src/schemas/crm.validator.ts | 83 +++++-------------- packages/validators/src/types/crm.types.ts | 45 ++++++++++ 3 files changed, 65 insertions(+), 68 deletions(-) create mode 100644 packages/validators/src/types/crm.types.ts 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 bcbbc9c..d6d8234 100644 --- a/packages/validators/src/schemas/crm.validator.ts +++ b/packages/validators/src/schemas/crm.validator.ts @@ -1,65 +1,19 @@ import { z } from "zod"; import { dateLikeSchema, idSchema, nullableUuidSchema } from "./common.validator.js"; - -const emptyStringToUndefined = (value: unknown) => { - if (typeof value === "string" && value.trim() === "") { - return undefined; - } - - return value; -}; - -const optionalTrimmedString = (max: number) => - z.preprocess(emptyStringToUndefined, z.string().trim().min(1).max(max).optional()); - -const optionalUuidFilter = z.preprocess(emptyStringToUndefined, z.string().uuid().optional()); - -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; - +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); @@ -68,6 +22,7 @@ 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), @@ -83,21 +38,23 @@ export const listOrgsQuerySchema = crmListQueryBaseSchema.extend({ export const listPeopleQuerySchema = crmListQueryBaseSchema.extend({ sortBy: personSortBySchema.default("name"), - status: z.preprocess(emptyStringToUndefined, personStatusSchema.optional()), - source: z.preprocess(emptyStringToUndefined, personSourceSchema.optional()), + status: personStatusSchema.optional(), + source: personSourceSchema.optional(), ownerId: optionalUuidFilter, }); export const listDealsQuerySchema = crmListQueryBaseSchema.extend({ sortBy: dealSortBySchema.default("title"), - stage: z.preprocess(emptyStringToUndefined, dealStageSchema.optional()), + 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(), 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; From df48c65548c93c89cb0fdc572b93da6c1a03b911 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Sun, 12 Apr 2026 17:06:42 +0545 Subject: [PATCH 05/15] feat: simplify organization query hooks and remove unused query key functions --- apps/web/hooks/queries/use-orgs.ts | 32 +++++++----------------------- apps/web/services/crm/utils.ts | 18 +++-------------- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/apps/web/hooks/queries/use-orgs.ts b/apps/web/hooks/queries/use-orgs.ts index 5140118..e5c1654 100644 --- a/apps/web/hooks/queries/use-orgs.ts +++ b/apps/web/hooks/queries/use-orgs.ts @@ -13,36 +13,24 @@ import { import type { BulkDeleteInput, CreateOrganizationInput, - Organization, OrganizationsListParams, - OrganizationsListResponse, UpdateOrganizationInput, } from "@/types/crm"; -function getOrganizationsListQueryKey(params: OrganizationsListParams = {}) { - return [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_LIST, params] as const; -} - -function getOrganizationDetailQueryKey(orgId: string) { - return [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_DETAIL, orgId] as const; -} - export function useOrganizations(params: OrganizationsListParams = {}) { const { data: session } = useAuthSession(); - - return useQuery({ - queryKey: getOrganizationsListQueryKey(params), + return useQuery({ + queryKey: [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_LIST, params], queryFn: () => listOrganizations(params), enabled: !!session?.user, - placeholderData: (prev) => prev, + placeholderData: (previousData) => previousData, }); } export function useOrg(orgId?: string | null) { const { data: session } = useAuthSession(); - - return useQuery({ - queryKey: getOrganizationDetailQueryKey(orgId ?? ""), + return useQuery({ + queryKey: [QUERY_KEYS.ORGS, QUERY_KEYS.ORGS_DETAIL, orgId ?? ""], queryFn: () => getOrganization(orgId!), enabled: !!session?.user && !!orgId, }); @@ -50,7 +38,6 @@ export function useOrg(orgId?: string | null) { export function useCreateOrg() { const queryClient = useQueryClient(); - return useMutation({ mutationFn: (input: CreateOrganizationInput) => createOrganization(input), onSuccess: () => { @@ -64,12 +51,10 @@ export function useCreateOrg() { export function useUpdateOrg(orgId: string) { const queryClient = useQueryClient(); - return useMutation({ mutationFn: (input: UpdateOrganizationInput) => updateOrganization(orgId, input), - onSuccess: (org) => { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); - queryClient.setQueryData(getOrganizationDetailQueryKey(org.id), org); toast.success("Organization updated", { description: "The organization has been updated successfully.", }); @@ -79,12 +64,10 @@ export function useUpdateOrg(orgId: string) { export function useDeleteOrg() { const queryClient = useQueryClient(); - return useMutation({ mutationFn: (orgId: string) => deleteOrganization(orgId), - onSuccess: (_, orgId) => { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORGS] }); - queryClient.removeQueries({ queryKey: getOrganizationDetailQueryKey(orgId) }); toast.success("Organization deleted", { description: "The organization has been deleted successfully.", }); @@ -94,7 +77,6 @@ export function useDeleteOrg() { export function useBulkDeleteOrgs() { const queryClient = useQueryClient(); - return useMutation({ mutationFn: (input: BulkDeleteInput) => bulkDeleteOrganizations(input), onSuccess: (deletedCount) => { diff --git a/apps/web/services/crm/utils.ts b/apps/web/services/crm/utils.ts index f8a7b8a..cdcecc7 100644 --- a/apps/web/services/crm/utils.ts +++ b/apps/web/services/crm/utils.ts @@ -3,23 +3,11 @@ import type { ApiSuccessResponse } from "@workspace/validators/types/auth"; type NullableParamValue = string | number | boolean | null | undefined; type ParamRecord = Record; -export function buildQueryParams(params: TParams): URLSearchParams { - const searchParams = new URLSearchParams(); - - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === null || value === "") { - continue; - } - - searchParams.set(key, String(value)); - } - - return searchParams; -} - export function cleanQueryParams(params: TParams): Partial { return Object.fromEntries( - Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== ""), + Object.entries(params).filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ), ) as Partial; } From 7c4145c6d3dd590f43f4013a8581061c758c9aab Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Mon, 13 Apr 2026 07:50:09 +0545 Subject: [PATCH 06/15] feat: replace custom debounce hook with usehooks-ts and clean up API response handling --- .../components/crm/orgs/orgs-data-table.tsx | 10 +++------- .../crm/people/people-data-table.tsx | 17 ++++++++-------- apps/web/hooks/use-debounce.ts | 12 ----------- apps/web/package.json | 1 + apps/web/services/crm/deals.service.ts | 17 ++++++++++------ apps/web/services/crm/orgs.service.ts | 20 ++++++++++++------- apps/web/services/crm/people.service.ts | 20 ++++++++++++------- apps/web/services/crm/utils.ts | 6 ------ pnpm-lock.yaml | 19 ++++++++++++++++++ 9 files changed, 68 insertions(+), 54 deletions(-) delete mode 100644 apps/web/hooks/use-debounce.ts diff --git a/apps/web/components/crm/orgs/orgs-data-table.tsx b/apps/web/components/crm/orgs/orgs-data-table.tsx index 5cc4455..a11f5b6 100644 --- a/apps/web/components/crm/orgs/orgs-data-table.tsx +++ b/apps/web/components/crm/orgs/orgs-data-table.tsx @@ -10,12 +10,8 @@ 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 { useDebounce } from "@/hooks/use-debounce"; +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"; @@ -42,7 +38,7 @@ export function OrgsDataTable() { const [deleteTarget, setDeleteTarget] = useState(null); // Debounced search value drives the query; raw input drives the input element - const debouncedSearch = useDebounce(searchInput, 300); + const [debouncedSearch] = useDebounceValue(searchInput, 300); // Derive filter values from TanStack columnFilters const industryFilter = columnFilters.find((f) => f.id === "industry")?.value as diff --git a/apps/web/components/crm/people/people-data-table.tsx b/apps/web/components/crm/people/people-data-table.tsx index 62b10a9..4d3e55a 100644 --- a/apps/web/components/crm/people/people-data-table.tsx +++ b/apps/web/components/crm/people/people-data-table.tsx @@ -11,7 +11,7 @@ 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 { useDebounce } from "@/hooks/use-debounce"; +import { useDebounceValue } from "usehooks-ts"; import type { EntitySheetMode } from "@/components/shared/entity-sheet"; import type { Person, PeopleListParams } from "@/types/crm"; @@ -41,15 +41,11 @@ export function PeopleDataTable() { const [deleteTarget, setDeleteTarget] = useState(null); // Debounce search to avoid a query on every keystroke - const debouncedSearch = useDebounce(searchInput, 350); + const [debouncedSearch] = useDebounceValue(searchInput, 350); // Extract individual filter values from the TanStack ColumnFiltersState - const statusFilter = columnFilters.find((f) => f.id === "status")?.value as - | string - | undefined; - const sourceFilter = columnFilters.find((f) => f.id === "source")?.value as - | string - | undefined; + const statusFilter = columnFilters.find((f) => f.id === "status")?.value as string | undefined; + const sourceFilter = columnFilters.find((f) => f.id === "source")?.value as string | undefined; // Build the API query params from all state slices const queryParams = useMemo( @@ -79,7 +75,10 @@ export function PeopleDataTable() { // Selected row IDs (row keys come from getRowId which returns person.id) const selectedIds = useMemo( - () => Object.entries(rowSelection).filter(([, v]) => v).map(([id]) => id), + () => + Object.entries(rowSelection) + .filter(([, v]) => v) + .map(([id]) => id), [rowSelection], ); const selectedCount = selectedIds.length; diff --git a/apps/web/hooks/use-debounce.ts b/apps/web/hooks/use-debounce.ts deleted file mode 100644 index d39df3f..0000000 --- a/apps/web/hooks/use-debounce.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useEffect, useState } from "react"; - -export function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const timer = setTimeout(() => setDebouncedValue(value), delay); - return () => clearTimeout(timer); - }, [value, delay]); - - return debouncedValue; -} diff --git a/apps/web/package.json b/apps/web/package.json index 7b1d220..1150d27 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,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 index 7774096..d4c1582 100644 --- a/apps/web/services/crm/deals.service.ts +++ b/apps/web/services/crm/deals.service.ts @@ -2,7 +2,7 @@ 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, unwrapApiResponse } from "./utils"; +import { cleanQueryParams } from "./utils"; type DealResponse = ApiSuccessResponse<{ deal: Deal }>; type DealsResponse = ApiSuccessResponse; @@ -12,25 +12,30 @@ export async function listDeals(params: Partial = {}) { params: cleanQueryParams(params), }); - return unwrapApiResponse(response.data); + const { data } = response.data; + return data; } export async function getDeal(id: string) { const response = await apiClient.get(`/deals/${id}`); - return unwrapApiResponse(response.data).deal; + const { data } = response.data; + return data.deal; } export async function createDeal(input: CreateDeal) { const response = await apiClient.post("/deals", input); - return unwrapApiResponse(response.data).deal; + const { data } = response.data; + return data.deal; } export async function updateDeal(id: string, input: UpdateDeal) { const response = await apiClient.patch(`/deals/${id}`, input); - return unwrapApiResponse(response.data).deal; + const { data } = response.data; + return data.deal; } export async function deleteDeal(id: string) { const response = await apiClient.delete(`/deals/${id}`); - return unwrapApiResponse(response.data).deal; + 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 index e7c2809..8e76443 100644 --- a/apps/web/services/crm/orgs.service.ts +++ b/apps/web/services/crm/orgs.service.ts @@ -1,5 +1,5 @@ import { apiClient } from "@/lib/axios-client"; -import { cleanQueryParams, unwrapApiResponse } from "@/services/crm/utils"; +import { cleanQueryParams } from "@/services/crm/utils"; import type { BulkDeleteInput, CreateOrg, @@ -19,27 +19,32 @@ export async function listOrganizations(params: Partial = {}) { params: cleanQueryParams(params), }); - return unwrapApiResponse(response.data); + const { data } = response.data; + return data; } export async function getOrganization(id: OrgParams["id"]) { const response = await apiClient.get(`/orgs/${id}`); - return unwrapApiResponse(response.data).org; + const { data } = response.data; + return data.org; } export async function createOrganization(input: CreateOrg) { const response = await apiClient.post("/orgs", input); - return unwrapApiResponse(response.data).org; + const { data } = response.data; + return data.org; } export async function updateOrganization(id: OrgParams["id"], input: UpdateOrg) { const response = await apiClient.patch(`/orgs/${id}`, input); - return unwrapApiResponse(response.data).org; + const { data } = response.data; + return data.org; } export async function deleteOrganization(id: OrgParams["id"]) { const response = await apiClient.delete(`/orgs/${id}`); - return unwrapApiResponse(response.data).org; + const { data } = response.data; + return data.org; } export async function bulkDeleteOrganizations(input: BulkDeleteInput) { @@ -47,5 +52,6 @@ export async function bulkDeleteOrganizations(input: BulkDeleteInput) { data: input, }); - return unwrapApiResponse(response.data).deleted; + 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 index be3158a..9b21fba 100644 --- a/apps/web/services/crm/people.service.ts +++ b/apps/web/services/crm/people.service.ts @@ -8,7 +8,7 @@ import type { UpdatePerson, } from "@workspace/validators/schemas/crm"; import type { Person, PeopleListResponse } from "@/types/crm"; -import { cleanQueryParams, unwrapApiResponse } from "./utils"; +import { cleanQueryParams } from "./utils"; type PersonResponse = ApiSuccessResponse<{ person: Person }>; type PeopleResponse = ApiSuccessResponse; @@ -19,27 +19,32 @@ export async function listPeople(params: Partial = {}) { params: cleanQueryParams(params), }); - return unwrapApiResponse(response.data); + const { data } = response.data; + return data; } export async function getPerson(id: PersonParams["id"]) { const response = await apiClient.get(`/people/${id}`); - return unwrapApiResponse(response.data).person; + const { data } = response.data; + return data.person; } export async function createPerson(input: CreatePerson) { const response = await apiClient.post("/people", input); - return unwrapApiResponse(response.data).person; + const { data } = response.data; + return data.person; } export async function updatePerson(id: PersonParams["id"], input: UpdatePerson) { const response = await apiClient.patch(`/people/${id}`, input); - return unwrapApiResponse(response.data).person; + const { data } = response.data; + return data.person; } export async function deletePerson(id: PersonParams["id"]) { const response = await apiClient.delete(`/people/${id}`); - return unwrapApiResponse(response.data).person; + const { data } = response.data; + return data.person; } export async function bulkDeletePeople(input: BulkDeleteInput) { @@ -47,5 +52,6 @@ export async function bulkDeletePeople(input: BulkDeleteInput) { data: input, }); - return unwrapApiResponse(response.data).deleted; + const { data } = response.data; + return data.deleted; } diff --git a/apps/web/services/crm/utils.ts b/apps/web/services/crm/utils.ts index cdcecc7..6f76783 100644 --- a/apps/web/services/crm/utils.ts +++ b/apps/web/services/crm/utils.ts @@ -1,5 +1,3 @@ -import type { ApiSuccessResponse } from "@workspace/validators/types/auth"; - type NullableParamValue = string | number | boolean | null | undefined; type ParamRecord = Record; @@ -10,7 +8,3 @@ export function cleanQueryParams(params: TParams): ), ) as Partial; } - -export function unwrapApiResponse(response: ApiSuccessResponse): TData { - return response.data; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d4d3f0..d74bddd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,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)) @@ -3156,6 +3159,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 +3827,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==} @@ -6531,6 +6543,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.debounce@4.0.8: {} + lodash.merge@4.6.2: {} lodash@4.17.23: {} @@ -7325,6 +7339,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 From b95e4d9d3b09c61e614e14d2d67f1f5931c17160 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Thu, 16 Apr 2026 06:39:00 +0545 Subject: [PATCH 07/15] Refactor CRM components to use centralized options and improve code organization - Moved industry and size options for organizations to a new `crm-options.ts` file. - Centralized person status and source options in `crm-options.ts`. - Replaced hardcoded options in filters with imported options. - Simplified the `getPeopleColumns` function by using a new `CrmRowActions` component for row actions. - Updated date formatting in various components to use `dayjs` for consistency. - Refactored state management in `PeopleDataTable` to use a single state object for table and UI states. - Cleaned up unused imports and helper functions in `people-columns.tsx` and `people-drawer.tsx`. - Improved the handling of drawer modes and form values in `PeopleDrawer`. - Enhanced pagination logic in `DataTable` to improve readability and maintainability. --- apps/web/components/crm/crm-options.ts | 58 ++++++ apps/web/components/crm/crm-row-actions.tsx | 50 +++++ apps/web/components/crm/orgs/orgs-columns.tsx | 68 ++----- .../components/crm/orgs/orgs-data-table.tsx | 171 ++++++++-------- apps/web/components/crm/orgs/orgs-drawer.tsx | 67 ++----- apps/web/components/crm/orgs/orgs-filters.tsx | 19 +- .../components/crm/people/people-columns.tsx | 120 +++--------- .../crm/people/people-data-table.tsx | 182 ++++++++++-------- .../components/crm/people/people-drawer.tsx | 131 ++++++------- .../components/crm/people/people-filters.tsx | 15 +- apps/web/components/shared/data-table.tsx | 32 +-- apps/web/package.json | 1 + pnpm-lock.yaml | 11 ++ pnpm-workspace.yaml | 1 + 14 files changed, 457 insertions(+), 469 deletions(-) create mode 100644 apps/web/components/crm/crm-options.ts create mode 100644 apps/web/components/crm/crm-row-actions.tsx 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/orgs/orgs-columns.tsx b/apps/web/components/crm/orgs/orgs-columns.tsx index 0dbec83..22bc9d1 100644 --- a/apps/web/components/crm/orgs/orgs-columns.tsx +++ b/apps/web/components/crm/orgs/orgs-columns.tsx @@ -1,15 +1,8 @@ "use client"; import type { ColumnDef } from "@tanstack/react-table"; -import { Building2, 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"; +import { Building2 } from "lucide-react"; +import { CrmRowActions } from "@/components/crm/crm-row-actions"; import type { Organization } from "@/types/crm"; interface GetOrgsColumnsProps { @@ -23,6 +16,8 @@ export function getOrgsColumns({ onEdit, onDelete, }: GetOrgsColumnsProps): ColumnDef[] { + const emptyCell = ; + return [ { id: "name", @@ -51,7 +46,7 @@ export function getOrgsColumns({ row.original.domain ? ( {row.original.domain} ) : ( - + emptyCell ), }, { @@ -63,7 +58,7 @@ export function getOrgsColumns({ row.original.industry ? ( {row.original.industry} ) : ( - + emptyCell ), }, { @@ -71,12 +66,7 @@ export function getOrgsColumns({ accessorKey: "size", header: "Size", enableSorting: true, - cell: ({ row }) => - row.original.size ? ( - {row.original.size} - ) : ( - - ), + cell: ({ row }) => (row.original.size ? {row.original.size} : emptyCell), }, { id: "location", @@ -87,7 +77,7 @@ export function getOrgsColumns({ row.original.location ? ( {row.original.location} ) : ( - + emptyCell ), }, { @@ -96,11 +86,7 @@ export function getOrgsColumns({ enableSorting: false, cell: ({ row }) => { const count = row.original.peopleCount ?? 0; - return ( - - {count} - - ); + return {count}; }, }, { @@ -108,36 +94,12 @@ export function getOrgsColumns({ enableSorting: false, enableHiding: false, cell: ({ row }) => ( - - - - - - onView(row.original)}> - - View - - onEdit(row.original)}> - - Edit - - - onDelete(row.original)} - > - - Delete - - - + 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 index a11f5b6..b6b5682 100644 --- a/apps/web/components/crm/orgs/orgs-data-table.tsx +++ b/apps/web/components/crm/orgs/orgs-data-table.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +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"; @@ -15,52 +15,59 @@ import { useDebounceValue } from "usehooks-ts"; import type { Organization, OrganizationsListParams } from "@/types/crm"; import type { EntitySheetMode } from "@/components/shared/entity-sheet"; -// ─── Types ───────────────────────────────────────────────────────────────── - type DrawerState = { open: boolean; mode: EntitySheetMode; org?: Organization; }; -// ─── Component ────────────────────────────────────────────────────────────── +type OrgsTableState = { + pagination: { pageIndex: number; pageSize: number }; + sorting: SortingState; + columnFilters: ColumnFiltersState; + searchInput: string; + rowSelection: RowSelectionState; +}; -export function OrgsDataTable() { - // Table state - const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - const [searchInput, setSearchInput] = useState(""); - const [rowSelection, setRowSelection] = useState({}); +type OrgsUiState = { + drawer: DrawerState; + deleteTarget: Organization | "bulk" | null; +}; - // Drawer / delete state - const [drawer, setDrawer] = useState({ open: false, mode: "view" }); - const [deleteTarget, setDeleteTarget] = useState(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(searchInput, 300); + const [debouncedSearch] = useDebounceValue(table.searchInput, 300); // Derive filter values from TanStack columnFilters - const industryFilter = columnFilters.find((f) => f.id === "industry")?.value as + const industryFilter = table.columnFilters.find((f) => f.id === "industry")?.value as | string | undefined; - const sizeFilter = columnFilters.find((f) => f.id === "size")?.value as string | undefined; + const sizeFilter = table.columnFilters.find((f) => f.id === "size")?.value as string | undefined; // Build server query params - const queryParams = useMemo( - () => ({ - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - ...(sorting[0] && { - sortBy: sorting[0].id as OrganizationsListParams["sortBy"], - sortOrder: sorting[0].desc ? "desc" : "asc", - }), - ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), - ...(industryFilter && { industry: industryFilter }), - ...(sizeFilter && { size: sizeFilter }), + 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", }), - [pagination, sorting, debouncedSearch, industryFilter, sizeFilter], - ); + ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), + ...(industryFilter && { industry: industryFilter }), + ...(sizeFilter && { size: sizeFilter }), + }; // Data + mutations const { data, isLoading, isError, refetch } = useOrganizations(queryParams); @@ -71,51 +78,50 @@ export function OrgsDataTable() { const totalCount = data?.meta.totalCount ?? 0; const pageCount = data?.meta.totalPages ?? 0; - const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]); + const selectedIds = Object.keys(table.rowSelection).filter((id) => table.rowSelection[id]); const selectedCount = selectedIds.length; const isDeletePending = isBulkDeleting || isDeleting; - // Stable column callbacks (setters are always stable) - const handleView = useCallback( - (org: Organization) => setDrawer({ open: true, mode: "view", org }), - [], - ); - const handleEdit = useCallback( - (org: Organization) => setDrawer({ open: true, mode: "edit", org }), - [], - ); - const handleDeleteRow = useCallback((org: Organization) => setDeleteTarget(org), []); + function updateTable(next: Partial) { + setTable((current) => ({ ...current, ...next })); + } - const columns = useMemo( - () => getOrgsColumns({ onView: handleView, onEdit: handleEdit, onDelete: handleDeleteRow }), - [handleView, handleEdit, handleDeleteRow], - ); + 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) { - setSorting(next); - setPagination((p) => ({ ...p, pageIndex: 0 })); - } - - function handleSearchChange(value: string) { - setSearchInput(value); - setPagination((p) => ({ ...p, pageIndex: 0 })); + setTable((current) => ({ + ...current, + sorting: next, + pagination: { ...current.pagination, pageIndex: 0 }, + })); } function handleConfirmDelete() { - if (deleteTarget === "bulk") { + if (ui.deleteTarget === "bulk") { bulkDeleteMutate( { ids: selectedIds }, { onSuccess: () => { - setRowSelection({}); - setDeleteTarget(null); + setTable((current) => ({ ...current, rowSelection: {} })); + setUi((current) => ({ ...current, deleteTarget: null })); }, }, ); - } else if (deleteTarget) { - deleteOrgMutate(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), + } else if (ui.deleteTarget) { + deleteOrgMutate(ui.deleteTarget.id, { + onSuccess: () => setUi((current) => ({ ...current, deleteTarget: null })), }); } } @@ -126,7 +132,7 @@ export function OrgsDataTable() { @@ -163,15 +169,15 @@ export function OrgsDataTable() { columns={columns} data={orgs} pageCount={pageCount} - pageIndex={pagination.pageIndex} - pageSize={pagination.pageSize} - onPaginationChange={setPagination} - sorting={sorting} + pageIndex={table.pagination.pageIndex} + pageSize={table.pagination.pageSize} + onPaginationChange={(pagination) => updateTable({ pagination })} + sorting={table.sorting} onSortingChange={handleSortingChange} - columnFilters={columnFilters} - onColumnFiltersChange={setColumnFilters} - searchValue={searchInput} - onSearchChange={handleSearchChange} + columnFilters={table.columnFilters} + onColumnFiltersChange={(columnFilters) => updateTable({ columnFilters })} + searchValue={table.searchInput} + onSearchChange={(searchInput) => updateTable({ searchInput })} searchPlaceholder="Search organizations…" filterConfig={ORGS_FILTER_CONFIG} isLoading={isLoading} @@ -180,26 +186,31 @@ export function OrgsDataTable() { errorDescription="There was a problem fetching your organizations." onRetry={refetch} enableRowSelection - rowSelection={rowSelection} - onRowSelectionChange={setRowSelection} + rowSelection={table.rowSelection} + onRowSelectionChange={(rowSelection) => updateTable({ rowSelection })} getRowId={(row) => row.id} - onRowClick={handleView} + onRowClick={(org) => openDrawer("view", org)} emptyTitle="No organizations yet" emptyDescription="Add your first organization to start tracking companies in your CRM." toolbarActions={toolbarActions} /> setDrawer((s) => ({ ...s, open }))} - initialMode={drawer.mode} - org={drawer.org} + open={ui.drawer.open} + onOpenChange={(open) => + 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) setDeleteTarget(null); + if (!open) setUi((current) => ({ ...current, deleteTarget: null })); }} title={confirmTitle} description={confirmDescription} diff --git a/apps/web/components/crm/orgs/orgs-drawer.tsx b/apps/web/components/crm/orgs/orgs-drawer.tsx index f1b0b1b..e4e702b 100644 --- a/apps/web/components/crm/orgs/orgs-drawer.tsx +++ b/apps/web/components/crm/orgs/orgs-drawer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Building2 } from "lucide-react"; @@ -25,26 +25,7 @@ import { Separator } from "@workspace/ui/components/ui/separator"; import { EntitySheet, type EntitySheetMode } from "@/components/shared/entity-sheet"; import { useCreateOrg, useUpdateOrg, useDeleteOrg } from "@/hooks/queries/use-orgs"; import type { Organization } from "@/types/crm"; - -// ─── Constants ──────────────────────────────────────────────────────────────── - -const 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" }, -] as const; - -const 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+" }, -] as const; +import { ORG_INDUSTRY_OPTIONS, ORG_SIZE_OPTIONS } from "@/components/crm/crm-options"; // ─── View helpers ───────────────────────────────────────────────────────────── @@ -198,7 +179,7 @@ function OrgForm({ Not specified - {INDUSTRY_OPTIONS.map((opt) => ( + {ORG_INDUSTRY_OPTIONS.map((opt) => ( {opt.label} @@ -228,7 +209,7 @@ function OrgForm({ Not specified - {SIZE_OPTIONS.map((opt) => ( + {ORG_SIZE_OPTIONS.map((opt) => ( {opt.label} @@ -270,42 +251,34 @@ function OrgForm({ interface OrgDrawerProps { open: boolean; onOpenChange: (open: boolean) => void; - initialMode: EntitySheetMode; + mode: EntitySheetMode; + onModeChange: (mode: EntitySheetMode) => void; org?: Organization; } -function getDefaultValues(org?: Organization): CreateOrg { - return { - name: org?.name ?? "", - domain: org?.domain ?? "", - industry: org?.industry ?? "", - size: org?.size ?? "", - location: org?.location ?? "", - }; -} - -export function OrgDrawer({ open, onOpenChange, initialMode, org }: OrgDrawerProps) { - const [mode, setMode] = useState(initialMode); - +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), - defaultValues: getDefaultValues(org), + values: formValues, }); - // Reset form and mode whenever the drawer opens - useEffect(() => { - if (!open) return; - setMode(initialMode); - form.reset(getDefaultValues(org)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, org?.id, initialMode]); - function onSubmit(values: CreateOrg) { // Strip empty optional strings to undefined const payload: CreateOrg = { @@ -350,7 +323,7 @@ export function OrgDrawer({ open, onOpenChange, initialMode, org }: OrgDrawerPro description={description} mode={mode} isSaving={isPending} - onEdit={mode === "view" ? () => setMode("edit") : undefined} + onEdit={mode === "view" ? () => onModeChange("edit") : undefined} onSave={mode !== "view" ? form.handleSubmit(onSubmit) : undefined} onDelete={mode === "view" && org ? handleDelete : undefined} deleteLabel={isDeleting ? "Deleting…" : "Delete"} diff --git a/apps/web/components/crm/orgs/orgs-filters.tsx b/apps/web/components/crm/orgs/orgs-filters.tsx index 8e3f2bc..1430c13 100644 --- a/apps/web/components/crm/orgs/orgs-filters.tsx +++ b/apps/web/components/crm/orgs/orgs-filters.tsx @@ -1,30 +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: [ - { 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" }, - ], + options: ORG_INDUSTRY_OPTIONS, }, { columnId: "size", label: "Size", allLabel: "Any 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+" }, - ], + options: ORG_SIZE_OPTIONS, }, ]; diff --git a/apps/web/components/crm/people/people-columns.tsx b/apps/web/components/crm/people/people-columns.tsx index b15d04a..82201bd 100644 --- a/apps/web/components/crm/people/people-columns.tsx +++ b/apps/web/components/crm/people/people-columns.tsx @@ -1,60 +1,12 @@ "use client"; +import dayjs from "dayjs"; import type { ColumnDef } from "@tanstack/react-table"; -import { Eye, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { Badge } from "@workspace/ui/components/ui/badge"; -import { Button } from "@workspace/ui/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@workspace/ui/components/ui/dropdown-menu"; import { cn } from "@workspace/ui/lib/utils"; -import type { PersonStatus } from "@workspace/validators/schemas/crm"; +import { CrmRowActions } from "@/components/crm/crm-row-actions"; import type { Person } from "@/types/crm"; - -// ─── Status config ─────────────────────────────────────────────────────────── - -export const PERSON_STATUS_CONFIG: Record = { - lead: { - label: "Lead", - className: - "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300 border-transparent", - }, - prospect: { - label: "Prospect", - className: - "bg-blue-100 text-blue-700 dark:bg-blue-950/70 dark:text-blue-300 border-transparent", - }, - qualified: { - label: "Qualified", - className: - "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/70 dark:text-emerald-300 border-transparent", - }, - customer: { - label: "Customer", - className: - "bg-violet-100 text-violet-700 dark:bg-violet-950/70 dark:text-violet-300 border-transparent", - }, - churned: { - label: "Churned", - className: - "bg-rose-100 text-rose-700 dark:bg-rose-950/70 dark:text-rose-300 border-transparent", - }, -}; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function formatDate(value: string | null | undefined): string { - if (!value) return "—"; - return new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }).format(new Date(value)); -} +import { PERSON_STATUS_OPTIONS } from "@/components/crm/crm-options"; // ─── Column factory ────────────────────────────────────────────────────────── @@ -69,6 +21,8 @@ export function getPeopleColumns({ onEdit, onDelete, }: GetPeopleColumnsProps): ColumnDef[] { + const emptyCell = ; + return [ { id: "name", @@ -97,7 +51,7 @@ export function getPeopleColumns({ {row.original.email} ) : ( - + emptyCell ), }, { @@ -109,7 +63,7 @@ export function getPeopleColumns({ row.original.phone ? ( {row.original.phone} ) : ( - + emptyCell ), }, { @@ -121,7 +75,7 @@ export function getPeopleColumns({ row.original.jobTitle ? ( {row.original.jobTitle} ) : ( - + emptyCell ), }, { @@ -130,10 +84,12 @@ export function getPeopleColumns({ header: "Status", enableSorting: true, cell: ({ row }) => { - const { status } = row.original; - const config = PERSON_STATUS_CONFIG[status]; + const config = PERSON_STATUS_OPTIONS.find((option) => option.value === row.original.status); + if (!config) return null; return ( - {config.label} + + {config.label} + ); }, }, @@ -152,11 +108,7 @@ export function getPeopleColumns({ enableSorting: false, cell: ({ row }) => { const name = row.original.orgName ?? row.original.org?.name; - return name ? ( - {name} - ) : ( - - ); + return name ? {name} : emptyCell; }, }, { @@ -165,11 +117,7 @@ export function getPeopleColumns({ enableSorting: false, cell: ({ row }) => { const name = row.original.ownerName ?? row.original.owner?.name; - return name ? ( - {name} - ) : ( - - ); + return name ? {name} : emptyCell; }, }, { @@ -179,7 +127,9 @@ export function getPeopleColumns({ enableSorting: true, cell: ({ row }) => ( - {formatDate(row.original.lastContactedAt)} + {row.original.lastContactedAt + ? dayjs(row.original.lastContactedAt).format("MMM D, YYYY") + : "—"} ), }, @@ -188,33 +138,13 @@ export function getPeopleColumns({ enableSorting: false, enableHiding: false, cell: ({ row }) => ( - - - - - - onView(row.original)}> - - View - - onEdit(row.original)}> - - Edit - - - onDelete(row.original)}> - - Delete - - - + 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 index 4d3e55a..afd245a 100644 --- a/apps/web/components/crm/people/people-data-table.tsx +++ b/apps/web/components/crm/people/people-data-table.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +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"; @@ -26,42 +26,56 @@ type DrawerState = { // 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() { - // Table state - const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - const [rowSelection, setRowSelection] = useState({}); - const [searchInput, setSearchInput] = useState(""); - - // UI state - const [drawer, setDrawer] = useState({ open: false, mode: "view" }); - const [deleteTarget, setDeleteTarget] = useState(null); + 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(searchInput, 350); + const [debouncedSearch] = useDebounceValue(table.searchInput, 350); // Extract individual filter values from the TanStack ColumnFiltersState - const statusFilter = columnFilters.find((f) => f.id === "status")?.value as string | undefined; - const sourceFilter = columnFilters.find((f) => f.id === "source")?.value as string | undefined; - - // Build the API query params from all state slices - const queryParams = useMemo( - () => ({ - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - ...(sorting[0] && { - sortBy: sorting[0].id as PeopleListParams["sortBy"], - sortOrder: sorting[0].desc ? "desc" : "asc", - }), - ...(debouncedSearch.trim() && { search: debouncedSearch.trim() }), - ...(statusFilter && { status: statusFilter as PeopleListParams["status"] }), - ...(sourceFilter && { source: sourceFilter as PeopleListParams["source"] }), + 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", }), - [pagination, sorting, debouncedSearch, statusFilter, sourceFilter], - ); + ...(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); @@ -74,53 +88,51 @@ export function PeopleDataTable() { const { mutate: bulkDelete, isPending: isBulkDeleting } = useBulkDeletePeople(); // Selected row IDs (row keys come from getRowId which returns person.id) - const selectedIds = useMemo( - () => - Object.entries(rowSelection) - .filter(([, v]) => v) - .map(([id]) => id), - [rowSelection], - ); + const selectedIds = Object.entries(table.rowSelection) + .filter(([, value]) => value) + .map(([id]) => id); const selectedCount = selectedIds.length; - // ─── Drawer helpers ────────────────────────────────────────────────────────── - - const openDrawer = useCallback((mode: EntitySheetMode, person?: Person) => { - setDrawer({ open: true, mode, person }); - }, []); + function updateTable(next: Partial) { + setTable((current) => ({ ...current, ...next })); + } - const closeDrawer = useCallback(() => { - setDrawer((prev) => ({ ...prev, open: false })); - }, []); + function openDrawer(mode: EntitySheetMode, person?: Person) { + setUi((current) => ({ + ...current, + drawer: { open: true, mode, person }, + })); + } - // ─── Columns (memoised so identity is stable across renders) ───────────────── + function closeDrawer() { + setUi((current) => ({ + ...current, + drawer: { ...current.drawer, open: false }, + })); + } - const columns = useMemo( - () => - getPeopleColumns({ - onView: (person) => openDrawer("view", person), - onEdit: (person) => openDrawer("edit", person), - onDelete: (person) => setDeleteTarget(person), - }), - [openDrawer], - ); + const columns = getPeopleColumns({ + onView: (person) => openDrawer("view", person), + onEdit: (person) => openDrawer("edit", person), + onDelete: (person) => setUi((current) => ({ ...current, deleteTarget: person })), + }); // ─── Delete ────────────────────────────────────────────────────────────────── function handleDeleteConfirm() { - if (deleteTarget === "bulk") { + if (ui.deleteTarget === "bulk") { bulkDelete( { ids: selectedIds }, { onSuccess: () => { - setRowSelection({}); - setDeleteTarget(null); + setTable((current) => ({ ...current, rowSelection: {} })); + setUi((current) => ({ ...current, deleteTarget: null })); }, }, ); - } else if (deleteTarget) { - deletePerson(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), + } else if (ui.deleteTarget) { + deletePerson(ui.deleteTarget.id, { + onSuccess: () => setUi((current) => ({ ...current, deleteTarget: null })), }); } } @@ -128,17 +140,17 @@ export function PeopleDataTable() { const isDeletePending = isDeleting || isBulkDeleting; const confirmDialogCopy = - deleteTarget === "bulk" + 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.`, } - : deleteTarget + : ui.deleteTarget ? { - title: `Delete "${deleteTarget.name}"?`, - description: `This will permanently remove ${deleteTarget.name} from your CRM. This action cannot be undone.`, + title: `Delete "${ui.deleteTarget.name}"?`, + description: `This will permanently remove ${ui.deleteTarget.name} from your CRM. This action cannot be undone.`, } : { title: "", description: "" }; @@ -162,18 +174,21 @@ export function PeopleDataTable() { columns={columns} data={people} pageCount={pageCount} - pageIndex={pagination.pageIndex} - pageSize={pagination.pageSize} - onPaginationChange={setPagination} - sorting={sorting} + pageIndex={table.pagination.pageIndex} + pageSize={table.pagination.pageSize} + onPaginationChange={(pagination) => updateTable({ pagination })} + sorting={table.sorting} onSortingChange={(next) => { - setSorting(next); - setPagination((p) => ({ ...p, pageIndex: 0 })); + setTable((current) => ({ + ...current, + sorting: next, + pagination: { ...current.pagination, pageIndex: 0 }, + })); }} - columnFilters={columnFilters} - onColumnFiltersChange={setColumnFilters} - searchValue={searchInput} - onSearchChange={setSearchInput} + columnFilters={table.columnFilters} + onColumnFiltersChange={(columnFilters) => updateTable({ columnFilters })} + searchValue={table.searchInput} + onSearchChange={(searchInput) => updateTable({ searchInput })} searchPlaceholder="Search people…" filterConfig={PEOPLE_FILTER_CONFIG} isLoading={isLoading} @@ -182,8 +197,8 @@ export function PeopleDataTable() { errorDescription="There was a problem loading your contacts. Please try again." onRetry={refetch} enableRowSelection - rowSelection={rowSelection} - onRowSelectionChange={setRowSelection} + rowSelection={table.rowSelection} + onRowSelectionChange={(rowSelection) => updateTable({ rowSelection })} getRowId={(row) => row.id} onRowClick={(person) => openDrawer("view", person)} emptyTitle="No people yet" @@ -193,7 +208,7 @@ export function PeopleDataTable() { - - - - - - - - - + } /> + + + + { + 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/components/crm/deals/deals-drawer.tsx b/apps/web/components/crm/deals/deals-drawer.tsx new file mode 100644 index 0000000..b81379c --- /dev/null +++ b/apps/web/components/crm/deals/deals-drawer.tsx @@ -0,0 +1,469 @@ +"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 { 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-mode field helpers ────────────────────────────────────────────────── + +function ViewField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +function ViewSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +// ─── 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..5535a6a --- /dev/null +++ b/apps/web/components/crm/deals/deals-kanban.tsx @@ -0,0 +1,315 @@ +"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, DEAL_STAGE_MAP } 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/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 From 45b10835f34e22e51a0372cc5996eb5a4ba85c01 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Thu, 16 Apr 2026 11:50:54 +0545 Subject: [PATCH 09/15] feat: add description to organizations page header for better context --- .../web/components/crm/deals/deals-kanban.tsx | 22 ++++++------------- .../components/crm/orgs/orgs-data-table.tsx | 1 + 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/apps/web/components/crm/deals/deals-kanban.tsx b/apps/web/components/crm/deals/deals-kanban.tsx index 5535a6a..d53a125 100644 --- a/apps/web/components/crm/deals/deals-kanban.tsx +++ b/apps/web/components/crm/deals/deals-kanban.tsx @@ -18,7 +18,7 @@ import { 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, DEAL_STAGE_MAP } from "./deals-options"; +import { DEAL_STAGE_OPTIONS } from "./deals-options"; import type { EntitySheetMode } from "@/components/shared/entity-sheet"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -66,9 +66,7 @@ function DealCard({ deal, onClick }: { deal: Deal; onClick: () => void }) { {deal.value && (
- - {Number(deal.value).toLocaleString()} - + {Number(deal.value).toLocaleString()} {deal.currency}
)} @@ -108,7 +106,7 @@ function DealCard({ deal, onClick }: { deal: Deal; onClick: () => void }) { function DealCardGhost({ deal }: { deal: Deal }) { return ( -
+

{deal.title}

{deal.value && (
@@ -190,7 +188,7 @@ export function DealsKanban({ onDrawerStateChange }: DealsKanbanProps) { >
-
+
{Array.from({ length: 2 }).map((_, i) => (
@@ -232,10 +230,7 @@ export function DealsKanban({ onDrawerStateChange }: DealsKanbanProps) { }, 0); return ( - + {/* Column header — not a drag handle (columns are fixed order) */}
{stageDeals.map((deal) => ( - openDrawer("view", deal)} - /> + openDrawer("view", deal)} /> ))} diff --git a/apps/web/components/crm/orgs/orgs-data-table.tsx b/apps/web/components/crm/orgs/orgs-data-table.tsx index b6b5682..2a9b19d 100644 --- a/apps/web/components/crm/orgs/orgs-data-table.tsx +++ b/apps/web/components/crm/orgs/orgs-data-table.tsx @@ -156,6 +156,7 @@ export function OrgsDataTable() { <> openDrawer("create")}> From 8b4064cffd07d6148ccb8803a3c55243f1fa03fb Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Thu, 16 Apr 2026 11:53:48 +0545 Subject: [PATCH 10/15] feat: remove source field from PersonForm to streamline the form --- .../components/crm/people/people-drawer.tsx | 30 +------------------ 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/apps/web/components/crm/people/people-drawer.tsx b/apps/web/components/crm/people/people-drawer.tsx index 08f2a7e..89d9ab5 100644 --- a/apps/web/components/crm/people/people-drawer.tsx +++ b/apps/web/components/crm/people/people-drawer.tsx @@ -30,7 +30,7 @@ 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_SOURCE_OPTIONS, PERSON_STATUS_OPTIONS } from "@/components/crm/crm-options"; +import { PERSON_STATUS_OPTIONS } from "@/components/crm/crm-options"; // ─── View-mode field helpers ────────────────────────────────────────────────── @@ -285,34 +285,6 @@ function PersonForm({ )} /> - ( - - Source - - - - )} - />
{/* Organization */} From 8b7068a46166492eb3fc2035b2cd5d1e4e94fc25 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Thu, 16 Apr 2026 12:11:36 +0545 Subject: [PATCH 11/15] feat: add detailed comments to DataTable component for improved code clarity --- apps/web/components/shared/data-table.tsx | 37 +++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/web/components/shared/data-table.tsx b/apps/web/components/shared/data-table.tsx index a615ca8..d64a26d 100644 --- a/apps/web/components/shared/data-table.tsx +++ b/apps/web/components/shared/data-table.tsx @@ -100,33 +100,33 @@ export interface DataTableProps { } export function DataTable({ - columns, - data, - pageCount, - pageIndex, - pageSize, - onPaginationChange, - sorting = [], - onSortingChange, - columnFilters = [], - onColumnFiltersChange, + 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 = "", - onSearchChange, - filterConfig = [], + 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 = {}, + rowSelection = {}, // the current row selection state, controlled by the parent component onRowSelectionChange, - getRowId, + 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, + 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 @@ -136,6 +136,7 @@ export function DataTable({ 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", @@ -164,11 +165,13 @@ export function DataTable({ [], ); + // 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, @@ -210,6 +213,7 @@ export function DataTable({ 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 : ""; @@ -222,6 +226,7 @@ export function DataTable({ 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 }); From e525bd94553cd87bd8262b6ad492eff037a13d96 Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Thu, 16 Apr 2026 12:34:50 +0545 Subject: [PATCH 12/15] feat: add clearCrmQueries function to remove specific queries on mutation success --- apps/web/hooks/queries/use-workspace.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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] }); +} From 72cfba9a08330c261f134193ca411a79acd473eb Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Thu, 16 Apr 2026 12:46:06 +0545 Subject: [PATCH 13/15] feat: refactor CRM view components to use CrmViewField and CrmViewSection for consistency --- apps/web/components/crm/crm-view.tsx | 23 +++++ .../web/components/crm/deals/deals-drawer.tsx | 91 ++++++++----------- apps/web/components/crm/orgs/orgs-drawer.tsx | 63 ++++--------- .../components/crm/people/people-drawer.tsx | 85 +++++++---------- 4 files changed, 109 insertions(+), 153 deletions(-) create mode 100644 apps/web/components/crm/crm-view.tsx 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 index b81379c..2e2519b 100644 --- a/apps/web/components/crm/deals/deals-drawer.tsx +++ b/apps/web/components/crm/deals/deals-drawer.tsx @@ -25,6 +25,7 @@ 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"; @@ -33,30 +34,6 @@ 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-mode field helpers ────────────────────────────────────────────────── - -function ViewField({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {label} - -
{children}
-
- ); -} - -function ViewSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

- {title} -

-
{children}
-
- ); -} - // ─── View content ───────────────────────────────────────────────────────────── function DealViewContent({ deal }: { deal: Deal }) { @@ -67,19 +44,19 @@ function DealViewContent({ deal }: { deal: Deal }) { return (
- - + + {deal.title} - - + + {stageConfig ? ( {stageConfig.label} ) : null} - +
- + {deal.value ? ( {deal.value} {deal.currency} @@ -87,41 +64,41 @@ function DealViewContent({ deal }: { deal: Deal }) { ) : ( 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")} - +
@@ -158,7 +135,11 @@ function DealForm({ Title * - + @@ -389,14 +370,18 @@ export function DealsDrawer({ const formValues = useMemo( () => ({ title: mode === "create" ? "" : (deal?.title ?? ""), - stage: mode === "create" ? ((initialStage as CreateDeal["stage"]) ?? "new") : (deal?.stage ?? "new"), + 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, + 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), diff --git a/apps/web/components/crm/orgs/orgs-drawer.tsx b/apps/web/components/crm/orgs/orgs-drawer.tsx index e4e702b..3ec3d60 100644 --- a/apps/web/components/crm/orgs/orgs-drawer.tsx +++ b/apps/web/components/crm/orgs/orgs-drawer.tsx @@ -23,45 +23,11 @@ import { } 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"; -// ─── View helpers ───────────────────────────────────────────────────────────── - -function ViewField({ - label, - value, - children, -}: { - label: string; - value?: string | null; - children?: React.ReactNode; -}) { - const content = children ?? value; - return ( -
-

{label}

- {content ? ( -

{content}

- ) : ( -

- )} -
- ); -} - -function ViewSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

- {title} -

-
{children}
-
- ); -} - function capitalize(str: string | null | undefined): string | null | undefined { if (!str) return str; return str.charAt(0).toUpperCase() + str.slice(1); @@ -91,21 +57,26 @@ function ViewContent({ org }: { org: Organization }) { - - - - - + + + {capitalize(org.industry) ?? } + + + {org.size ?? } + + + {org.location ?? } + + {org.peopleCount !== undefined && ( <> - - - + + + {`${org.peopleCount} ${org.peopleCount === 1 ? "person" : "people"}`} + + )}
diff --git a/apps/web/components/crm/people/people-drawer.tsx b/apps/web/components/crm/people/people-drawer.tsx index 89d9ab5..36adfcd 100644 --- a/apps/web/components/crm/people/people-drawer.tsx +++ b/apps/web/components/crm/people/people-drawer.tsx @@ -25,6 +25,7 @@ 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"; @@ -32,30 +33,6 @@ import type { Person } from "@/types/crm"; import type { WorkspaceMember } from "@/types/workspace-settings"; import { PERSON_STATUS_OPTIONS } from "@/components/crm/crm-options"; -// ─── View-mode field helpers ────────────────────────────────────────────────── - -function ViewField({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {label} - -
{children}
-
- ); -} - -function ViewSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

- {title} -

-
{children}
-
- ); -} - // ─── View content ───────────────────────────────────────────────────────────── function PersonViewContent({ person }: { person: Person }) { @@ -65,11 +42,11 @@ function PersonViewContent({ person }: { person: Person }) { return (
- - + + {person.name} - - + + {person.email ? ( Not provided )} - - + + {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")} - +
From 1c7d3e4d4f9050534f6c909c7cd3425f3a2e8dfa Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Thu, 16 Apr 2026 12:49:49 +0545 Subject: [PATCH 14/15] feat: enhance listDeals function with improved filtering and sorting logic --- apps/api/src/controllers/deals.controller.ts | 131 +++++++++---------- 1 file changed, 61 insertions(+), 70 deletions(-) diff --git a/apps/api/src/controllers/deals.controller.ts b/apps/api/src/controllers/deals.controller.ts index 9979cb0..c09d757 100644 --- a/apps/api/src/controllers/deals.controller.ts +++ b/apps/api/src/controllers/deals.controller.ts @@ -1,90 +1,76 @@ import type { Context } from "hono"; import type { CreateDeal, ListDealsQuery, UpdateDeal } from "@workspace/validators/schemas/crm"; -import { and, asc, count, desc, eq, ilike, type SQL } from "drizzle-orm"; +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"; -const dealSortColumns = { - title: deals.title, - value: deals.value, - currency: deals.currency, - stage: deals.stage, - closeDate: deals.closeDate, - createdAt: deals.createdAt, - updatedAt: deals.updatedAt, -} as const; - -function buildDealFilters(workspaceId: string, query: ListDealsQuery): SQL[] { - const filters: SQL[] = [eq(deals.workspaceId, workspaceId)]; - - if (query.stage) { - filters.push(eq(deals.stage, query.stage)); - } - - if (query.ownerId) { - filters.push(eq(deals.ownerId, query.ownerId)); - } - - if (query.search) { - const searchTerm = `%${query.search}%`; - - filters.push(ilike(deals.title, searchTerm)); - } - - return filters; -} - -function buildDealOrderBy(query: ListDealsQuery) { - const column = dealSortColumns[query.sortBy] ?? deals.title; - return query.sortOrder === "desc" ? desc(column) : asc(column); -} - export async function listDeals(c: Context, query: ListDealsQuery) { const workspaceId = getSessionWorkspaceId(c); - const page = query.page; - const pageSize = query.pageSize; + const { page, pageSize, sortBy, sortOrder, stage, ownerId, search } = query; const offset = (page - 1) * pageSize; - const filters = buildDealFilters(workspaceId, query); - const orderBy = buildDealOrderBy(query); + const conditions = [eq(deals.workspaceId, workspaceId)]; + + 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.query.deals.findMany({ - where: and(...filters), - with: { - org: { - columns: { - id: true, - name: true, - }, - }, - person: { - columns: { - id: true, - name: true, - }, - }, - owner: { - columns: { - id: true, - name: true, - }, - }, - }, - orderBy: [orderBy], - limit: pageSize, - offset, - }), db .select({ - totalCount: count(deals.id), + 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) - .where(and(...filters)), + .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); @@ -93,7 +79,12 @@ export async function listDeals(c: Context, query: ListDealsQuery) { return sendSuccess( c, { - deals: results, + deals: results.map((deal) => ({ + ...deal, + orgName: deal.orgName ?? null, + personName: deal.personName ?? null, + ownerName: deal.ownerName ?? null, + })), meta: { page, pageSize, From 722cf9e01bce65e0e2e5da8e281aebe31ea3110e Mon Sep 17 00:00:00 2001 From: Samir Khanal Date: Thu, 16 Apr 2026 12:51:47 +0545 Subject: [PATCH 15/15] feat: invalidate ORGS queries on person creation, update, deletion, and bulk deletion --- apps/web/hooks/queries/use-people.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/hooks/queries/use-people.ts b/apps/web/hooks/queries/use-people.ts index 1304fe8..0353d11 100644 --- a/apps/web/hooks/queries/use-people.ts +++ b/apps/web/hooks/queries/use-people.ts @@ -45,6 +45,7 @@ export function useCreatePerson() { 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.", }); @@ -59,6 +60,7 @@ export function useUpdatePerson(personId: string) { 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.", }); @@ -73,6 +75,7 @@ export function useDeletePerson() { 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.", }); @@ -87,6 +90,7 @@ export function useBulkDeletePeople() { 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