diff --git a/web/src/lib/api/client/app/crm/tasks/searchTasks.ts b/web/src/lib/api/client/app/crm/tasks/searchTasks.ts new file mode 100644 index 00000000..05892845 --- /dev/null +++ b/web/src/lib/api/client/app/crm/tasks/searchTasks.ts @@ -0,0 +1,21 @@ +import type SearchTasks from "@/lib/api/models/app/crm/SearchTasks"; +import type TasksSearchResult from "@/lib/api/models/app/crm/TasksSearchResult"; +import Request from "../../../Request"; + +export default async function searchTasks( + filters: SearchTasks, + offset = 0, + limit = 50, +): Promise { + const qs = new URLSearchParams(); + if (offset) qs.set("offset", String(offset)); + if (limit) qs.set("limit", String(limit)); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + + return await Request({ + method: "POST", + url: `/crm/tasks/search${suffix}`, + data: filters, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/crm/tasks/tasksSummary.ts b/web/src/lib/api/client/app/crm/tasks/tasksSummary.ts new file mode 100644 index 00000000..8e184014 --- /dev/null +++ b/web/src/lib/api/client/app/crm/tasks/tasksSummary.ts @@ -0,0 +1,12 @@ +import type SearchTasks from "@/lib/api/models/app/crm/SearchTasks"; +import type { TasksSummary } from "@/lib/api/models/app/crm/TasksSearchResult"; +import Request from "../../../Request"; + +export default async function tasksSummary(filters: SearchTasks): Promise { + return await Request({ + method: "POST", + url: "/crm/tasks/summary", + data: filters, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/crm/tasks/useSearchTasks.ts b/web/src/lib/api/hooks/app/crm/tasks/useSearchTasks.ts new file mode 100644 index 00000000..f71c679e --- /dev/null +++ b/web/src/lib/api/hooks/app/crm/tasks/useSearchTasks.ts @@ -0,0 +1,41 @@ +import { useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; +import type SearchTasks from "@/lib/api/models/app/crm/SearchTasks"; +import type TasksSearchResult from "@/lib/api/models/app/crm/TasksSearchResult"; +import searchTasks from "@/lib/api/client/app/crm/tasks/searchTasks"; + +interface UseSearchTasksProps { + filters: SearchTasks; + limit?: number; + enabled?: boolean; +} + +// Server-driven tasks fetch that scales to thousands of rows. Offset-paginated +// (the backend uses offset rather than a keyset cursor so nullable due-date +// sorts don't drop rows), so the page param is the next offset. Pages flatten +// into a single `tasks` list and `total` comes straight off the server so the +// UI can show "N of M loaded". +export default function useSearchTasks({ filters, limit = 50, enabled = true }: UseSearchTasksProps) { + const queryResult = useInfiniteQuery< + TasksSearchResult, + Error, + InfiniteData, + [string, string, string, SearchTasks, number], + number + >({ + queryKey: ["crm", "tasks", "search", filters, limit], + queryFn: async ({ pageParam }) => searchTasks(filters, pageParam, limit), + initialPageParam: 0, + getNextPageParam: (lastPage) => + lastPage.pagination.has_more ? (lastPage.pagination.next_offset ?? undefined) : undefined, + staleTime: 30_000, + enabled, + }); + + const tasks = queryResult.data?.pages + .flatMap((p) => p.data ?? []) + .filter((t): t is NonNullable => t != null); + + const total = queryResult.data?.pages[0]?.pagination.total ?? 0; + + return { ...queryResult, tasks, total }; +} diff --git a/web/src/lib/api/hooks/app/crm/tasks/useTasksSummary.ts b/web/src/lib/api/hooks/app/crm/tasks/useTasksSummary.ts new file mode 100644 index 00000000..3a1f36a5 --- /dev/null +++ b/web/src/lib/api/hooks/app/crm/tasks/useTasksSummary.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import type SearchTasks from "@/lib/api/models/app/crm/SearchTasks"; +import tasksSummary from "@/lib/api/client/app/crm/tasks/tasksSummary"; + +// Server-aggregated totals for the same filter the table renders. Kept as a +// separate query (not folded into the list) so the header stats stay correct +// over the whole set while the rows page in. +export default function useTasksSummary(filters: SearchTasks, enabled = true) { + return useQuery({ + queryKey: ["crm", "tasks", "summary", filters], + queryFn: () => tasksSummary(filters), + staleTime: 30_000, + enabled, + }); +} diff --git a/web/src/lib/api/models/app/crm/SearchTasks.ts b/web/src/lib/api/models/app/crm/SearchTasks.ts new file mode 100644 index 00000000..8548d327 --- /dev/null +++ b/web/src/lib/api/models/app/crm/SearchTasks.ts @@ -0,0 +1,42 @@ +// Filter body for POST /crm/tasks/search and /crm/tasks/summary. Every field +// is optional; an empty body matches every task in the org (the "All tasks" +// default). The same body drives the rows and the summary totals, so a header +// number always reflects the exact filter shown below it. + +import type { CRMTaskPriority, CRMTaskStatus } from "@/lib/api/models/app/crm/CRMTask"; + +export type TaskSortBy = + | "created_at" + | "updated_at" + | "due_date" + | "priority" + | "title"; + +export default interface SearchTasks { + query: string; + statuses: CRMTaskStatus[]; + priorities: CRMTaskPriority[]; + // Task type NAMEs (crm_tasks.type), e.g. ["Call", "Email"]. + types: string[]; + // User UUIDs (assigned_to). String-matched on the server. + assigned_to: string[]; + contact_id?: string; + deal_id?: string; + due_after?: string; + due_before?: string; + // due_date < now() AND status NOT IN ('completed','cancelled'). + overdue?: boolean; + sort_by: TaskSortBy; + // false => DESC (default), true => ASC. + reverse: boolean; +} + +export const EMPTY_TASK_SEARCH: SearchTasks = { + query: "", + statuses: [], + priorities: [], + types: [], + assigned_to: [], + sort_by: "created_at", + reverse: false, +}; diff --git a/web/src/lib/api/models/app/crm/TasksSearchResult.ts b/web/src/lib/api/models/app/crm/TasksSearchResult.ts new file mode 100644 index 00000000..b962af8a --- /dev/null +++ b/web/src/lib/api/models/app/crm/TasksSearchResult.ts @@ -0,0 +1,29 @@ +import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; + +export interface TasksSearchPagination { + total: number; + limit: number; + offset: number; + has_more: boolean; + next_offset?: number | null; +} + +export default interface TasksSearchResult { + data: CRMTask[]; + pagination: TasksSearchPagination; +} + +// Server-aggregated totals over a SearchTasks filter. Every number here is a +// COUNT over the whole matching set — never a client reduce over a loaded +// page — so the header stats stay honest at scale. +export interface TasksSummary { + total: number; + pending_count: number; + in_progress_count: number; + completed_count: number; + cancelled_count: number; + // due_date < now() AND status NOT IN ('completed','cancelled'). + overdue_count: number; + // priority IN ('high','urgent'). + high_priority_count: number; +}