@evenicanpm/portal-table-ui 2.0.0 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evenicanpm/portal-table-ui",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "",
5
5
  "license": "ISC",
6
6
  "author": "",
@@ -47,5 +47,5 @@
47
47
  "@tanstack/react-query": "^5.90.11",
48
48
  "date-fns": "^4.1.0"
49
49
  },
50
- "gitHead": "7552c1e700a0027b8a65506c77cb10dae092fdfb"
50
+ "gitHead": "8227589295d3d63586f9b26fff8d643b070989b3"
51
51
  }
@@ -25,11 +25,17 @@ export function BulkActionButton({
25
25
  const { mutate, isPending } = useMutation(mutationOptions);
26
26
 
27
27
  const handleClick = () => {
28
- mutate(undefined, {
29
- onSuccess: () => {
30
- clearSelection();
28
+ mutate(
29
+ {
30
+ resourceName,
31
+ selectedIds,
31
32
  },
32
- });
33
+ {
34
+ onSuccess: () => {
35
+ clearSelection();
36
+ },
37
+ },
38
+ );
33
39
  };
34
40
 
35
41
  const Icon = action.icon;
@@ -9,7 +9,7 @@ export const dataMaskMap: Table.Handlers.DataMaskMap = {
9
9
 
10
10
  const date = new Date(value);
11
11
 
12
- if (isNaN(date.getTime())) {
12
+ if (Number.isNaN(date.getTime())) {
13
13
  console.warn("Invalid datetime value:", value);
14
14
  return "";
15
15
  }
@@ -21,7 +21,7 @@ export const dataMaskMap: Table.Handlers.DataMaskMap = {
21
21
 
22
22
  const date = new Date(value);
23
23
 
24
- if (isNaN(date.getTime())) {
24
+ if (Number.isNaN(date.getTime())) {
25
25
  console.warn("Invalid date value:", value);
26
26
  return "";
27
27
  }
@@ -19,7 +19,9 @@ interface DetailsProps {
19
19
  /** What is the id field name */
20
20
  id: string | number;
21
21
  /** The actual pojo of data from the API */
22
- documentData: Types.Queries.Row | unknown;
22
+ documentData?: unknown;
23
+ /** The row data for the document */
24
+ row: Types.Queries.Row;
23
25
  /* the label of the document type ie 'INVOICES' */
24
26
  documentTypeLabel: string;
25
27
  /** Special instructions for how to render certain properties */
@@ -46,6 +48,7 @@ const Details = ({
46
48
  id,
47
49
  documentTypeLabel,
48
50
  documentData,
51
+ row,
49
52
  handleBack,
50
53
  columns,
51
54
  dataMaskMap,
@@ -69,7 +72,7 @@ const Details = ({
69
72
  ? undefined
70
73
  : visibleDetails?.map((col) => ({
71
74
  key: col.Data,
72
- value: (documentData as Types.Queries.Row)?.[col.Data],
75
+ value: row?.[col.Data], // use row instead of documentData
73
76
  maskFn: resolvers.resolveDataMask(col, dataMaskMap),
74
77
  mask: col.DataMask,
75
78
  col,
@@ -98,6 +101,7 @@ const Details = ({
98
101
  {TemplateComponent ? (
99
102
  <TemplateComponent
100
103
  data={documentData}
104
+ row={row}
101
105
  statusColorMap={statusColorMap}
102
106
  statusTextMap={statusTextMap}
103
107
  loading={loading}
@@ -76,8 +76,13 @@ interface InvoiceData {
76
76
  DueDate?: string;
77
77
  Notes?: string;
78
78
  Id: string;
79
+ OrderedByCustomerName?: string;
79
80
  }
80
81
 
82
+ const detailSkeletonKeys = ["from", "bill-to", "invoice-date", "due-date"];
83
+ const tableSkeletonRowKeys = ["line-1", "line-2", "line-3", "line-4"];
84
+ const totalSkeletonKeys = ["subtotal", "tax", "total"];
85
+
81
86
  const isDataEmpty = (data: InvoiceData | null | undefined): boolean => {
82
87
  if (!data) return true;
83
88
  const hasNoLineItems =
@@ -90,6 +95,7 @@ const isDataEmpty = (data: InvoiceData | null | undefined): boolean => {
90
95
  export const InvoiceDetailsTemplate = ({
91
96
  data,
92
97
  statusColorMap,
98
+ row,
93
99
  statusTextMap,
94
100
  loading = false,
95
101
  actions,
@@ -107,6 +113,7 @@ export const InvoiceDetailsTemplate = ({
107
113
  return (
108
114
  <Box sx={{ p: { xs: 2, sm: 3 }, width: containerWidth, mx: "auto" }}>
109
115
  <Card
116
+ variant="outlined"
110
117
  sx={{ p: { xs: 3, sm: 6 }, borderRadius: 3, textAlign: "center" }}
111
118
  >
112
119
  <Typography variant="h6" color="text.secondary" gutterBottom>
@@ -147,10 +154,10 @@ export const InvoiceDetailsTemplate = ({
147
154
  <Skeleton variant="rectangular" width={100} height={32} />
148
155
  <Skeleton variant="rectangular" width={120} height={40} />
149
156
  </Box>
150
- <Card sx={{ p: { xs: 2, sm: 4 }, borderRadius: 3 }}>
157
+ <Card variant="outlined" sx={{ p: { xs: 2, sm: 4 }, borderRadius: 3 }}>
151
158
  <Grid2 container spacing={{ xs: 2, sm: 4 }}>
152
- {Array.from({ length: 4 }).map((_, i) => (
153
- <Grid2 size={{ xs: 12, sm: 6 }} key={`skeleton-${i}`}>
159
+ {detailSkeletonKeys.map((key) => (
160
+ <Grid2 size={{ xs: 12, sm: 6 }} key={key}>
154
161
  <Skeleton width="40%" height={20} />
155
162
  <Skeleton width="80%" />
156
163
  <Skeleton width="60%" />
@@ -178,8 +185,8 @@ export const InvoiceDetailsTemplate = ({
178
185
  </TableRow>
179
186
  </TableHead>
180
187
  <TableBody>
181
- {Array.from({ length: 4 }).map((_, idx) => (
182
- <TableRow key={`skeleton-${idx}`}>
188
+ {tableSkeletonRowKeys.map((key) => (
189
+ <TableRow key={key}>
183
190
  {columns.map((col) => (
184
191
  <TableCell
185
192
  key={col.label}
@@ -196,9 +203,9 @@ export const InvoiceDetailsTemplate = ({
196
203
 
197
204
  <Box mt={3} display="flex" justifyContent="flex-end">
198
205
  <Box width={{ xs: "100%", sm: 300 }}>
199
- {Array.from({ length: 3 }).map((_, i) => (
206
+ {totalSkeletonKeys.map((key) => (
200
207
  <Box
201
- key={`skeleton-${i}`}
208
+ key={key}
202
209
  display="flex"
203
210
  justifyContent="space-between"
204
211
  mb={1}
@@ -234,8 +241,7 @@ export const InvoiceDetailsTemplate = ({
234
241
  )}
235
242
  <Box display="flex" gap={2} alignItems="center">
236
243
  {actions?.map((action) => {
237
- const isPaid = invoice.Status === 3;
238
- const enabled = !isPaid && action.isEnabled;
244
+ const enabled = !action.isEnabled || action.isEnabled(row);
239
245
  if (!enabled) {
240
246
  return (
241
247
  <span
@@ -262,7 +268,10 @@ export const InvoiceDetailsTemplate = ({
262
268
 
263
269
  {/* Notes Section */}
264
270
  {invoice?.Notes && (
265
- <Card sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, mb: 3 }}>
271
+ <Card
272
+ variant="outlined"
273
+ sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, mb: 3 }}
274
+ >
266
275
  <Typography variant="subtitle1" color="text.secondary" mb={1}>
267
276
  Notes
268
277
  </Typography>
@@ -277,16 +286,49 @@ export const InvoiceDetailsTemplate = ({
277
286
  )}
278
287
 
279
288
  {/* Main Invoice Card */}
280
- <Card sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3 }}>
289
+ <Card variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3 }}>
281
290
  {/* Details Section */}
282
291
  {(invoice?.CompanyName ||
283
292
  invoice?.Name ||
284
293
  invoice?.InvoiceDate ||
285
294
  invoice?.DueDate) && (
286
295
  <>
287
- <Typography variant="subtitle1" color="text.secondary" mb={2}>
288
- Details
289
- </Typography>
296
+ <Box
297
+ sx={{
298
+ display: "flex",
299
+ flexDirection: { xs: "column", sm: "row" },
300
+ gap: 1,
301
+ justifyContent: "space-between",
302
+ alignItems: { xs: "flex-start", sm: "center" },
303
+ marginBottom: 2,
304
+ }}
305
+ >
306
+ <Typography variant="subtitle1" color="text.secondary">
307
+ Details
308
+ </Typography>
309
+
310
+ {invoice.OrderedByCustomerName && (
311
+ <Box
312
+ sx={{
313
+ display: "flex",
314
+ gap: 1,
315
+ alignItems: "center",
316
+ flexWrap: "wrap",
317
+ }}
318
+ >
319
+ <Typography variant="body2" color="text.secondary">
320
+ Order Placed By:
321
+ </Typography>
322
+ <Typography
323
+ variant="body2"
324
+ sx={{ wordBreak: "break-all" }}
325
+ fontWeight={600}
326
+ >
327
+ {invoice.OrderedByCustomerName}
328
+ </Typography>
329
+ </Box>
330
+ )}
331
+ </Box>
290
332
  <Grid2 container spacing={{ xs: 2, sm: 4 }}>
291
333
  {invoice?.CompanyName && (
292
334
  <Grid2 size={{ xs: 12, sm: 6 }}>
@@ -384,9 +426,16 @@ export const InvoiceDetailsTemplate = ({
384
426
  const unitPrice = item.Price ?? 0;
385
427
  const quantity = item.Quantity ?? 0;
386
428
  const lineTotal = item.NetAmount ?? unitPrice * quantity;
429
+ const itemKey = [
430
+ item.ItemId ?? "item",
431
+ productName,
432
+ quantity,
433
+ unitPrice,
434
+ lineTotal,
435
+ ].join("-");
387
436
 
388
437
  return (
389
- <TableRow key={`${item.ItemId}-${idx}`}>
438
+ <TableRow key={itemKey}>
390
439
  <TableCell sx={{ width: 60, minWidth: 60 }}>
391
440
  <Typography variant="body2">
392
441
  {item?.ItemId || idx + 1}
@@ -8,6 +8,7 @@ import { InvoiceDetailsTemplate } from "./invoice-details";
8
8
  */
9
9
  export interface DetailsTemplateProps {
10
10
  data: unknown;
11
+ row: Types.Queries.Row;
11
12
  statusColorMap: Types.Schemas.StatusColorMap;
12
13
  statusTextMap: Types.Schemas.StatusTextMap;
13
14
  loading?: boolean;
@@ -5,7 +5,6 @@ import { styled } from "@mui/material/styles";
5
5
  import TableCell from "@mui/material/TableCell";
6
6
  import TableHead from "@mui/material/TableHead";
7
7
  import TableRow from "@mui/material/TableRow";
8
- import type React from "react";
9
8
 
10
9
  // STYLED COMPONENTS
11
10
  const StyledTableCell = styled(TableCell)(({ theme }) => ({
@@ -85,7 +84,7 @@ export default function TableHeader({
85
84
  key={column.Label + String(i)}
86
85
  sx={{
87
86
  whiteSpace: "normal",
88
- wordBreak: "break-word",
87
+ wordBreak: "normal",
89
88
  }}
90
89
  >
91
90
  {column.Label}
@@ -6,8 +6,8 @@ import React from "react";
6
6
  interface SearchProps {
7
7
  searchTerm: string;
8
8
  setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
9
- setColumn: React.Dispatch<React.SetStateAction<string>>;
10
- selectedColumn: string;
9
+ setColumn: React.Dispatch<React.SetStateAction<Column>>;
10
+ selectedColumn: Column;
11
11
  searchCols: Column[];
12
12
  }
13
13
 
@@ -78,12 +78,19 @@ const SearchField = React.memo(
78
78
  onChange={(e) => {
79
79
  setSearchTerm(e.target.value);
80
80
  }}
81
- placeholder={`Seach by ${selectedColumn}`}
81
+ placeholder={
82
+ selectedColumn?.Label
83
+ ? `Search by ${selectedColumn.Label}`
84
+ : "Select a column to search"
85
+ }
82
86
  endAdornment={
83
87
  <ColumnSelect
84
- selected={selectedColumn}
88
+ selected={selectedColumn?.Data || ""}
85
89
  searchCols={searchCols}
86
- handleChange={setColumn}
90
+ handleChange={(value) => {
91
+ const col = searchCols.find((c) => c.Data === value);
92
+ if (col) setColumn(col);
93
+ }}
87
94
  />
88
95
  }
89
96
  />
@@ -22,7 +22,10 @@ export function ActionButton({
22
22
 
23
23
  const handleClick = (e: React.MouseEvent) => {
24
24
  e.stopPropagation();
25
- mutate({ docId });
25
+ mutate({
26
+ docId,
27
+ resourceName: "",
28
+ });
26
29
  };
27
30
 
28
31
  if (action.icon) {
@@ -14,15 +14,8 @@ type ColumnLogic =
14
14
  | {
15
15
  id: string;
16
16
  mask: string | undefined;
17
- maskFn: any;
18
- cellFn:
19
- | ((
20
- value: string | number | string[],
21
- ) => React.ReactElement<
22
- any,
23
- string | React.JSXElementConstructor<any>
24
- >)
25
- | ((value: string | number) => string | number);
17
+ maskFn: (value: unknown, mask?: string) => string | number | string[];
18
+ cellFn: (value: string | number | string[]) => React.ReactNode;
26
19
  }[]
27
20
  | undefined;
28
21
 
@@ -1,3 +1,4 @@
1
+ import type { Column } from "@evenicanpm/portal-types-schemas";
1
2
  import { FlexBox, ResponsiveTablePagination } from "@evenicanpm/ui";
2
3
  import Table from "@mui/material/Table";
3
4
  import TableBody from "@mui/material/TableBody";
@@ -150,14 +151,28 @@ export default function DocumentTable({
150
151
  const visibleCols = transformers.getVisibleColumns(columns);
151
152
 
152
153
  // Depends on searchableCols, must be declared after transformer
153
- const [searchColumn, setSearchColumn] = useState(
154
- () => searchableCols?.[0]?.Data ?? "Title",
154
+ const [searchColumn, setSearchColumn] = useState<Column>(
155
+ () => searchableCols?.[0] as Column,
155
156
  );
156
157
 
157
- const { data: rows, isPending: rowsPending } = useQuery(
158
- resource.rowsQueryFn({ page, pageSize, filters, searchColumn, searchTerm }),
158
+ // always derive a safe usable column
159
+ const effectiveSearchColumn = searchColumn ?? searchableCols?.[0];
160
+
161
+ const { data: fetchedRows, isPending: rowsPending } = useQuery(
162
+ resource.rowsQueryFn({
163
+ page,
164
+ pageSize: pageSize + 1, // Fetch one extra to check if there's a next page
165
+ filters,
166
+ searchColumn: effectiveSearchColumn?.Data || "",
167
+ searchTerm,
168
+ }),
159
169
  );
160
170
 
171
+ const hasNextPage = fetchedRows ? fetchedRows.data.length > pageSize : false;
172
+ const rows: Types.Queries.TableRows | undefined = fetchedRows
173
+ ? { ...fetchedRows, data: fetchedRows.data.slice(0, pageSize) }
174
+ : undefined;
175
+
161
176
  // --- Resolver --- //
162
177
  const filterLogic = filterReqs?.map((req) => ({
163
178
  req,
@@ -194,8 +209,8 @@ export default function DocumentTable({
194
209
  !!resource.detailsQueryFn && !!resource.detailsTemplate;
195
210
 
196
211
  const { data: detailsData, isPending: detailsPending } = useQuery(
197
- hasTemplateSupport && selectedDocument
198
- ? resource.detailsQueryFn!(String(selectedDocument))
212
+ hasTemplateSupport && selectedDocument && resource.detailsQueryFn
213
+ ? resource.detailsQueryFn(String(selectedDocument))
199
214
  : {
200
215
  queryKey: [],
201
216
  queryFn: async () => undefined,
@@ -226,14 +241,16 @@ export default function DocumentTable({
226
241
  const rowSnapshot =
227
242
  rows?.data.find((doc) => doc?.[idField] === selectedDocument) ?? {};
228
243
 
229
- const finalDetailsData =
230
- hasTemplateSupport && detailsData ? detailsData : rowSnapshot;
244
+ const shouldUseDetailsData = hasTemplateSupport;
231
245
 
232
246
  return (
233
247
  <Details
234
248
  documentTypeLabel={label}
235
249
  id={selectedDocument}
236
- documentData={finalDetailsData}
250
+ documentData={
251
+ shouldUseDetailsData ? (detailsData ?? rowSnapshot) : undefined
252
+ }
253
+ row={rowSnapshot}
237
254
  handleBack={handleBack}
238
255
  columns={columns}
239
256
  dataMaskMap={resolvedDataMaskMap}
@@ -267,7 +284,7 @@ export default function DocumentTable({
267
284
  setSearchTerm={setSearchTerm}
268
285
  setColumn={setSearchColumn}
269
286
  searchCols={searchableCols || []}
270
- selectedColumn={searchColumn}
287
+ selectedColumn={effectiveSearchColumn}
271
288
  />
272
289
  {filterLogic?.map((filter) => (
273
290
  <filter.FilterComponent
@@ -357,6 +374,13 @@ export default function DocumentTable({
357
374
  rowsPerPageOptions={[5, 10]}
358
375
  onPageChange={handleChangePage}
359
376
  onRowsPerPageChange={handleChangePageSize}
377
+ slotProps={{
378
+ actions: {
379
+ nextButton: {
380
+ disabled: !hasNextPage,
381
+ },
382
+ },
383
+ }}
360
384
  />
361
385
  </>
362
386
  );
@@ -62,6 +62,21 @@ export const TableDashboard = ({ resources, viewConfigQuery }: TableProps) => {
62
62
  pageSize={pageSize}
63
63
  setPageSize={setPageSize}
64
64
  />
65
+ {/* CHILD TABLE */}
66
+ {resource.children?.length ? (
67
+ <Box mt={6}>
68
+ {resource.children.map((child, idx) => (
69
+ <Box key={child.name + String(idx)} mt={4}>
70
+ <Table
71
+ viewConfigQuery={viewConfigQuery}
72
+ resource={child}
73
+ pageSize={pageSize}
74
+ setPageSize={setPageSize}
75
+ />
76
+ </Box>
77
+ ))}
78
+ </Box>
79
+ ) : null}
65
80
  </TabPanel>
66
81
  </Box>
67
82
  ))}
@@ -66,7 +66,7 @@ export const resolveCellUI = (
66
66
  ) => {
67
67
  if (Object.hasOwn(handlers, col.Data)) return handlers?.[col?.Data];
68
68
 
69
- return (value: string | number) => value;
69
+ return (value: string | number | string[]) => value;
70
70
  };
71
71
 
72
72
  export function resolveActions(
@@ -30,20 +30,28 @@ type BaseAction = {
30
30
  icon?: SvgIconComponent;
31
31
  };
32
32
 
33
+ type MutationArgs = {
34
+ resourceName: string;
35
+ docId: string | number;
36
+ };
37
+
38
+ type BulkMutationArgs = {
39
+ resourceName: string;
40
+ selectedIds: (string | number)[];
41
+ };
42
+
33
43
  export type UIAction = BaseAction & {
34
44
  isEnabled?: (row: Record<string, unknown>) => boolean;
35
- mutation: (args: {
36
- resourceName: string;
37
- docId: string | number;
38
- }) => UseMutationOptions<any, any, any, any>;
45
+ mutation: (
46
+ args: MutationArgs,
47
+ ) => UseMutationOptions<unknown, unknown, MutationArgs, unknown>;
39
48
  };
40
49
 
41
50
  export type UIBulkAction = BaseAction & {
42
51
  isAllowed?: (rows: Record<string, unknown>[]) => boolean;
43
- mutation: (args: {
44
- resourceName: string;
45
- selectedIds: (string | number)[];
46
- }) => UseMutationOptions<any, any, any, any>;
52
+ mutation: (
53
+ args: BulkMutationArgs,
54
+ ) => UseMutationOptions<unknown, unknown, BulkMutationArgs, unknown>;
47
55
  };
48
56
 
49
57
  /**
@@ -86,4 +94,7 @@ export interface Resource {
86
94
  // Actions and Bulk Actions
87
95
  actions?: Record<string, UIAction>;
88
96
  bulkActions?: Record<string, UIBulkAction>;
97
+
98
+ // Children resources for nested tables
99
+ children?: Resource[];
89
100
  }