Files
pn-new-crm/src/hooks/use-paginated-query.ts

196 lines
6.0 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useCallback, useMemo } from 'react';
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { apiFetch } from '@/lib/api/client';
import type { PaginatedResponse } from '@/types/api';
import {
serializeFiltersToParams,
deserializeFiltersFromParams,
type FilterDefinition,
type FilterValues,
} from '@/components/shared/filter-bar';
interface UsePaginatedQueryOptions {
queryKey: QueryKey;
endpoint: string;
initialPage?: number;
initialPageSize?: number;
filterDefinitions?: FilterDefinition[];
}
export function usePaginatedQuery<T>({
queryKey,
endpoint,
initialPage = 1,
initialPageSize = 25,
filterDefinitions = [],
}: UsePaginatedQueryOptions) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
// Read initial state from URL
const pageFromUrl = Number(searchParams.get('page')) || initialPage;
const pageSizeFromUrl = Number(searchParams.get('limit')) || initialPageSize;
const sortFieldFromUrl = searchParams.get('sort') ?? undefined;
const sortOrderFromUrl = (searchParams.get('order') as 'asc' | 'desc') ?? 'desc';
const [page, setPageState] = useState(pageFromUrl);
const [pageSize, setPageSizeState] = useState(pageSizeFromUrl);
const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>(
sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : undefined,
);
const [filters, setFiltersState] = useState<FilterValues>(() =>
deserializeFiltersFromParams(searchParams, filterDefinitions),
);
// Sync state to URL
const syncUrl = useCallback(
(p: number, ps: number, s?: typeof sort, f?: FilterValues) => {
const params = new URLSearchParams();
if (p !== 1) params.set('page', String(p));
if (ps !== initialPageSize) params.set('limit', String(ps));
if (s) {
params.set('sort', s.field);
params.set('order', s.direction);
}
if (f) {
const filterParams = serializeFiltersToParams(f);
filterParams.forEach((value, key) => params.set(key, value));
}
// Keep existing tab param
const tab = searchParams.get('tab');
if (tab) params.set('tab', tab);
const qs = params.toString();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.replace(`${pathname}${qs ? `?${qs}` : ''}` as any, { scroll: false });
},
[pathname, router, searchParams, initialPageSize],
);
function setPage(p: number) {
setPageState(p);
syncUrl(p, pageSize, sort, filters);
}
function setPageSize(ps: number) {
setPageSizeState(ps);
setPageState(1);
syncUrl(1, ps, sort, filters);
}
function setSort(field: string, direction: 'asc' | 'desc') {
const newSort = { field, direction };
setSortState(newSort);
setPageState(1);
syncUrl(1, pageSize, newSort, filters);
}
function setFilter(key: string, value: unknown) {
const newFilters = { ...filters };
if (value === undefined || value === null) {
delete newFilters[key];
} else {
newFilters[key] = value;
}
setFiltersState(newFilters);
setPageState(1);
syncUrl(1, pageSize, sort, newFilters);
}
function clearFilters() {
setFiltersState({});
setPageState(1);
syncUrl(1, pageSize, sort, {});
}
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
/**
* Atomically replace the entire filter set. Used by the saved-views
* apply path calling `clearFilters()` + N x `setFilter()` in a row
* lost all but the last setFilter because each one reads the stale
* `filters` closure and overwrites with `{...filters, key: val}`.
* setAllFilters writes the whole object in one setState so the view
* lands intact.
*/
function setAllFilters(next: FilterValues) {
setFiltersState(next);
setPageState(1);
syncUrl(1, pageSize, sort, next);
}
// Build query string for API
const apiParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('limit', String(pageSize));
if (sort) {
params.set('sort', sort.field);
params.set('order', sort.direction);
}
const filterParams = serializeFiltersToParams(filters);
filterParams.forEach((value, key) => params.set(key, value));
return params.toString();
}, [page, pageSize, sort, filters]);
const fullQueryKey = [...queryKey, apiParams];
// Endpoints that already carry a query string (e.g. `/api/v1/documents?tab=eoi_queue`)
// need our pagination params merged with `&`, not a second `?`. Without this guard
// the URL becomes `…?tab=foo?page=1` and the API rejects it as 400.
const separator = endpoint.includes('?') ? '&' : '?';
const { data, isLoading, isFetching } = useQuery<PaginatedResponse<T>>({
queryKey: fullQueryKey,
queryFn: () => apiFetch<PaginatedResponse<T>>(`${endpoint}${separator}${apiParams}`),
});
const pagination = data?.pagination
? {
page: data.pagination.page,
pageSize: data.pagination.pageSize,
total: data.pagination.total,
totalPages: data.pagination.totalPages,
}
: { page, pageSize, total: 0, totalPages: 0 };
/**
* Optimistically removes an item from the cached list (e.g., after archive).
*/
function optimisticRemove(id: string) {
queryClient.setQueryData<PaginatedResponse<T>>(fullQueryKey, (old) => {
if (!old) return old;
return {
...old,
data: old.data.filter((item) => (item as Record<string, unknown>).id !== id),
pagination: {
...old.pagination,
total: old.pagination.total - 1,
},
};
});
}
return {
data: data?.data ?? [],
pagination,
isLoading,
isFetching,
sort,
setSort,
page,
setPage,
pageSize,
setPageSize,
filters,
setFilter,
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
setAllFilters,
clearFilters,
optimisticRemove,
};
}