diff --git a/bp-panel-list-sorting.md b/bp-panel-list-sorting.md new file mode 100644 index 0000000..0df52b4 --- /dev/null +++ b/bp-panel-list-sorting.md @@ -0,0 +1,241 @@ +# BP Panel — List sorting (frontend) + +Guide for **Business Partner (BP)** list screens under `/api/v1`. Use these query parameters to control item order on paginated GET endpoints. + +This does **not** apply to Kanban board card order (see [board-api.md](./board-api.md) — cards use `order` on move/reorder APIs). + +--- + +## Quick reference + +| Parameter | Type | Default | Allowed values | +|-----------|------|---------|----------------| +| `sort_by` | string | `created_at` | MongoDB field name (see per-endpoint tables) | +| `sort_order` | string | `desc` | `asc` or `desc` | + +**Example (newest tenders first — also the default if omitted):** + +```http +GET /api/v1/tenders?limit=20&sort_by=created_at&sort_order=desc +Authorization: Bearer +``` + +**Example (soonest deadline first):** + +```http +GET /api/v1/tenders?limit=20&sort_by=tender_deadline&sort_order=asc +``` + +--- + +## Authentication + +All BP list endpoints below require a **customer** JWT from: + +```http +POST /api/v1/profile/login +``` + +```http +Authorization: Bearer +``` + +Base URL (local): `http://localhost:8081/api/v1` + +--- + +## How sorting works + +1. **`sort_by`** must match the **BSON field name** stored in MongoDB (snake_case), not the JSON display name in the UI. +2. **`sort_order`** + - `desc` — highest / latest values first (default). + - `asc` — lowest / earliest values first. +3. If you omit both parameters, the API uses **`sort_by=created_at`** and **`sort_order=desc`**. +4. Lists use **keyset (cursor) pagination**. When requesting page 2+ with `cursor`, you **must keep the same** `sort_by`, `sort_order`, `limit`, and filters as the request that produced `meta.next_cursor`. Changing sort mid-pagination returns **400** (`cursor does not match requested sort`). +5. Do not send **`cursor` and `offset` together** — the API returns **400**. + +### Pagination parameters (used with sorting) + +| Parameter | Default | Max | Notes | +|-----------|---------|-----|--------| +| `limit` | `10` | `100` | Page size | +| `offset` | `0` | — | First page only; prefer `cursor` for deep pages | +| `cursor` | — | — | Copy from `meta.next_cursor` on the previous response | + +### Response metadata + +Sorted list responses include `meta` on the envelope: + +```json +{ + "success": true, + "message": "Tenders retrieved successfully", + "data": { "tenders": [] }, + "meta": { + "total": 240, + "limit": 20, + "offset": 0, + "page": 1, + "pages": 12, + "has_more": true, + "next_cursor": "eyJ2IjoxLCJzb3J0X2ZpZWxkIjoiY3JlYXRlZF9hdCIs..." + } +} +``` + +--- + +## Endpoints that support `sort_by` / `sort_order` + +### Tenders — main panel list + +| | | +|--|--| +| **Method / path** | `GET /api/v1/tenders` | +| **Also** | `GET /api/v1/tenders/recommend` (same sorting rules) | +| **Default sort** | `created_at` `desc` | + +**Recommended `sort_by` values** (field exists on tender documents): + +| `sort_by` | Use case | +|-----------|----------| +| `created_at` | Newest ingested / default | +| `updated_at` | Recently changed tenders | +| `publication_date` | Publication date on TED notice | +| `tender_deadline` | Submission / tender deadline | +| `submission_deadline` | Alternative deadline field when set | +| `estimated_value` | Value high → low or low → high | +| `title` | Alphabetical (locale-dependent) | +| `status` | Group by lifecycle status | +| `country_code` | Group by country | + +> The backend does not whitelist tender `sort_by` values. Use only documented fields; unknown fields may sort incorrectly or behave unpredictably. + +**Example with filters + sort:** + +```http +GET /api/v1/tenders?limit=20&country_codes=DE,FR&sort_by=publication_date&sort_order=desc&deadline_from=1717200000 +``` + +**TypeScript helper:** + +```typescript +type SortOrder = "asc" | "desc"; + +interface ListSortParams { + sort_by?: string; + sort_order?: SortOrder; + limit?: number; + offset?: number; + cursor?: string; +} + +function buildTenderListQuery( + filters: Record, + sort: ListSortParams = { sort_by: "created_at", sort_order: "desc", limit: 20 } +): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filters)) { + if (value === undefined) continue; + if (Array.isArray(value)) value.forEach((v) => params.append(key, v)); + else params.set(key, String(value)); + } + params.set("sort_by", sort.sort_by ?? "created_at"); + params.set("sort_order", sort.sort_order ?? "desc"); + if (sort.limit) params.set("limit", String(sort.limit)); + if (sort.cursor) params.set("cursor", sort.cursor); + else if (sort.offset) params.set("offset", String(sort.offset)); + return params.toString(); +} +``` + +--- + +### Notifications + +| | | +|--|--| +| **Method / path** | `GET /api/v1/notifications` | +| **Default sort** | `created_at` `desc` | + +**Recommended `sort_by` values:** + +| `sort_by` | Use case | +|-----------|----------| +| `created_at` | Newest notifications (default) | +| `updated_at` | Last updated | +| `seen_at` | Seen / unread ordering | +| `priority` | Important first (with `sort_order=desc`) | +| `schedule_at` | Scheduled notifications | + +--- + +### Feedback (company list) + +| | | +|--|--| +| **Method / path** | `GET /api/v1/feedback` | +| **Default sort** | `created_at` `desc` | +| **Scope** | Company-scoped from the logged-in customer | + +**Recommended `sort_by` values:** + +| `sort_by` | Use case | +|-----------|----------| +| `created_at` | Newest feedback (default) | +| `updated_at` | Recently changed | +| `feedback_type` | Group likes / dislikes | + +--- + +## Endpoints without `sort_by` / `sort_order` (BP) + +| Endpoint | Notes | +|----------|--------| +| `GET /api/v1/tender-approvals` | Uses `limit` / `offset` only; **no** `sort_by` on the public handler today | +| `GET /api/v1/companies` | Single “my company” resource, not a sortable list | +| Kanban (`/admin/v1/kanban/...`) | Admin-only; card order is `order` + move/reorder APIs | + +If you need sorted tender approvals in BP, coordinate with backend to add `sort_by` / `sort_order` to `PublicGetTenderApprovals` (admin list already supports them). + +--- + +## UI patterns + +### Column header → query params + +| UI label | `sort_by` | Typical `sort_order` | +|----------|-----------|----------------------| +| Date added | `created_at` | `desc` | +| Deadline | `tender_deadline` | `asc` | +| Value | `estimated_value` | `desc` | +| Published | `publication_date` | `desc` | + +Toggle the same column: flip `asc` ↔ `desc`. Changing column: set new `sort_by` and reset to page 1 (`offset=0`, drop `cursor`). + +### Infinite scroll / “Load more” + +1. First request: `sort_by`, `sort_order`, `limit`, filters — no `cursor`. +2. Next page: repeat the **same** sort and filters; pass `cursor=meta.next_cursor`. +3. Stop when `meta.has_more === false`. + +--- + +## Errors + +| Situation | HTTP | `error.code` (typical) | +|-----------|------|-------------------------| +| Invalid `sort_order` (not `asc`/`desc`) | 400 | `BAD_REQUEST` | +| `cursor` + `offset` together | 400 | `BAD_REQUEST` | +| `cursor` with different `sort_by` / `sort_order` | 400 | `BAD_REQUEST` | +| `limit` < 1 or > 100 | 400 | `BAD_REQUEST` | + +--- + +## Admin panel (reference) + +Admin list routes under `/admin/v1/*` use the same `sort_by` / `sort_order` conventions where `response.NewPagination` is used. Some admin forms restrict values (e.g. tender approvals: `created_at`, `updated_at`, `status`, `submission_mode`). BP tender lists are **less restricted** than admin tender-approval lists. + +--- + +**Source:** `pkg/response/pagination.go`, `pkg/mongo/list_pagination.go`, `internal/tender`, `internal/notification`, `internal/feedback` · **Last updated:** 2026-05-31 diff --git a/src/components/Tables/admins/index.tsx b/src/components/Tables/admins/index.tsx index 0c9968a..82fecaa 100644 --- a/src/components/Tables/admins/index.tsx +++ b/src/components/Tables/admins/index.tsx @@ -22,6 +22,7 @@ import { TableHead, TableHeader, TableRow, + TableSortProvider, } from "@/components/ui/table"; import TableSkeleton from "@/components/ui/TableSkeleton"; import { AdminStatus } from "@/constants/enums"; @@ -41,6 +42,11 @@ const AdminsTable = () => { isChangeStatusModalOpen, setIsChangeStatusModalOpen, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, changeStatus, currentUser, setStatus, @@ -86,18 +92,29 @@ const AdminsTable = () => { onClearAll={clearAllFilters} />
+ - {columns.map((column, index) => ( - - {column} - - ))} + {columns.map((column, index) => { + const sortKey = columnSortKeys[column]; + return ( + + {column} + + ); + })} @@ -202,6 +219,7 @@ const AdminsTable = () => { )}
+
{ "actions", ]; + /** Column label → BSON `sort_by` field. */ + const columnSortKeys: Record = { + "full name": "full_name", + email: "email", + username: "username", + status: "status", + }; + + /** First-click direction per column. */ + const columnDefaultOrder: Record = { + "full name": "asc", + email: "asc", + username: "asc", + }; + + const sortBy = (params.sort_by as string) ?? "created_at"; + const sortOrder = (params.sort_order as SortDirection) ?? "desc"; + + const handleSortChange = useCallback( + (key: string, order: SortDirection) => { + const newParams = buildFirstPageParams({ + ...params, + sort_by: key, + sort_order: order, + }); + const cleanedParams = deleteEmptyKeys(newParams); + const queryString = buildQueryString(cleanedParams); + router.push(`${pathName}?${queryString}`); + }, + [buildFirstPageParams, params, pathName, router], + ); + return { allUsers: usersList, usersList, setFilterValue, metadata, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, isPending, onConfirmDelete, isModalOpen, diff --git a/src/components/Tables/cms/index.tsx b/src/components/Tables/cms/index.tsx index ecb9b8f..aafae25 100644 --- a/src/components/Tables/cms/index.tsx +++ b/src/components/Tables/cms/index.tsx @@ -11,6 +11,7 @@ import { TableHead, TableHeader, TableRow, + TableSortProvider, } from "@/components/ui/table"; import TableSkeleton from "@/components/ui/TableSkeleton"; import { _TooltipDefaultParams } from "@/constants/tooltip"; @@ -22,6 +23,11 @@ import useCmsTablePresenter from "./useCmsTablePresenter"; const CmsTable = () => { const { columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, data, isPending, isModalOpen, @@ -55,14 +61,28 @@ const CmsTable = () => { onClearAll={clearAllFilters} />
+ - {columns.map((column) => ( - - {column} - - ))} + {columns.map((column) => { + const sortKey = columnSortKeys[column]; + return ( + + {column} + + ); + })} {isPending ? ( @@ -130,6 +150,7 @@ const CmsTable = () => { )}
+
{isModalOpen && ( { if (!currentCms?.id) return; deleteCms(currentCms.id); }; + + /** Column label → BSON `sort_by` field. */ + const columnSortKeys: Record = { + key: "key", + "created at": "created_at", + }; + + /** First-click direction per column. */ + const columnDefaultOrder: Record = { + key: "asc", + "created at": "desc", + }; + + const sortBy = (params.sort_by as string) ?? "created_at"; + const sortOrder = (params.sort_order as SortDirection) ?? "desc"; + + const handleSortChange = useCallback( + (key: string, order: SortDirection) => { + const next: Record = { + ...params, + sort_by: key, + sort_order: order, + offset: 0, + }; + delete next.cursor; + const cleanedParams = deleteEmptyKeys(next); + const queryString = buildQueryString(cleanedParams); + router.push(`${pathname}?${queryString}`); + }, + [params, pathname, router], + ); + return { columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, isPending, data, isModalOpen, diff --git a/src/components/Tables/companies/company-categories/index.tsx b/src/components/Tables/companies/company-categories/index.tsx index f51aca5..b84fbe5 100644 --- a/src/components/Tables/companies/company-categories/index.tsx +++ b/src/components/Tables/companies/company-categories/index.tsx @@ -14,6 +14,7 @@ import { TableHead, TableHeader, TableRow, + TableSortProvider, } from "@/components/ui/table"; import TableSkeleton from "@/components/ui/TableSkeleton"; import { _TooltipDefaultParams } from "@/constants/tooltip"; @@ -42,6 +43,11 @@ const CompanyCategoriesTable = () => { watch, setFilterValue, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, isMutating, metadata, shouldShowPagination, @@ -70,15 +76,28 @@ const CompanyCategoriesTable = () => { onClearAll={clearAllFilters} />
+ - Row - Title - Description - Created at - Published - Actions + {columns.map((column) => { + const sortKey = columnSortKeys[column]; + return ( + + {column} + + ); + })} {isLoading ? ( @@ -172,6 +191,7 @@ const CompanyCategoriesTable = () => { )}
+
{isModalOpen && ( { "Published", "Actions", ]; + + /** Column label → BSON `sort_by` field. */ + const columnSortKeys: Record = { + Title: "name", + Description: "description", + "Created at": "created_at", + Published: "published", + }; + + /** First-click direction per column. */ + const columnDefaultOrder: Record = { + Title: "asc", + Description: "asc", + "Created at": "desc", + Published: "desc", + }; + + const sortBy = (params.sort_by as string) ?? "created_at"; + const sortOrder = (params.sort_order as SortDirection) ?? "desc"; + + const handleSortChange = useCallback( + (key: string, order: SortDirection) => { + const newParams = buildFirstPageParams({ + ...params, + sort_by: key, + sort_order: order, + }); + const cleanedParams = deleteEmptyKeys(newParams); + const queryString = buildQueryString(cleanedParams); + router.push(`${pathName}?${queryString}`); + }, + [buildFirstPageParams, params, pathName, router], + ); + return { categories, optimisticCategories, @@ -119,6 +154,11 @@ const useCompanyCategoriesPresenter = () => { pathName, router, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, params, setParams, currentCategory, diff --git a/src/components/Tables/companies/index.tsx b/src/components/Tables/companies/index.tsx index f8a8742..e04b8da 100644 --- a/src/components/Tables/companies/index.tsx +++ b/src/components/Tables/companies/index.tsx @@ -18,6 +18,7 @@ import { TableHead, TableHeader, TableRow, + TableSortProvider, } from "@/components/ui/table"; import TableSkeleton from "@/components/ui/TableSkeleton"; import { _TooltipDefaultParams } from "@/constants/tooltip"; @@ -55,6 +56,11 @@ const CompaniesTable = () => { isPending, pathName, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, allCompanies, setIsModalOpen, router, @@ -93,18 +99,29 @@ const CompaniesTable = () => { onClearAll={clearAllFilters} />
+ - {columns.map((col) => ( - - {col} - - ))} + {columns.map((col) => { + const sortKey = columnSortKeys[col]; + return ( + + {col} + + ); + })} {isPending ? ( @@ -267,6 +284,7 @@ const CompaniesTable = () => { )}
+
{isModalOpen && ( { deleteCompany(currentCompany.id); } }; + + /** Column label → BSON `sort_by` field. */ + const columnSortKeys: Record = { + name: "name", + email: "email", + phone: "phone", + country: "address.country", + state: "address.state", + language: "language", + currency: "currency", + "employee count": "employee_count", + }; + + /** First-click direction per column. */ + const columnDefaultOrder: Record = { + name: "asc", + email: "asc", + phone: "asc", + country: "asc", + state: "asc", + language: "asc", + currency: "asc", + "employee count": "desc", + }; + + const sortBy = (params.sort_by as string) ?? "created_at"; + const sortOrder = (params.sort_order as SortDirection) ?? "desc"; + + const handleSortChange = useCallback( + (key: string, order: SortDirection) => { + const newParams = buildFirstPageParams({ + ...params, + sort_by: key, + sort_order: order, + }); + const cleanedParams = deleteEmptyKeys(newParams); + const queryString = buildQueryString(cleanedParams); + router.push(`${pathName}?${queryString}`); + }, + [buildFirstPageParams, params, pathName, router], + ); + const allCompanies = data?.data.companies ?? []; const metadata = data?.meta; return { @@ -111,6 +157,11 @@ export const useCompanyListPresenter = () => { onConfirmDelete, metadata, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, pathName, currentCompany, modalConfig, diff --git a/src/components/Tables/customers/index.tsx b/src/components/Tables/customers/index.tsx index 8342470..0336908 100644 --- a/src/components/Tables/customers/index.tsx +++ b/src/components/Tables/customers/index.tsx @@ -22,6 +22,7 @@ import { TableHead, TableHeader, TableRow, + TableSortProvider, } from "@/components/ui/table"; import TableSkeleton from "@/components/ui/TableSkeleton"; import { _TooltipDefaultParams } from "@/constants/tooltip"; @@ -38,6 +39,11 @@ const CustomersTable = () => { pathName, router, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, allCustomers, metadata, isModalOpen, @@ -84,18 +90,29 @@ const CustomersTable = () => { onClearAll={clearAllFilters} />
+ - {columns.map((col) => ( - - {col} - - ))} + {columns.map((col) => { + const sortKey = columnSortKeys[col]; + return ( + + {col} + + ); + })} {isPending ? ( @@ -266,6 +283,7 @@ const CustomersTable = () => { )}
+
{ } }; + /** Column label → BSON `sort_by` field. */ + const columnSortKeys: Record = { + "full name": "full_name", + email: "email", + "created at": "created_at", + type: "type", + status: "status", + role: "role", + }; + + /** First-click direction per column. */ + const columnDefaultOrder: Record = { + "full name": "asc", + email: "asc", + type: "asc", + role: "asc", + "created at": "desc", + }; + + const sortBy = (params.sort_by as string) ?? "created_at"; + const sortOrder = (params.sort_order as SortDirection) ?? "desc"; + + const handleSortChange = useCallback( + (key: string, order: SortDirection) => { + const newParams = buildFirstPageParams({ + ...params, + sort_by: key, + sort_order: order, + }); + const cleanedParams = deleteEmptyKeys(newParams); + const queryString = buildQueryString(cleanedParams); + router.push(`${pathName}?${queryString}`); + }, + [buildFirstPageParams, params, pathName, router], + ); + const customers = data?.data?.customers ?? []; const shouldShowPagination = !isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; @@ -153,6 +190,11 @@ const useCustomerListPresenter = () => { metadata: data?.meta, currentCustomer, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, isPending, setCurrentCustomer, selectedCompanies, diff --git a/src/components/Tables/tenders/index.tsx b/src/components/Tables/tenders/index.tsx index d94f174..3fcfd3e 100644 --- a/src/components/Tables/tenders/index.tsx +++ b/src/components/Tables/tenders/index.tsx @@ -10,6 +10,7 @@ import { TableHead, TableHeader, TableRow, + TableSortProvider, } from "@/components/ui/table"; import IsVisible from "@/components/ui/IsVisible"; import ListHeader from "@/components/ui/ListHeader"; @@ -25,6 +26,11 @@ import useTenderListPresenter from "./useTenderListPresenter"; const TendersTable = () => { const { columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, filterRegister, handleFilterSubmit, search, @@ -67,14 +73,28 @@ const TendersTable = () => { onClearAll={clearAllFilters} />
+ - {columns.map((column) => ( - - {column} - - ))} + {columns.map((column) => { + const sortKey = columnSortKeys[column]; + return ( + + {column} + + ); + })} @@ -153,6 +173,7 @@ const TendersTable = () => { )}
+
{ "status", "actions", ]; + + /** Column label → BSON `sort_by` field (see bp-panel-list-sorting.md). */ + const columnSortKeys: Record = { + title: "title", + "country code": "country_code", + "publication date": "publication_date", + "submission deadline": "submission_deadline", + "tender deadline": "tender_deadline", + "created at": "created_at", + status: "status", + }; + + /** First-click direction per column, following the BP sorting UX guide. */ + const columnDefaultOrder: Record = { + title: "asc", + "submission deadline": "asc", + "tender deadline": "asc", + }; + + const sortBy = (params.sort_by as string) ?? "created_at"; + const sortOrder = (params.sort_order as SortDirection) ?? "desc"; + + const handleSortChange = useCallback( + (key: string, order: SortDirection) => { + const newParams = buildFirstPageParams({ + ...params, + sort_by: key, + sort_order: order, + }); + const cleanedParams = deleteEmptyKeys(newParams); + const queryString = buildQueryString(cleanedParams); + router.push(`${pathName}?${queryString}`); + }, + [buildFirstPageParams, params, pathName, router], + ); + return { currentTender, columns, + columnSortKeys, + columnDefaultOrder, + sortBy, + sortOrder, + handleSortChange, setCurrentTender, router, allTenders: data, diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 53aa0c4..c9ea0f7 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -1,5 +1,13 @@ import { cn } from "@/lib/utils"; import * as React from "react"; +import { + SortDirection, + TableSortContext, + TableSortContextValue, +} from "./tableSortContext"; +import { useSortableHeadPresenter } from "./useSortableHeadPresenter"; + +export type { SortDirection }; export function Table({ className, @@ -15,6 +23,31 @@ export function Table({ ); } +/** + * Provides the active `sort_by` / `sort_order` state to any `TableHead` + * rendered with `sortable`. Wrap a `` with this to enable server-driven + * column sorting (see `bp-panel-list-sorting.md`). + */ +export function TableSortProvider({ + children, + ...value +}: TableSortContextValue & { children: React.ReactNode }) { + const memoizedValue = React.useMemo( + () => ({ + sortKey: value.sortKey, + sortOrder: value.sortOrder, + onSortChange: value.onSortChange, + }), + [value.sortKey, value.sortOrder, value.onSortChange], + ); + + return ( + + {children} + + ); +} + export function TableHeader({ className, ...props @@ -74,18 +107,133 @@ export const TableRow = React.forwardRef< }); TableRow.displayName = "TableRow"; +const tableHeadBaseClasses = + "h-13 px-4 text-left align-middle text-[11px] font-semibold uppercase tracking-[0.08em] text-dark-5 dark:text-dark-6 [&:has([role=checkbox])]:pr-0"; + +interface TableHeadProps + extends React.ThHTMLAttributes { + /** When set, renders an interactive, animated sort control. */ + sortable?: boolean; + /** BSON field name sent as `sort_by` (e.g. `created_at`). */ + sortKey?: string; + /** Direction requested on the first click of an inactive column. */ + defaultSortOrder?: SortDirection; +} + export function TableHead({ className, + sortable, + sortKey, + defaultSortOrder, ...props -}: React.ThHTMLAttributes) { +}: TableHeadProps) { + if (sortable && sortKey) { + return ( + + ); + } + + return ); } diff --git a/src/components/ui/tableSortContext.ts b/src/components/ui/tableSortContext.ts new file mode 100644 index 0000000..78acbcf --- /dev/null +++ b/src/components/ui/tableSortContext.ts @@ -0,0 +1,14 @@ +import { createContext } from "react"; + +export type SortDirection = "asc" | "desc"; + +export interface TableSortContextValue { + /** BSON field name (snake_case) currently used for `sort_by`. */ + sortKey?: string; + /** Active `sort_order` value. */ + sortOrder?: SortDirection; + /** Fired when a sortable header is toggled. */ + onSortChange?: (key: string, order: SortDirection) => void; +} + +export const TableSortContext = createContext({}); diff --git a/src/components/ui/useSortableHeadPresenter.ts b/src/components/ui/useSortableHeadPresenter.ts new file mode 100644 index 0000000..9f9b4d9 --- /dev/null +++ b/src/components/ui/useSortableHeadPresenter.ts @@ -0,0 +1,143 @@ +"use client"; + +import gsap from "gsap"; +import { useCallback, useContext, useLayoutEffect, useRef } from "react"; +import { SortDirection, TableSortContext } from "./tableSortContext"; + +const prefersReducedMotion = () => + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +/** + * Presenter for a single sortable column header. + * + * Owns the interaction logic (which direction to request next) and the GSAP + * choreography for the animated sort indicator, keeping the `table.tsx` view + * free of side effects per the architecture guidelines. + */ +export const useSortableHeadPresenter = ( + sortKey: string, + defaultSortOrder: SortDirection = "desc", +) => { + const { + sortKey: activeKey, + sortOrder, + onSortChange, + } = useContext(TableSortContext); + + const isActive = activeKey === sortKey; + const currentOrder: SortDirection = isActive + ? sortOrder ?? defaultSortOrder + : defaultSortOrder; + + const rootRef = useRef(null); + const pillRef = useRef(null); + const upRef = useRef(null); + const downRef = useRef(null); + const sweepRef = useRef(null); + const didMount = useRef(false); + + const handleClick = useCallback(() => { + if (!onSortChange) return; + const nextOrder: SortDirection = isActive + ? currentOrder === "asc" + ? "desc" + : "asc" + : defaultSortOrder; + onSortChange(sortKey, nextOrder); + }, [onSortChange, isActive, currentOrder, defaultSortOrder, sortKey]); + + useLayoutEffect(() => { + const reduced = prefersReducedMotion(); + + const ctx = gsap.context(() => { + const up = upRef.current; + const down = downRef.current; + const pill = pillRef.current; + const sweep = sweepRef.current; + if (!up || !down || !pill) return; + + const activeArrow = currentOrder === "asc" ? up : down; + const idleArrow = currentOrder === "asc" ? down : up; + + const activeOpacity = isActive ? 1 : 0.45; + const idleOpacity = isActive ? 0.2 : 0.45; + + if (reduced) { + gsap.set(idleArrow, { opacity: idleOpacity, y: 0, scale: 1 }); + gsap.set(activeArrow, { opacity: activeOpacity, y: 0, scale: 1 }); + return; + } + + // Resting state for whichever chevron is not the requested direction. + gsap.to(idleArrow, { + opacity: idleOpacity, + scale: 0.85, + y: 0, + duration: 0.3, + ease: "power2.out", + }); + + // First paint: settle without the celebratory burst. + if (!didMount.current) { + didMount.current = true; + gsap.set(activeArrow, { + opacity: activeOpacity, + scale: isActive ? 1 : 0.85, + y: 0, + }); + return; + } + + gsap.to(activeArrow, { + opacity: activeOpacity, + scale: isActive ? 1 : 0.85, + y: 0, + duration: 0.35, + ease: "power2.out", + }); + + if (!isActive) return; + + // Pop the pill and slide the chosen chevron in from its direction. + gsap.fromTo( + pill, + { scale: 0.78 }, + { scale: 1, duration: 0.55, ease: "back.out(3.2)" }, + ); + gsap.fromTo( + activeArrow, + { y: currentOrder === "asc" ? 7 : -7, scale: 0.4, opacity: 0 }, + { + y: 0, + scale: 1, + opacity: 1, + duration: 0.55, + ease: "back.out(2.6)", + }, + ); + + // A light sweep races across the indicator on every change. + if (sweep) { + gsap.fromTo( + sweep, + { xPercent: -160, opacity: 0.9 }, + { xPercent: 160, opacity: 0, duration: 0.7, ease: "power2.inOut" }, + ); + } + }, rootRef); + + return () => ctx.revert(); + }, [isActive, currentOrder]); + + return { + rootRef, + pillRef, + upRef, + downRef, + sweepRef, + isActive, + currentOrder, + handleClick, + }; +};
; +} + +function Chevron({ + direction, + className, +}: { + direction: "up" | "down"; + className?: string; +}) { + return ( + + ); +} + +function SortableTableHead({ + className, + sortKey, + defaultSortOrder = "desc", + children, + ...props +}: Omit & { sortKey: string }) { + const { + rootRef, + pillRef, + upRef, + downRef, + sweepRef, + isActive, + currentOrder, + handleClick, + } = useSortableHeadPresenter(sortKey, defaultSortOrder); + return ( + > + +