@suphark/ui 0.1.1 → 0.2.0
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/CHANGELOG.md +11 -0
- package/README.md +5 -1
- package/package.json +13 -2
- package/src/components/data-table/data-table-column-header.tsx +71 -0
- package/src/components/data-table/data-table-date-range-filter.tsx +25 -0
- package/src/components/data-table/data-table-faceted-filter.tsx +181 -0
- package/src/components/data-table/data-table-pagination.tsx +114 -0
- package/src/components/data-table/data-table-row-actions.tsx +166 -0
- package/src/components/data-table/data-table-toolbar.tsx +207 -0
- package/src/components/data-table/data-table-view-option.tsx +59 -0
- package/src/components/data-table/data-table.tsx +399 -0
- package/src/components/data-table/index.ts +9 -0
- package/src/components/data-table/types.ts +106 -0
- package/src/components/data-table/use-data-table.ts +229 -0
- package/src/components/design-system/sections/showcase-data-display.tsx +71 -0
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +8 -8
- package/src/components/shared/button/delete-many-button.tsx +85 -0
- package/src/components/shared/resource-property-filter.tsx +399 -0
- package/src/data-table.ts +21 -0
- package/src/hooks/use-table-search-params.ts +147 -0
- package/src/next.ts +5 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnDef,
|
|
3
|
+
ColumnFiltersState,
|
|
4
|
+
FilterFn,
|
|
5
|
+
OnChangeFn,
|
|
6
|
+
PaginationState,
|
|
7
|
+
Row,
|
|
8
|
+
SortingState,
|
|
9
|
+
Table,
|
|
10
|
+
TableState,
|
|
11
|
+
VisibilityState,
|
|
12
|
+
} from "@tanstack/react-table";
|
|
13
|
+
import type React from "react";
|
|
14
|
+
|
|
15
|
+
export type FacetedFilterConfig = {
|
|
16
|
+
columnId: string;
|
|
17
|
+
title: string;
|
|
18
|
+
compact?: boolean;
|
|
19
|
+
options: {
|
|
20
|
+
label: string;
|
|
21
|
+
value: string | number | boolean;
|
|
22
|
+
icon?: React.ComponentType<{ className?: string }>;
|
|
23
|
+
}[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface DataTableProps<TData, TValue> {
|
|
27
|
+
columns: ColumnDef<TData, TValue>[];
|
|
28
|
+
data: TData[];
|
|
29
|
+
|
|
30
|
+
// Styling Hooks
|
|
31
|
+
className?: string; // Appended to table wrap
|
|
32
|
+
containerClassName?: string; // Appended to outer wrapper
|
|
33
|
+
/**
|
|
34
|
+
* Appended to the scrollable box that wraps the table itself.
|
|
35
|
+
*
|
|
36
|
+
* Needed for `stickyHeader`: the box already scrolls horizontally, and CSS
|
|
37
|
+
* forces the vertical axis to `auto` alongside it, so it — not the page — is
|
|
38
|
+
* what a sticky header anchors to. Give it a height (e.g. `max-h-[70vh]`) or
|
|
39
|
+
* the header has nothing to stick within and scrolls away with the page.
|
|
40
|
+
*/
|
|
41
|
+
tableContainerClassName?: string;
|
|
42
|
+
/** ตรึงแถบเปลี่ยนหน้าไว้ล่างสุดของจอเสมอ ใช้คู่กับ `stickyHeader` */
|
|
43
|
+
stickyPagination?: boolean;
|
|
44
|
+
|
|
45
|
+
// Accessibility & UI
|
|
46
|
+
stickyHeader?: boolean;
|
|
47
|
+
stickyEdgeColumns?: boolean;
|
|
48
|
+
emptyStateComponent?: React.ReactNode;
|
|
49
|
+
|
|
50
|
+
// Filters & Toolbar
|
|
51
|
+
facetedFilters?: FacetedFilterConfig[];
|
|
52
|
+
actionsComponent?: (table: Table<TData>) => React.ReactNode;
|
|
53
|
+
globalFilterKeys?: string[];
|
|
54
|
+
globalFilterFn?: FilterFn<TData>;
|
|
55
|
+
globalFilterPlaceholder?: string;
|
|
56
|
+
toolbarFilters?: (table: Table<TData>) => React.ReactNode;
|
|
57
|
+
preservedFilterIds?: string[];
|
|
58
|
+
|
|
59
|
+
// State & Visibility
|
|
60
|
+
initialVisibility?: VisibilityState;
|
|
61
|
+
initialColumnFilters?: ColumnFiltersState;
|
|
62
|
+
columnFilters?: ColumnFiltersState;
|
|
63
|
+
initialState?: Partial<TableState>;
|
|
64
|
+
|
|
65
|
+
// Pagination & Sorting (Server-side support)
|
|
66
|
+
pageCount?: number;
|
|
67
|
+
rowCount?: number;
|
|
68
|
+
/**
|
|
69
|
+
* ควบคุม pagination จากภายนอก (ใช้คู่กับ manualPagination)
|
|
70
|
+
* จำเป็นเมื่อ state จริงอยู่ที่อื่น เช่นใน URL — ไม่งั้นเลขหน้าใน UI จะไม่ตรงกับข้อมูลที่ server ส่งมา
|
|
71
|
+
*/
|
|
72
|
+
pagination?: PaginationState;
|
|
73
|
+
/** ควบคุม sorting จากภายนอก (ใช้คู่กับ manualSorting) */
|
|
74
|
+
sorting?: SortingState;
|
|
75
|
+
/** ควบคุมคำค้นจากภายนอก (ใช้คู่กับ manualFiltering) */
|
|
76
|
+
globalFilter?: string;
|
|
77
|
+
/**
|
|
78
|
+
* เรียกเมื่อผู้ใช้พิมพ์ในช่องค้นหา
|
|
79
|
+
* ต้องส่งมาด้วยเมื่อเปิด manualFiltering ไม่งั้นช่องค้นหาจะไม่ทำอะไรเลย
|
|
80
|
+
* เพราะ TanStack ถูกสั่งไม่ให้กรองเองแล้ว
|
|
81
|
+
*/
|
|
82
|
+
onGlobalFilterChange?: (value: string) => void;
|
|
83
|
+
manualPagination?: boolean;
|
|
84
|
+
manualSorting?: boolean;
|
|
85
|
+
manualFiltering?: boolean;
|
|
86
|
+
onPaginationChange?: OnChangeFn<PaginationState>;
|
|
87
|
+
onPageSizeChange?: (pageSize: number) => void;
|
|
88
|
+
onSortingChange?: OnChangeFn<SortingState>;
|
|
89
|
+
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
|
|
90
|
+
|
|
91
|
+
// Customization
|
|
92
|
+
getRowClassName?: (row: Row<TData>) => string;
|
|
93
|
+
getSubRows?: (originalRow: TData) => TData[] | undefined;
|
|
94
|
+
getRowId?: (originalRow: TData) => string;
|
|
95
|
+
initialPageSize?: number;
|
|
96
|
+
meta?: Record<string, unknown>;
|
|
97
|
+
isLoading?: boolean;
|
|
98
|
+
|
|
99
|
+
// Advanced
|
|
100
|
+
enableColumnResizing?: boolean;
|
|
101
|
+
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
|
|
102
|
+
getFacetedUniqueValues?: (table: Table<TData>, columnId: string) => () => Map<unknown, number>;
|
|
103
|
+
pageSizeOptions?: number[];
|
|
104
|
+
onReorder?: (newData: TData[]) => void;
|
|
105
|
+
renderCustomView?: (table: Table<TData>) => React.ReactNode;
|
|
106
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ColumnFiltersState,
|
|
5
|
+
type FilterFn,
|
|
6
|
+
getCoreRowModel,
|
|
7
|
+
getExpandedRowModel,
|
|
8
|
+
getFacetedRowModel,
|
|
9
|
+
getFilteredRowModel,
|
|
10
|
+
getPaginationRowModel,
|
|
11
|
+
getSortedRowModel,
|
|
12
|
+
type PaginationState,
|
|
13
|
+
type SortingState,
|
|
14
|
+
useReactTable,
|
|
15
|
+
type VisibilityState,
|
|
16
|
+
} from "@tanstack/react-table";
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
|
|
19
|
+
import type { DataTableProps } from "./types";
|
|
20
|
+
|
|
21
|
+
function getNestedValue<TData>(obj: TData, path: string): unknown {
|
|
22
|
+
let current: unknown = obj;
|
|
23
|
+
for (const key of path.split(".")) {
|
|
24
|
+
if (current && typeof current === "object" && key in current) {
|
|
25
|
+
current = (current as Record<string, unknown>)[key];
|
|
26
|
+
} else {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useDataTable<TData, TValue>({
|
|
34
|
+
columns,
|
|
35
|
+
data,
|
|
36
|
+
initialVisibility = {},
|
|
37
|
+
globalFilterFn,
|
|
38
|
+
globalFilterKeys,
|
|
39
|
+
getFacetedUniqueValues: getFacetedUniqueValuesProp,
|
|
40
|
+
initialColumnFilters = [],
|
|
41
|
+
columnFilters: columnFiltersProp,
|
|
42
|
+
getSubRows,
|
|
43
|
+
getRowId,
|
|
44
|
+
initialPageSize = 25,
|
|
45
|
+
meta,
|
|
46
|
+
initialState,
|
|
47
|
+
pageCount,
|
|
48
|
+
rowCount,
|
|
49
|
+
pagination: paginationProp,
|
|
50
|
+
sorting: sortingProp,
|
|
51
|
+
globalFilter: globalFilterProp,
|
|
52
|
+
onGlobalFilterChange,
|
|
53
|
+
manualPagination,
|
|
54
|
+
manualSorting,
|
|
55
|
+
manualFiltering,
|
|
56
|
+
onPaginationChange,
|
|
57
|
+
onSortingChange,
|
|
58
|
+
onColumnFiltersChange,
|
|
59
|
+
enableColumnResizing = false,
|
|
60
|
+
enableRowSelection = true,
|
|
61
|
+
}: DataTableProps<TData, TValue>) {
|
|
62
|
+
const [rowSelection, setRowSelection] = React.useState({});
|
|
63
|
+
const [columnVisibility, setColumnVisibility] =
|
|
64
|
+
React.useState<VisibilityState>(initialVisibility);
|
|
65
|
+
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
|
66
|
+
columnFiltersProp ?? initialColumnFilters,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Sync with prop if provided
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
if (columnFiltersProp !== undefined) {
|
|
72
|
+
setColumnFilters(columnFiltersProp);
|
|
73
|
+
}
|
|
74
|
+
}, [columnFiltersProp]);
|
|
75
|
+
const [sorting, setSorting] = React.useState<SortingState>(
|
|
76
|
+
() => sortingProp ?? (initialState?.sorting as SortingState | undefined) ?? [],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Sync with prop if provided — เทียบด้วยค่าเหมือน pagination ด้วยเหตุผลเดียวกัน
|
|
80
|
+
const sortingKey = sortingProp ? JSON.stringify(sortingProp) : undefined;
|
|
81
|
+
React.useEffect(() => {
|
|
82
|
+
if (sortingKey === undefined) return;
|
|
83
|
+
setSorting((current) =>
|
|
84
|
+
JSON.stringify(current) === sortingKey ? current : JSON.parse(sortingKey),
|
|
85
|
+
);
|
|
86
|
+
}, [sortingKey]);
|
|
87
|
+
const [globalFilter, setGlobalFilter] = React.useState(
|
|
88
|
+
globalFilterProp ?? (initialState?.globalFilter as string) ?? "",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Sync with prop if provided
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
if (globalFilterProp !== undefined) {
|
|
94
|
+
setGlobalFilter(globalFilterProp);
|
|
95
|
+
}
|
|
96
|
+
}, [globalFilterProp]);
|
|
97
|
+
const [pagination, setPagination] = React.useState<PaginationState>(
|
|
98
|
+
paginationProp ?? { pageIndex: 0, pageSize: initialPageSize },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Sync with prop if provided — กันเลขหน้าใน UI หลุดจาก state จริงที่อยู่ใน URL
|
|
102
|
+
//
|
|
103
|
+
// ต้องเทียบด้วยค่า ไม่ใช่ตัวอ้างอิง เพราะผู้เรียกมักส่ง object literal ใหม่ทุก render
|
|
104
|
+
// ถ้าผูก effect ไว้กับตัว object มันจะรันทุกครั้งแล้วรีเซ็ต state ทับ
|
|
105
|
+
// การกดเปลี่ยนหน้าที่เพิ่งเกิดขึ้นทิ้งไปก่อนที่ค่าใหม่จะกลับมาจาก server
|
|
106
|
+
const pageIndexProp = paginationProp?.pageIndex;
|
|
107
|
+
const pageSizeProp = paginationProp?.pageSize;
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
if (pageIndexProp === undefined || pageSizeProp === undefined) return;
|
|
110
|
+
setPagination((current) =>
|
|
111
|
+
current.pageIndex === pageIndexProp && current.pageSize === pageSizeProp
|
|
112
|
+
? current
|
|
113
|
+
: { pageIndex: pageIndexProp, pageSize: pageSizeProp },
|
|
114
|
+
);
|
|
115
|
+
}, [pageIndexProp, pageSizeProp]);
|
|
116
|
+
|
|
117
|
+
// Sync initialPageSize when it loads asynchronously (e.g. from user preferences)
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
setPagination((prev) => {
|
|
120
|
+
// Only update if the size actually changed
|
|
121
|
+
if (prev.pageSize !== initialPageSize) {
|
|
122
|
+
return { ...prev, pageSize: initialPageSize };
|
|
123
|
+
}
|
|
124
|
+
return prev;
|
|
125
|
+
});
|
|
126
|
+
}, [initialPageSize]);
|
|
127
|
+
|
|
128
|
+
const finalGlobalFilterFn = React.useMemo(() => {
|
|
129
|
+
if (globalFilterFn) {
|
|
130
|
+
return globalFilterFn;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (globalFilterKeys && globalFilterKeys.length > 0) {
|
|
134
|
+
const generatedFn: FilterFn<TData> = (row, _columnId, filterValue) => {
|
|
135
|
+
const search = String(filterValue).toLowerCase();
|
|
136
|
+
for (const key of globalFilterKeys) {
|
|
137
|
+
const value = getNestedValue(row.original, key as string);
|
|
138
|
+
|
|
139
|
+
if (value !== null && value !== undefined) {
|
|
140
|
+
const valueAsString = String(value).toLowerCase();
|
|
141
|
+
if (valueAsString.includes(search)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
};
|
|
148
|
+
return generatedFn;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return undefined;
|
|
152
|
+
}, [globalFilterFn, globalFilterKeys]);
|
|
153
|
+
|
|
154
|
+
const memoizedData = React.useMemo(() => data, [data]);
|
|
155
|
+
const memoizedColumns = React.useMemo(() => columns, [columns]);
|
|
156
|
+
|
|
157
|
+
const table = useReactTable({
|
|
158
|
+
data: memoizedData,
|
|
159
|
+
columns: memoizedColumns,
|
|
160
|
+
pageCount: pageCount ?? (manualPagination ? -1 : undefined),
|
|
161
|
+
rowCount,
|
|
162
|
+
manualPagination,
|
|
163
|
+
manualSorting,
|
|
164
|
+
manualFiltering,
|
|
165
|
+
state: {
|
|
166
|
+
sorting,
|
|
167
|
+
columnVisibility,
|
|
168
|
+
rowSelection,
|
|
169
|
+
columnFilters,
|
|
170
|
+
globalFilter,
|
|
171
|
+
pagination,
|
|
172
|
+
},
|
|
173
|
+
enableColumnResizing,
|
|
174
|
+
columnResizeMode: "onChange",
|
|
175
|
+
defaultColumn: {
|
|
176
|
+
minSize: 50,
|
|
177
|
+
maxSize: 800,
|
|
178
|
+
},
|
|
179
|
+
initialState: {
|
|
180
|
+
pagination: {
|
|
181
|
+
pageSize: initialPageSize,
|
|
182
|
+
},
|
|
183
|
+
columnFilters: initialColumnFilters,
|
|
184
|
+
...initialState,
|
|
185
|
+
},
|
|
186
|
+
meta,
|
|
187
|
+
getSubRows: getSubRows,
|
|
188
|
+
getRowId: getRowId,
|
|
189
|
+
getExpandedRowModel: getExpandedRowModel(),
|
|
190
|
+
filterFromLeafRows: true,
|
|
191
|
+
enableRowSelection,
|
|
192
|
+
onRowSelectionChange: setRowSelection,
|
|
193
|
+
onSortingChange: (updater) => {
|
|
194
|
+
setSorting(updater);
|
|
195
|
+
if (onSortingChange) onSortingChange(updater);
|
|
196
|
+
},
|
|
197
|
+
onColumnFiltersChange: (updater) => {
|
|
198
|
+
setColumnFilters(updater);
|
|
199
|
+
if (onColumnFiltersChange) onColumnFiltersChange(updater);
|
|
200
|
+
},
|
|
201
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
202
|
+
onGlobalFilterChange: (updater) => {
|
|
203
|
+
setGlobalFilter(updater);
|
|
204
|
+
if (onGlobalFilterChange) {
|
|
205
|
+
const next = typeof updater === "function" ? updater(globalFilter) : updater;
|
|
206
|
+
onGlobalFilterChange((next as string) ?? "");
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
onPaginationChange: (updater) => {
|
|
210
|
+
setPagination(updater);
|
|
211
|
+
if (onPaginationChange) onPaginationChange(updater);
|
|
212
|
+
},
|
|
213
|
+
// Correction: To use `onPaginationChange`, we usually need to control state.
|
|
214
|
+
// Let's create local pagination state to be robust.
|
|
215
|
+
globalFilterFn: finalGlobalFilterFn,
|
|
216
|
+
getCoreRowModel: getCoreRowModel(),
|
|
217
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
218
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
219
|
+
getSortedRowModel: getSortedRowModel(),
|
|
220
|
+
getFacetedRowModel: getFacetedRowModel(),
|
|
221
|
+
getFacetedUniqueValues: getFacetedUniqueValuesProp,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// NOTE: If manualPagination is true, getPaginationRowModel might not be needed?
|
|
225
|
+
// TanStack docs say: if manualPagination is true, you don't need getPaginationRowModel IF you only provide the current page data.
|
|
226
|
+
// But usage of getPaginationRowModel is safe even with manualPagination as it just slices.
|
|
227
|
+
|
|
228
|
+
return { table, initialColumnFilters };
|
|
229
|
+
}
|
|
@@ -57,6 +57,8 @@ import {
|
|
|
57
57
|
TableHeader,
|
|
58
58
|
TableRow,
|
|
59
59
|
} from "@suphark/ui/components/ui/table";
|
|
60
|
+
import { DataTable, DataTableColumnHeader } from "@suphark/ui/data-table";
|
|
61
|
+
import type { ColumnDef } from "@tanstack/react-table";
|
|
60
62
|
import {
|
|
61
63
|
AlertTriangle,
|
|
62
64
|
Download,
|
|
@@ -125,6 +127,43 @@ export function ShowcaseDataDisplay() {
|
|
|
125
127
|
},
|
|
126
128
|
];
|
|
127
129
|
|
|
130
|
+
type PackageRow = (typeof tableRows)[number];
|
|
131
|
+
const packageColumns: ColumnDef<PackageRow>[] = [
|
|
132
|
+
{
|
|
133
|
+
accessorKey: "code",
|
|
134
|
+
header: ({ column }) => <DataTableColumnHeader column={column} title="รหัส" />,
|
|
135
|
+
cell: ({ row }) => <span className="font-mono text-xs">{row.original.code}</span>,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
accessorKey: "name",
|
|
139
|
+
header: ({ column }) => <DataTableColumnHeader column={column} title="รายการ" />,
|
|
140
|
+
cell: ({ row }) => (
|
|
141
|
+
<PrimarySecondaryText primary={row.original.name} secondary={row.original.category} />
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
accessorKey: "vendor",
|
|
146
|
+
header: ({ column }) => <DataTableColumnHeader column={column} title="คู่ค้า" />,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
accessorKey: "status",
|
|
150
|
+
header: ({ column }) => <DataTableColumnHeader column={column} title="สถานะ" />,
|
|
151
|
+
cell: ({ row }) => <Badge variant="outline">{row.original.status}</Badge>,
|
|
152
|
+
filterFn: (row, id, value: string[]) => value.includes(row.getValue(id)),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
accessorKey: "totalAmount",
|
|
156
|
+
header: ({ column }) => (
|
|
157
|
+
<DataTableColumnHeader column={column} title="มูลค่ารวม (THB)" className="justify-end" />
|
|
158
|
+
),
|
|
159
|
+
cell: ({ row }) => (
|
|
160
|
+
<div className="text-right">
|
|
161
|
+
<FormattedNumber value={row.original.totalAmount} />
|
|
162
|
+
</div>
|
|
163
|
+
),
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
128
167
|
return (
|
|
129
168
|
<div className="flex flex-col gap-8">
|
|
130
169
|
{/* Section Header */}
|
|
@@ -310,6 +349,38 @@ export function ShowcaseDataDisplay() {
|
|
|
310
349
|
</CardContent>
|
|
311
350
|
</Card>
|
|
312
351
|
|
|
352
|
+
{/* DataTable (@suphark/ui/data-table) */}
|
|
353
|
+
<Card>
|
|
354
|
+
<CardHeader>
|
|
355
|
+
<CardTitle className="text-base">DataTable (tanstack) — @suphark/ui/data-table</CardTitle>
|
|
356
|
+
<CardDescription className="text-xs">
|
|
357
|
+
ตารางมาตรฐานพร้อม global search, faceted filter, เรียงคอลัมน์, ซ่อน/แสดงคอลัมน์ และ pagination
|
|
358
|
+
— import แยกจาก <code>@suphark/ui/data-table</code> (ต้องมี peer{" "}
|
|
359
|
+
<code>@tanstack/react-table</code>)
|
|
360
|
+
</CardDescription>
|
|
361
|
+
</CardHeader>
|
|
362
|
+
<CardContent>
|
|
363
|
+
<DataTable
|
|
364
|
+
columns={packageColumns}
|
|
365
|
+
data={tableRows}
|
|
366
|
+
globalFilterKeys={["code", "name", "vendor"]}
|
|
367
|
+
globalFilterPlaceholder="ค้นหารหัส / รายการ / คู่ค้า…"
|
|
368
|
+
facetedFilters={[
|
|
369
|
+
{
|
|
370
|
+
columnId: "status",
|
|
371
|
+
title: "สถานะ",
|
|
372
|
+
options: [
|
|
373
|
+
{ label: "Approved", value: "Approved" },
|
|
374
|
+
{ label: "Under Review", value: "Under Review" },
|
|
375
|
+
{ label: "Draft", value: "Draft" },
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
]}
|
|
379
|
+
initialPageSize={5}
|
|
380
|
+
/>
|
|
381
|
+
</CardContent>
|
|
382
|
+
</Card>
|
|
383
|
+
|
|
313
384
|
{/* Item & Attachment Primitives */}
|
|
314
385
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
315
386
|
{/* Item Primitive */}
|
|
@@ -91,9 +91,9 @@ export function ShowcaseGuidelinesRecipes() {
|
|
|
91
91
|
];
|
|
92
92
|
|
|
93
93
|
const recipe1 = `// 1. PageShell + Toolbar + DataTable Pattern
|
|
94
|
-
import { PageShell } from "@suphark/ui/
|
|
95
|
-
import { AddNewButton } from "@suphark/ui
|
|
96
|
-
import { DataTable } from "@suphark/ui/
|
|
94
|
+
import { PageShell } from "@suphark/ui/next";
|
|
95
|
+
import { AddNewButton } from "@suphark/ui";
|
|
96
|
+
import { DataTable } from "@suphark/ui/data-table";
|
|
97
97
|
|
|
98
98
|
export default function PackageListPage() {
|
|
99
99
|
return (
|
|
@@ -117,8 +117,8 @@ export default function PackageListPage() {
|
|
|
117
117
|
}`;
|
|
118
118
|
|
|
119
119
|
const recipe2 = `// 2. FieldGroup + InputGroup + Server Action Validation
|
|
120
|
-
import { FieldGroup, Field, FieldLabel, FieldDescription, FieldError } from "@suphark/ui
|
|
121
|
-
import { InputGroup, InputGroupAddon, InputGroupInput } from "@suphark/ui
|
|
120
|
+
import { FieldGroup, Field, FieldLabel, FieldDescription, FieldError } from "@suphark/ui";
|
|
121
|
+
import { InputGroup, InputGroupAddon, InputGroupInput } from "@suphark/ui";
|
|
122
122
|
import { DollarSign } from "lucide-react";
|
|
123
123
|
|
|
124
124
|
export function BudgetField({ state }: { state?: ActionResponse }) {
|
|
@@ -161,7 +161,7 @@ import {
|
|
|
161
161
|
AlertDialogTitle,
|
|
162
162
|
AlertDialogTrigger,
|
|
163
163
|
} from "@suphark/ui/components/ui/alert-dialog";
|
|
164
|
-
import { Button } from "@suphark/ui
|
|
164
|
+
import { Button } from "@suphark/ui";
|
|
165
165
|
|
|
166
166
|
export function DeletePackageDialog({ packageId }: { packageId: string }) {
|
|
167
167
|
return (
|
|
@@ -191,8 +191,8 @@ export function DeletePackageDialog({ packageId }: { packageId: string }) {
|
|
|
191
191
|
"use client";
|
|
192
192
|
|
|
193
193
|
import { useTransition } from "react";
|
|
194
|
-
import { Button } from "@suphark/ui
|
|
195
|
-
import { Spinner } from "@suphark/ui
|
|
194
|
+
import { Button } from "@suphark/ui";
|
|
195
|
+
import { Spinner } from "@suphark/ui";
|
|
196
196
|
import { toast } from "sonner";
|
|
197
197
|
import { approvePackageAction } from "../actions/approve-package";
|
|
198
198
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { DeleteButton } from "@suphark/ui/components/shared/button/delete-button";
|
|
4
|
+
import {
|
|
5
|
+
AlertDialog,
|
|
6
|
+
AlertDialogAction,
|
|
7
|
+
AlertDialogCancel,
|
|
8
|
+
AlertDialogContent,
|
|
9
|
+
AlertDialogDescription,
|
|
10
|
+
AlertDialogFooter,
|
|
11
|
+
AlertDialogHeader,
|
|
12
|
+
AlertDialogTitle,
|
|
13
|
+
AlertDialogTrigger,
|
|
14
|
+
} from "@suphark/ui/components/ui/alert-dialog";
|
|
15
|
+
import { buttonVariants } from "@suphark/ui/components/ui/button";
|
|
16
|
+
import type { Table } from "@tanstack/react-table";
|
|
17
|
+
import { Loader2 } from "lucide-react";
|
|
18
|
+
import { useTransition } from "react";
|
|
19
|
+
import { toast } from "sonner";
|
|
20
|
+
|
|
21
|
+
interface DeleteManyButtonProps<TData> {
|
|
22
|
+
table: Table<TData>;
|
|
23
|
+
deleteAction: (data: { ids: string[] }) => Promise<unknown>;
|
|
24
|
+
onSuccess?: () => void;
|
|
25
|
+
itemDescription?: string;
|
|
26
|
+
responsive?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function DeleteManyButton<TData>({
|
|
30
|
+
table,
|
|
31
|
+
deleteAction,
|
|
32
|
+
onSuccess,
|
|
33
|
+
itemDescription = "items",
|
|
34
|
+
responsive = false,
|
|
35
|
+
}: DeleteManyButtonProps<TData>) {
|
|
36
|
+
const [isPending, startTransition] = useTransition();
|
|
37
|
+
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
|
38
|
+
|
|
39
|
+
if (selectedRows.length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleDelete() {
|
|
44
|
+
startTransition(async () => {
|
|
45
|
+
const ids = selectedRows.map((row) => (row.original as { id: string }).id);
|
|
46
|
+
try {
|
|
47
|
+
const result = (await deleteAction({ ids })) as any;
|
|
48
|
+
if (result?.error) {
|
|
49
|
+
throw new Error(result.error);
|
|
50
|
+
}
|
|
51
|
+
toast.success(`${ids.length} ${itemDescription} deleted successfully.`);
|
|
52
|
+
onSuccess?.();
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
toast.error(err.message);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<AlertDialog>
|
|
61
|
+
<AlertDialogTrigger render={<DeleteButton isLoading={isPending} responsive={responsive} />}>
|
|
62
|
+
Delete ({selectedRows.length})
|
|
63
|
+
</AlertDialogTrigger>
|
|
64
|
+
<AlertDialogContent>
|
|
65
|
+
<AlertDialogHeader>
|
|
66
|
+
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
67
|
+
<AlertDialogDescription>
|
|
68
|
+
This will permanently delete the selected {itemDescription}.
|
|
69
|
+
</AlertDialogDescription>
|
|
70
|
+
</AlertDialogHeader>
|
|
71
|
+
<AlertDialogFooter>
|
|
72
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
73
|
+
<AlertDialogAction
|
|
74
|
+
onClick={handleDelete}
|
|
75
|
+
disabled={isPending}
|
|
76
|
+
className={buttonVariants({ variant: "destructive" })}
|
|
77
|
+
>
|
|
78
|
+
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
79
|
+
Delete
|
|
80
|
+
</AlertDialogAction>
|
|
81
|
+
</AlertDialogFooter>
|
|
82
|
+
</AlertDialogContent>
|
|
83
|
+
</AlertDialog>
|
|
84
|
+
);
|
|
85
|
+
}
|