/ frontend / src / app / components / DataList.jsx
DataList.jsx
  1  import { useState, useMemo } from "react";
  2  import {
  3    Box, Button, Card, Checkbox, Chip, IconButton, InputAdornment, MenuItem, Select,
  4    Table, TableBody, TableCell, TableHead, TableRow, TablePagination,
  5    TableSortLabel, TextField, Tooltip, Typography, styled,
  6  } from "@mui/material";
  7  import { Search, Inbox, Close, SearchOff } from "@mui/icons-material";
  8  import { useTranslation } from "react-i18next";
  9  
 10  const StyledCard = styled(Card)(({ theme }) => ({
 11    borderRadius: 12,
 12    border: "1px solid",
 13    borderColor: theme.palette.divider,
 14    overflow: "hidden",
 15  }));
 16  
 17  const Header = styled(Box)(({ theme }) => ({
 18    padding: "20px 24px 16px",
 19    borderBottom: `1px solid ${theme.palette.divider}`,
 20    display: "flex",
 21    alignItems: "center",
 22    justifyContent: "space-between",
 23    gap: theme.spacing(2),
 24    flexWrap: "wrap",
 25  }));
 26  
 27  const ToolbarRow = styled(Box)(({ theme }) => ({
 28    padding: "14px 24px",
 29    display: "flex",
 30    alignItems: "center",
 31    gap: theme.spacing(2),
 32    flexWrap: "wrap",
 33    borderBottom: `1px solid ${theme.palette.divider}`,
 34  }));
 35  
 36  // Shown above the table when one or more rows are selected (bulkActions
 37  // opt-in). Sits between the toolbar and the table so the selection
 38  // count + action buttons are unmistakable.
 39  const BulkActionBar = styled(Box)(({ theme }) => ({
 40    padding: "10px 24px",
 41    display: "flex",
 42    alignItems: "center",
 43    gap: theme.spacing(1.5),
 44    backgroundColor: theme.palette.action.selected,
 45    borderBottom: `1px solid ${theme.palette.divider}`,
 46    flexWrap: "wrap",
 47  }));
 48  
 49  const StyledTableRow = styled(TableRow)(({ theme, clickable }) => ({
 50    "& td": {
 51      borderBottom: `1px solid ${theme.palette.divider}`,
 52      padding: "14px 16px",
 53    },
 54    "& td:first-of-type": { paddingLeft: 24 },
 55    "& td:last-of-type": { paddingRight: 24 },
 56    "&:last-child td": { borderBottom: "none" },
 57    ...(clickable === "true" && {
 58      cursor: "pointer",
 59      "&:hover": {
 60        backgroundColor: theme.palette.action.hover,
 61      },
 62    }),
 63  }));
 64  
 65  const EmptyState = styled(Box)(({ theme }) => ({
 66    padding: "60px 24px",
 67    textAlign: "center",
 68    color: theme.palette.text.secondary,
 69    display: "flex",
 70    flexDirection: "column",
 71    alignItems: "center",
 72    gap: theme.spacing(1),
 73  }));
 74  
 75  /**
 76   * Generic data list with search, filter, sort, pagination.
 77   *
 78   * Props:
 79   *  - title: string — header title
 80   *  - subtitle: string — header description (optional)
 81   *  - data: array — rows
 82   *  - columns: [{ key, label, sortable, align, render(row), width }]
 83   *  - searchKeys: array of field paths to search (supports dot notation)
 84   *  - filters: [{ key, label, options: [{ value, label }], getValue(row) }]
 85   *  - onRowClick: (row) => void — makes rows clickable
 86   *  - rowKey: (row) => string — default: row.id
 87   *  - actions: (row) => ReactNode — right-side action buttons per row
 88   *  - headerAction: ReactNode — right-side header button (e.g. "New")
 89   *  - emptyMessage: string — fallback text when `emptyState` is not provided
 90   *  - emptyState: { icon, title, message, actionLabel, onAction } — rich
 91   *    empty-state block (shown when data.length === 0). `icon` is a
 92   *    component class (e.g. `Group`). Falls back to Inbox + emptyMessage
 93   *    when the prop is omitted.
 94   *  - bulkActions: [{ label, icon, color, onClick(selectedRows) }] — when
 95   *    non-empty, renders a select-column + a bulk-action bar. Parent
 96   *    handles the API calls; DataList just hands back selected rows and
 97   *    clears the selection after each action.
 98   *  - defaultSort: { key, direction }
 99   *  - pageSize: default 10
100   */
101  export default function DataList({
102    title,
103    subtitle,
104    data = [],
105    columns,
106    searchKeys = [],
107    filters = [],
108    onRowClick,
109    rowKey = (row) => row.id,
110    actions,
111    headerAction,
112    emptyMessage,
113    emptyState = null,
114    bulkActions = [],
115    defaultSort = null,
116    pageSize = 10,
117  }) {
118    const { t } = useTranslation();
119    const effectiveEmptyMessage = emptyMessage ?? t("dataList.noResults");
120    const [search, setSearch] = useState("");
121    const [sortKey, setSortKey] = useState(defaultSort?.key || null);
122    const [sortDir, setSortDir] = useState(defaultSort?.direction || "asc");
123    const [filterValues, setFilterValues] = useState({});
124    const [page, setPage] = useState(0);
125    const [rowsPerPage, setRowsPerPage] = useState(pageSize);
126    // Set of selected row keys. Stored as a Set so toggling is O(1) and
127    // the parent can read size via .size. Cleared whenever the filtered
128    // view changes so hidden rows don't silently stay selected.
129    const [selectedKeys, setSelectedKeys] = useState(() => new Set());
130  
131    const getNested = (obj, path) => {
132      if (!obj || !path) return "";
133      return path.split(".").reduce((acc, part) => (acc == null ? acc : acc[part]), obj);
134    };
135  
136    const matchesSearch = (row) => {
137      if (!search) return true;
138      const needle = search.toLowerCase();
139      return searchKeys.some((key) => {
140        const value = getNested(row, key);
141        if (value == null) return false;
142        return String(value).toLowerCase().includes(needle);
143      });
144    };
145  
146    const matchesFilters = (row) => {
147      return filters.every((f) => {
148        const selected = filterValues[f.key];
149        if (!selected || selected === "__all__") return true;
150        const rowValue = f.getValue ? f.getValue(row) : getNested(row, f.key);
151        return String(rowValue) === String(selected);
152      });
153    };
154  
155    const sorted = useMemo(() => {
156      let result = data.filter((row) => matchesSearch(row) && matchesFilters(row));
157      if (sortKey) {
158        const col = columns.find((c) => c.key === sortKey);
159        const getter = col?.sortValue || ((row) => getNested(row, sortKey));
160        result = [...result].sort((a, b) => {
161          const av = getter(a);
162          const bv = getter(b);
163          if (av == null && bv == null) return 0;
164          if (av == null) return 1;
165          if (bv == null) return -1;
166          if (typeof av === "number" && typeof bv === "number") {
167            return sortDir === "asc" ? av - bv : bv - av;
168          }
169          return sortDir === "asc"
170            ? String(av).localeCompare(String(bv))
171            : String(bv).localeCompare(String(av));
172        });
173      }
174      return result;
175      // eslint-disable-next-line react-hooks/exhaustive-deps
176    }, [data, search, sortKey, sortDir, filterValues, columns]);
177  
178    const paged = useMemo(() => {
179      const start = page * rowsPerPage;
180      return sorted.slice(start, start + rowsPerPage);
181    }, [sorted, page, rowsPerPage]);
182  
183    const handleSort = (key) => {
184      if (sortKey === key) {
185        setSortDir(sortDir === "asc" ? "desc" : "asc");
186      } else {
187        setSortKey(key);
188        setSortDir("asc");
189      }
190    };
191  
192    // ── Bulk selection helpers ─────────────────────────────────────────
193    const bulkEnabled = bulkActions.length > 0;
194    const pagedKeys = useMemo(() => paged.map(rowKey), [paged, rowKey]);
195    const allPageSelected = pagedKeys.length > 0 && pagedKeys.every((k) => selectedKeys.has(k));
196    const somePageSelected = pagedKeys.some((k) => selectedKeys.has(k));
197  
198    const toggleRow = (row) => {
199      const k = rowKey(row);
200      setSelectedKeys((prev) => {
201        const next = new Set(prev);
202        if (next.has(k)) next.delete(k); else next.add(k);
203        return next;
204      });
205    };
206    const toggleAllOnPage = () => {
207      setSelectedKeys((prev) => {
208        const next = new Set(prev);
209        if (allPageSelected) {
210          pagedKeys.forEach((k) => next.delete(k));
211        } else {
212          pagedKeys.forEach((k) => next.add(k));
213        }
214        return next;
215      });
216    };
217    const clearSelection = () => setSelectedKeys(new Set());
218  
219    const runBulkAction = (action) => {
220      const selectedRows = data.filter((row) => selectedKeys.has(rowKey(row)));
221      if (selectedRows.length === 0) return;
222      // Hand rows to the consumer; they're responsible for API calls and
223      // refreshing `data`. Clear selection so stale keys don't linger on
224      // rows that the parent just deleted.
225      Promise.resolve(action.onClick(selectedRows)).finally(clearSelection);
226    };
227  
228    // ── Render ─────────────────────────────────────────────────────────
229    const hasToolbar = searchKeys.length > 0 || filters.length > 0;
230    const selectedCount = selectedKeys.size;
231    const searching = search.length > 0;
232    const hasData = data.length > 0;
233  
234    return (
235      <StyledCard elevation={0}>
236        {(title || headerAction) && (
237          <Header>
238            <Box>
239              {title && <Typography variant="h6" fontWeight={700}>{title}</Typography>}
240              {subtitle && (
241                <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
242                  {subtitle}
243                </Typography>
244              )}
245            </Box>
246            {headerAction}
247          </Header>
248        )}
249  
250        {hasToolbar && (
251          <ToolbarRow>
252            {searchKeys.length > 0 && (
253              <TextField
254                size="small"
255                placeholder={t("dataList.search")}
256                value={search}
257                onChange={(e) => { setSearch(e.target.value); setPage(0); }}
258                sx={{ flex: 1, minWidth: 220 }}
259                InputProps={{
260                  startAdornment: (
261                    <InputAdornment position="start">
262                      <Search fontSize="small" color="action" />
263                    </InputAdornment>
264                  ),
265                  endAdornment: searching ? (
266                    <InputAdornment position="end">
267                      <Tooltip title={t("dataList.clearSearch")}>
268                        <IconButton
269                          size="small" edge="end" aria-label={t("dataList.clearSearch")}
270                          onClick={() => { setSearch(""); setPage(0); }}
271                        >
272                          <Close fontSize="small" />
273                        </IconButton>
274                      </Tooltip>
275                    </InputAdornment>
276                  ) : null,
277                }}
278              />
279            )}
280            {filters.map((f) => (
281              <Select
282                key={f.key}
283                size="small"
284                value={filterValues[f.key] || "__all__"}
285                onChange={(e) => {
286                  setFilterValues({ ...filterValues, [f.key]: e.target.value });
287                  setPage(0);
288                }}
289                sx={{ minWidth: 160 }}
290                displayEmpty
291                renderValue={(selected) => {
292                  if (!selected || selected === "__all__") return `${f.label}: ${t("common.all")}`;
293                  const opt = f.options.find((o) => String(o.value) === String(selected));
294                  return `${f.label}: ${opt?.label || selected}`;
295                }}
296              >
297                <MenuItem value="__all__">{f.label}: {t("common.all")}</MenuItem>
298                {f.options.map((opt) => (
299                  <MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
300                ))}
301              </Select>
302            ))}
303            <Box sx={{ flex: "0 0 auto", ml: "auto" }}>
304              <Chip label={t("common.resultCount", { count: sorted.length })} size="small" variant="outlined" />
305            </Box>
306          </ToolbarRow>
307        )}
308  
309        {bulkEnabled && selectedCount > 0 && (
310          <BulkActionBar>
311            <Typography variant="body2" fontWeight={600}>
312              {t("dataList.selected", { count: selectedCount })}
313            </Typography>
314            {bulkActions.map((a, i) => (
315              <Button
316                key={i}
317                size="small"
318                variant="outlined"
319                color={a.color || "primary"}
320                startIcon={a.icon}
321                onClick={() => runBulkAction(a)}
322              >
323                {a.label}
324              </Button>
325            ))}
326            <Button size="small" onClick={clearSelection} sx={{ ml: "auto" }}>
327              {t("dataList.clear")}
328            </Button>
329          </BulkActionBar>
330        )}
331  
332        {paged.length === 0 ? (
333          <EmptyState>
334            {/* Three distinct empty-states:
335                1. Searched, no matches → "no matches" + clear-search CTA.
336                2. Data present but no rich empty-state provided → plain text.
337                3. No data at all, rich empty-state provided → icon + title + action. */}
338            {searching && hasData ? (
339              <>
340                <SearchOff sx={{ fontSize: 48, opacity: 0.4 }} />
341                <Typography variant="body2">
342                  {t("dataList.noMatches", { query: search })}
343                </Typography>
344                <Button size="small" onClick={() => { setSearch(""); setPage(0); }}>
345                  {t("dataList.clearSearch")}
346                </Button>
347              </>
348            ) : !hasData && emptyState ? (
349              <>
350                {emptyState.icon ? (
351                  <Box
352                    sx={{
353                      width: 72, height: 72, borderRadius: "50%",
354                      display: "flex", alignItems: "center", justifyContent: "center",
355                      backgroundColor: "action.hover", mb: 1,
356                    }}
357                  >
358                    <emptyState.icon sx={{ fontSize: 36, color: "text.secondary" }} />
359                  </Box>
360                ) : (
361                  <Inbox sx={{ fontSize: 48, opacity: 0.4 }} />
362                )}
363                {emptyState.title && (
364                  <Typography variant="subtitle1" fontWeight={600} sx={{ color: "text.primary" }}>
365                    {emptyState.title}
366                  </Typography>
367                )}
368                <Typography variant="body2" sx={{ maxWidth: 360 }}>
369                  {emptyState.message || effectiveEmptyMessage}
370                </Typography>
371                {emptyState.actionLabel && emptyState.onAction && (
372                  <Button
373                    size="small" variant="contained"
374                    startIcon={emptyState.actionIcon}
375                    onClick={emptyState.onAction}
376                    sx={{ mt: 1 }}
377                  >
378                    {emptyState.actionLabel}
379                  </Button>
380                )}
381              </>
382            ) : (
383              <>
384                <Inbox sx={{ fontSize: 48, opacity: 0.4 }} />
385                <Typography variant="body2">{effectiveEmptyMessage}</Typography>
386              </>
387            )}
388          </EmptyState>
389        ) : (
390          <Box sx={{ overflowX: "auto" }}>
391            <Table>
392              <TableHead>
393                <TableRow
394                  sx={{
395                    "& th:first-of-type": { paddingLeft: "24px" },
396                    "& th:last-of-type": { paddingRight: "24px" },
397                  }}
398                >
399                  {bulkEnabled && (
400                    <TableCell
401                      padding="checkbox"
402                      sx={{ backgroundColor: "action.hover", borderBottom: "1px solid", borderColor: "divider" }}
403                    >
404                      <Checkbox
405                        size="small"
406                        checked={allPageSelected}
407                        indeterminate={!allPageSelected && somePageSelected}
408                        onChange={toggleAllOnPage}
409                        inputProps={{ "aria-label": "select all on page" }}
410                      />
411                    </TableCell>
412                  )}
413                  {columns.map((col) => (
414                    <TableCell
415                      key={col.key}
416                      align={col.align || "left"}
417                      sx={{
418                        fontWeight: 600,
419                        fontSize: "0.78rem",
420                        textTransform: "uppercase",
421                        letterSpacing: "0.5px",
422                        color: "text.secondary",
423                        backgroundColor: "action.hover",
424                        borderBottom: "1px solid",
425                        borderColor: "divider",
426                        width: col.width,
427                      }}
428                    >
429                      {col.sortable ? (
430                        <TableSortLabel
431                          active={sortKey === col.key}
432                          direction={sortKey === col.key ? sortDir : "asc"}
433                          onClick={() => handleSort(col.key)}
434                        >
435                          {col.label}
436                        </TableSortLabel>
437                      ) : (
438                        col.label
439                      )}
440                    </TableCell>
441                  ))}
442                  {actions && <TableCell align="right" sx={{ backgroundColor: "action.hover" }} />}
443                </TableRow>
444              </TableHead>
445              <TableBody>
446                {paged.map((row) => {
447                  const k = rowKey(row);
448                  const isSelected = selectedKeys.has(k);
449                  return (
450                    <StyledTableRow
451                      key={k}
452                      clickable={onRowClick ? "true" : "false"}
453                      selected={isSelected}
454                      onClick={onRowClick ? (e) => {
455                        // Don't fire row click on action button clicks / checkbox clicks.
456                        if (e.target.closest("button, a, input")) return;
457                        onRowClick(row);
458                      } : undefined}
459                    >
460                      {bulkEnabled && (
461                        <TableCell padding="checkbox">
462                          <Checkbox
463                            size="small"
464                            checked={isSelected}
465                            onChange={() => toggleRow(row)}
466                            onClick={(e) => e.stopPropagation()}
467                            inputProps={{ "aria-label": `select row ${k}` }}
468                          />
469                        </TableCell>
470                      )}
471                      {columns.map((col) => (
472                        <TableCell key={col.key} align={col.align || "left"}>
473                          {col.render ? col.render(row) : getNested(row, col.key)}
474                        </TableCell>
475                      ))}
476                      {actions && (
477                        <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
478                          {actions(row)}
479                        </TableCell>
480                      )}
481                    </StyledTableRow>
482                  );
483                })}
484              </TableBody>
485            </Table>
486          </Box>
487        )}
488  
489        {sorted.length > rowsPerPage && (
490          <TablePagination
491            component="div"
492            count={sorted.length}
493            page={page}
494            onPageChange={(_, p) => setPage(p)}
495            rowsPerPage={rowsPerPage}
496            onRowsPerPageChange={(e) => {
497              setRowsPerPage(parseInt(e.target.value, 10));
498              setPage(0);
499            }}
500            rowsPerPageOptions={[10, 25, 50, 100]}
501          />
502        )}
503      </StyledCard>
504    );
505  }