@m5kdev/web-ui 0.3.1 → 0.3.2
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.
- package/dist/src/modules/auth/components/RangeNuqsDatePicker.js +17 -6
- package/dist/src/modules/table/components/NuqsTable.d.ts +1 -0
- package/dist/src/modules/table/components/NuqsTable.js +50 -14
- package/dist/src/modules/table/components/TableGroupBy.d.ts +12 -0
- package/dist/src/modules/table/components/TableGroupBy.js +18 -0
- package/dist/src/modules/table/components/TablePagination.js +20 -7
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +5 -5
|
@@ -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 (
|
|
190
|
-
//
|
|
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, {
|
|
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 ??
|
|
@@ -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
|
-
//
|
|
128
|
-
const
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
}
|