@m5kdev/web-ui 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
4
4
  import { useNuqsQueryParams } from "@m5kdev/frontend/modules/table/hooks/useNuqsQueryParams";
5
5
  import { calendarDateToEndOfDayUTC, calendarDateToUTC, dateFilterToRangeValue, } from "@m5kdev/web-ui/modules/table/filterTransformers";
6
6
  import { DateTime } from "luxon";
7
- import { useEffect, useMemo, useState } from "react";
7
+ import { startTransition, useEffect, useMemo, useState } from "react";
8
8
  import { useTranslation } from "react-i18next";
9
9
  const toCalendarDate = (dt) => new CalendarDate(dt.year, dt.month, dt.day);
10
10
  const isSameCalendarDate = (a, b) => a.year === b.year && a.month === b.month && a.day === b.day;
@@ -17,6 +17,10 @@ export const RangeNuqsDatePicker = ({ columnId = "startedAt", endColumnId = "end
17
17
  const dateRange = useMemo(() => {
18
18
  return dateFilterToRangeValue(filters, columnId);
19
19
  }, [filters, columnId]);
20
+ const [localRange, setLocalRange] = useState(dateRange);
21
+ useEffect(() => {
22
+ setLocalRange(dateRange);
23
+ }, [dateRange]);
20
24
  const todayIso = DateTime.utc().toISODate();
21
25
  const quickRangeOptions = useMemo(() => {
22
26
  const now = DateTime.utc();
@@ -186,12 +190,15 @@ export const RangeNuqsDatePicker = ({ columnId = "startedAt", endColumnId = "end
186
190
  const handleDateRangeChange = (range) => {
187
191
  if (!setFilters)
188
192
  return;
189
- if (!range?.start || !range?.end) {
190
- // Remove date filters
193
+ if (range === null) {
194
+ // Explicit clear — remove date filters
191
195
  const newFilters = filters?.filter((f) => f.columnId !== columnId && f.columnId !== endColumnId) ?? [];
192
- setFilters(newFilters);
196
+ startTransition(() => setFilters(newFilters));
193
197
  return;
194
198
  }
199
+ // Incomplete range (e.g. only start selected mid-calendar-selection) — keep current filters
200
+ if (!range.start || !range.end)
201
+ return;
195
202
  // Use intersect to find records where [startedAt, endedAt] overlaps with the selected range
196
203
  // This includes ongoing records (endedAt = NULL) and records that overlap the range
197
204
  const startDate = range.start;
@@ -207,7 +214,7 @@ export const RangeNuqsDatePicker = ({ columnId = "startedAt", endColumnId = "end
207
214
  endColumnId,
208
215
  },
209
216
  ];
210
- setFilters(newFilters);
217
+ startTransition(() => setFilters(newFilters));
211
218
  };
212
219
  const handleQuickRangeChange = (key) => {
213
220
  if (!key) {
@@ -224,7 +231,11 @@ export const RangeNuqsDatePicker = ({ columnId = "startedAt", endColumnId = "end
224
231
  });
225
232
  };
226
233
  return (_jsxs("div", { className: `flex gap-4 flex-row ${className ?? ""}`, children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: dateRangeLabel ??
227
- t(`${translationNamespace}:reports.dateRange`, { defaultValue: "Date range" }) }), _jsx(DateRangePicker, { value: dateRange ?? undefined, onChange: handleDateRangeChange, className: "w-[300px]", granularity: "day", showMonthAndYearPickers: true, maxValue: today(getLocalTimeZone()), popoverProps: {
234
+ t(`${translationNamespace}:reports.dateRange`, { defaultValue: "Date range" }) }), _jsx(DateRangePicker, { "aria-label": "Date range", value: localRange ?? undefined, onChange: setLocalRange, onBlur: () => {
235
+ if (localRange !== undefined) {
236
+ handleDateRangeChange(localRange);
237
+ }
238
+ }, className: "w-[300px]", granularity: "day", showMonthAndYearPickers: true, maxValue: today(getLocalTimeZone()), popoverProps: {
228
239
  portalContainer: document.body,
229
240
  disableAnimation: true,
230
241
  } })] }), showQuickRange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: quickRangeLabel ??
@@ -10,6 +10,7 @@ export type NuqsTableColumn<T> = ColumnDef<T> & {
10
10
  value: string;
11
11
  }[];
12
12
  endColumnId?: string;
13
+ groupable?: boolean;
13
14
  };
14
15
  type NuqsTableParams<T> = {
15
16
  data: T[];
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Checkbox, Popover, PopoverContent, PopoverTrigger } from "@heroui/react";
3
- import { flexRender, getCoreRowModel, getPaginationRowModel, useReactTable, } from "@tanstack/react-table";
4
- import { ChevronDown, ChevronUp } from "lucide-react";
3
+ import { flexRender, getCoreRowModel, getExpandedRowModel, getGroupedRowModel, getPaginationRowModel, useReactTable, } from "@tanstack/react-table";
4
+ import { ChevronDown, ChevronRight, ChevronUp } from "lucide-react";
5
5
  import { useEffect, useMemo, useState } from "react";
6
6
  import { Button } from "../../../components/ui/button";
7
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table";
8
8
  import { ColumnOrderAndVisibility } from "./ColumnOrderAndVisibility";
9
9
  import { TableFiltering } from "./TableFiltering";
10
+ import { TableGroupBy } from "./TableGroupBy";
10
11
  import { TablePagination } from "./TablePagination";
11
12
  function getStorageKey(columnIds) {
12
13
  const sortedIds = [...columnIds].sort().join(",");
@@ -117,15 +118,17 @@ export const NuqsTable = ({ data, total, columns, tableProps, singleFilter = fal
117
118
  }, [layout, columnIds]);
118
119
  const columnOrder = useMemo(() => layout.map((column) => column.id), [layout]);
119
120
  const columnVisibility = useMemo(() => Object.fromEntries(layout.map((column) => [column.id, column.visibility])), [layout]);
120
- const { limit = 10, page = 1, sorting, setSorting, setPagination, pagination, rowSelection, setRowSelection, setFilters, filters, } = tableProps;
121
+ const { limit = 10, page = 1, sorting, setSorting, setPagination, pagination, rowSelection, setRowSelection, setFilters, filters, grouping, setGrouping, } = tableProps;
122
+ const isGrouped = grouping.length > 0;
123
+ const [expanded, setExpanded] = useState({});
121
124
  // Redirect back if we're on an empty page (past the last page)
122
125
  useEffect(() => {
123
- if (data.length === 0 && page > 1 && total !== undefined) {
126
+ if (!isGrouped && data.length === 0 && page > 1 && total !== undefined) {
124
127
  setPagination?.({ pageIndex: page - 2, pageSize: limit });
125
128
  }
126
- }, [data.length, page, limit, total, setPagination]);
127
- // Calculate pageCount from total if available, otherwise use heuristic
128
- const pageCount = total !== undefined
129
+ }, [data.length, page, limit, total, setPagination, isGrouped]);
130
+ // When grouped, TanStack handles pagination client-side; otherwise use server total
131
+ const serverPageCount = total !== undefined
129
132
  ? Math.ceil(total / limit) || 1
130
133
  : data.length === limit
131
134
  ? page + 1
@@ -135,14 +138,21 @@ export const NuqsTable = ({ data, total, columns, tableProps, singleFilter = fal
135
138
  columns,
136
139
  getRowId: (row) => String(row.id),
137
140
  manualSorting: true,
138
- manualPagination: true,
139
- state: { pagination, sorting, rowSelection, columnOrder, columnVisibility },
140
- pageCount,
141
+ manualPagination: !isGrouped,
142
+ state: { pagination, sorting, rowSelection, columnOrder, columnVisibility, grouping, expanded },
143
+ ...(isGrouped ? {} : { pageCount: serverPageCount }),
141
144
  manualFiltering: true,
145
+ enableGrouping: true,
146
+ groupedColumnMode: false,
147
+ autoResetExpanded: false,
142
148
  onSortingChange: setSorting,
143
149
  onPaginationChange: setPagination,
144
150
  onRowSelectionChange: setRowSelection,
151
+ onGroupingChange: setGrouping,
152
+ onExpandedChange: setExpanded,
145
153
  getCoreRowModel: getCoreRowModel(),
154
+ getGroupedRowModel: getGroupedRowModel(),
155
+ getExpandedRowModel: getExpandedRowModel(),
146
156
  getPaginationRowModel: getPaginationRowModel(),
147
157
  onColumnOrderChange: (updater) => {
148
158
  setLayout((prev) => applyOrder(prev, typeof updater === "function" ? updater(prev.map((i) => i.id)) : updater));
@@ -188,11 +198,37 @@ export const NuqsTable = ({ data, total, columns, tableProps, singleFilter = fal
188
198
  }));
189
199
  return [...baseColumns, ...periodColumns];
190
200
  }, [columns]);
201
+ const groupableColumns = useMemo(() => columns
202
+ .filter((col) => col.groupable)
203
+ .map((col) => ({ id: String(col.id), label: String(col.header) })), [columns]);
204
+ const hasGrouping = grouping.length > 0;
191
205
  const [isFiltersOpen, setIsFiltersOpen] = useState(false);
192
206
  const [isColumnsOpen, setIsColumnsOpen] = useState(false);
193
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex w-full items-center gap-2 justify-end", children: [_jsxs(Popover, { placement: "bottom", isOpen: isFiltersOpen, onOpenChange: setIsFiltersOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: ["Filters", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(TableFiltering, { columns: filterableColumns, onFiltersChange: onFiltersChange, filters: filters ?? [], onClose: () => setIsFiltersOpen(false), singleFilter: singleFilter, filterMethods: filterMethods }) })] }), _jsxs(Popover, { placement: "bottom", isOpen: isColumnsOpen, onOpenChange: setIsColumnsOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: ["Columns", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(ColumnOrderAndVisibility, { layout: layout, onChangeOrder: onChangeOrder, onChangeVisibility: onChangeVisibility, onClose: () => setIsColumnsOpen(false) }) })] })] }), _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: _jsx(Checkbox, { isSelected: table.getIsAllRowsSelected(), onValueChange: (checked) => {
207
+ const [isGroupByOpen, setIsGroupByOpen] = useState(false);
208
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex w-full items-center gap-2 justify-end", children: [_jsxs(Popover, { placement: "bottom", isOpen: isFiltersOpen, onOpenChange: setIsFiltersOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: ["Filters", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(TableFiltering, { columns: filterableColumns, onFiltersChange: onFiltersChange, filters: filters ?? [], onClose: () => setIsFiltersOpen(false), singleFilter: singleFilter, filterMethods: filterMethods }) })] }), groupableColumns.length > 0 && (_jsxs(Popover, { placement: "bottom", isOpen: isGroupByOpen, onOpenChange: setIsGroupByOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: hasGrouping ? "secondary" : "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: [hasGrouping
209
+ ? `Grouped by: ${grouping.map((id) => groupableColumns.find((c) => c.id === id)?.label ?? id).join(" → ")}`
210
+ : "Group by", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(TableGroupBy, { columns: groupableColumns, activeGrouping: grouping, onGroupingChange: (columnIds) => {
211
+ setGrouping(columnIds);
212
+ setExpanded({});
213
+ setPagination?.({ pageIndex: 0, pageSize: limit });
214
+ }, onClose: () => setIsGroupByOpen(false) }) })] })), _jsxs(Popover, { placement: "bottom", isOpen: isColumnsOpen, onOpenChange: setIsColumnsOpen, portalContainer: document.body, children: [_jsx(PopoverTrigger, { children: _jsx(Button, { variant: "outline", size: "sm", children: _jsxs("div", { className: "flex items-center gap-2", children: ["Columns", _jsx(ChevronDown, { className: "h-4 w-4" })] }) }) }), _jsx(PopoverContent, { children: _jsx(ColumnOrderAndVisibility, { layout: layout, onChangeOrder: onChangeOrder, onChangeVisibility: onChangeVisibility, onClose: () => setIsColumnsOpen(false) }) })] })] }), _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: _jsx(Checkbox, { isSelected: table.getIsAllRowsSelected(), onValueChange: (checked) => {
194
215
  table.toggleAllRowsSelected(checked);
195
- } }) }), table.getHeaderGroups()[0].headers.map((header) => (_jsxs(TableHead, { onClick: header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined, children: [flexRender(header.column.columnDef.header, header.getContext()), header.column.getCanSort() && (_jsxs(_Fragment, { children: [header.column.getIsSorted() === "asc" && (_jsx(ChevronUp, { className: "h-4 w-4 inline ml-1" })), header.column.getIsSorted() === "desc" && (_jsx(ChevronDown, { className: "h-4 w-4 inline ml-1" }))] }))] }, header.id)))] }) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Checkbox, { isSelected: row.getIsSelected(), onValueChange: (checked) => {
196
- row.toggleSelected(checked);
197
- } }) }), row.getVisibleCells().map((cell) => (_jsx(TableCell, { children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id)))] }, row.id))) })] }), _jsx(TablePagination, { pageCount: pageCount, page: page, limit: limit, setPagination: setPagination })] }));
216
+ } }) }), table.getHeaderGroups()[0].headers.map((header) => (_jsxs(TableHead, { onClick: header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined, children: [flexRender(header.column.columnDef.header, header.getContext()), header.column.getCanSort() && (_jsxs(_Fragment, { children: [header.column.getIsSorted() === "asc" && (_jsx(ChevronUp, { className: "h-4 w-4 inline ml-1" })), header.column.getIsSorted() === "desc" && (_jsx(ChevronDown, { className: "h-4 w-4 inline ml-1" }))] }))] }, header.id)))] }) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => {
217
+ if (row.getIsGrouped()) {
218
+ return (_jsxs(TableRow, { className: "bg-muted/40 font-medium cursor-pointer hover:bg-muted/60", onClick: () => row.toggleExpanded(), children: [_jsx(TableCell, {}), row.getVisibleCells().map((cell) => {
219
+ if (cell.getIsGrouped()) {
220
+ return (_jsx(TableCell, { children: _jsxs("span", { className: "flex items-center gap-1.5", children: [row.getIsExpanded() ? (_jsx(ChevronDown, { className: "h-4 w-4 shrink-0" })) : (_jsx(ChevronRight, { className: "h-4 w-4 shrink-0" })), flexRender(cell.column.columnDef.cell, cell.getContext()), _jsxs("span", { className: "text-muted-foreground font-normal ml-1", children: ["(", row.subRows.length, ")"] })] }) }, cell.id));
221
+ }
222
+ if (cell.getIsAggregated()) {
223
+ return (_jsx(TableCell, { children: flexRender(cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell, cell.getContext()) }, cell.id));
224
+ }
225
+ return _jsx(TableCell, {}, cell.id);
226
+ })] }, row.id));
227
+ }
228
+ return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Checkbox, { isSelected: row.getIsSelected(), onValueChange: (checked) => {
229
+ row.toggleSelected(checked);
230
+ } }) }), row.getVisibleCells().map((cell) => (_jsx(TableCell, { children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id)))] }, row.id));
231
+ }) })] }), _jsx(TablePagination, { pageCount: isGrouped
232
+ ? Math.ceil(table.getPrePaginationRowModel().rows.length / limit) || 1
233
+ : serverPageCount, page: isGrouped ? table.getState().pagination.pageIndex + 1 : page, limit: limit, setPagination: setPagination })] }));
198
234
  };
@@ -0,0 +1,12 @@
1
+ interface TableGroupByColumn {
2
+ id: string;
3
+ label: string;
4
+ }
5
+ interface TableGroupByProps {
6
+ columns: TableGroupByColumn[];
7
+ activeGrouping: string[];
8
+ onGroupingChange: (columnIds: string[]) => void;
9
+ onClose: () => void;
10
+ }
11
+ export declare const TableGroupBy: ({ columns, activeGrouping, onGroupingChange, onClose, }: TableGroupByProps) => import("react/jsx-runtime").JSX.Element;
12
+ export {};
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Button } from "../../../components/ui/button";
4
+ import { cn } from "../../../lib/utils";
5
+ export const TableGroupBy = ({ columns, activeGrouping, onGroupingChange, onClose, }) => {
6
+ const [selected, setSelected] = useState(activeGrouping);
7
+ const toggle = (id) => {
8
+ setSelected((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
9
+ };
10
+ const onApply = () => {
11
+ onGroupingChange(selected);
12
+ onClose();
13
+ };
14
+ const onClear = () => {
15
+ setSelected([]);
16
+ };
17
+ return (_jsxs("div", { className: "flex flex-col gap-2 p-1 min-w-[180px]", children: [_jsx("div", { className: "flex flex-col gap-0.5", children: columns.map((col) => (_jsxs("button", { type: "button", className: cn("text-left px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors flex items-center gap-2", selected.includes(col.id) && "bg-muted font-medium"), onClick: () => toggle(col.id), children: [_jsx("span", { className: cn("h-4 w-4 rounded border border-muted-foreground/40 flex items-center justify-center shrink-0 text-xs", selected.includes(col.id) && "bg-primary border-primary text-primary-foreground"), children: selected.includes(col.id) ? "✓" : "" }), col.label, selected.includes(col.id) && selected.length > 1 && (_jsx("span", { className: "text-muted-foreground text-xs ml-auto", children: selected.indexOf(col.id) + 1 }))] }, col.id))) }), _jsxs("div", { className: "flex gap-1.5", children: [_jsx(Button, { size: "sm", variant: "outline", onClick: onClear, className: "flex-1", children: "Clear" }), _jsx(Button, { size: "sm", onClick: onApply, className: "flex-1", children: "Apply" })] })] }));
18
+ };
@@ -1,19 +1,32 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
2
3
  import { Input } from "../../../components/ui/input";
3
4
  import { Pagination, PaginationContent, PaginationNext, PaginationPrevious, } from "../../../components/ui/pagination";
4
5
  export const TablePagination = ({ pageCount, page = 1, limit = 10, setPagination, }) => {
6
+ const [inputValue, setInputValue] = useState(String(page));
7
+ useEffect(() => {
8
+ setInputValue(String(page));
9
+ }, [page]);
10
+ const commitPage = () => {
11
+ const newPage = Number(inputValue);
12
+ if (Number.isFinite(newPage) && newPage >= 1 && newPage <= pageCount) {
13
+ setPagination?.({ pageIndex: newPage - 1, pageSize: limit });
14
+ }
15
+ else {
16
+ setInputValue(String(page));
17
+ }
18
+ };
5
19
  const isFirstPage = page === 1;
6
20
  const isLastPage = page >= pageCount;
7
- return (_jsx(Pagination, { children: _jsxs(PaginationContent, { children: [_jsx(PaginationPrevious, { isActive: !isFirstPage, onClick: () => {
21
+ return (_jsx(Pagination, { children: _jsxs(PaginationContent, { children: [_jsx(PaginationPrevious, { isActive: !isFirstPage, "aria-disabled": isFirstPage, className: isFirstPage ? "pointer-events-none opacity-50" : undefined, onClick: () => {
8
22
  if (!isFirstPage) {
9
23
  setPagination?.({ pageIndex: page - 2, pageSize: limit });
10
24
  }
11
- } }), _jsx(Input, { type: "number", value: page, min: 1, max: pageCount, onChange: (e) => {
12
- const newPage = e.target.valueAsNumber;
13
- if (newPage >= 1 && newPage <= pageCount) {
14
- setPagination?.({ pageIndex: newPage - 1, pageSize: limit });
15
- }
16
- } }), _jsx(PaginationNext, { isActive: !isLastPage, onClick: () => {
25
+ } }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Input, { type: "number", value: inputValue, min: 1, max: pageCount, onChange: (e) => setInputValue(e.target.value), onBlur: commitPage, onKeyDown: (e) => {
26
+ if (e.key === "Enter") {
27
+ commitPage();
28
+ }
29
+ } }), _jsxs("span", { className: "text-sm text-muted-foreground whitespace-nowrap", children: ["/ ", pageCount] })] }), _jsx(PaginationNext, { isActive: !isLastPage, "aria-disabled": isLastPage, className: isLastPage ? "pointer-events-none opacity-50" : undefined, onClick: () => {
17
30
  if (!isLastPage) {
18
31
  setPagination?.({ pageIndex: page, pageSize: limit });
19
32
  }