@micha.bigler/ui-core-micha 2.4.4 → 2.5.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.
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
3
3
  import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, } from '@mui/material';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import apiClient from './apiClient';
6
- import { HEADLESS_BASE } from './authConfig.jsx';
6
+ import { HEADLESS_BASE } from './authConfig';
7
7
  import { rejectReauth, resolveReauth, subscribe } from './reauth';
8
8
  export function ReauthModal() {
9
9
  const { t } = useTranslation();
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useMemo, useState, useEffect, useCallback } from 'react';
3
- import { Box, Typography, FormControl, Select, MenuItem, Button, Tooltip, CircularProgress, Alert, TextField, } from '@mui/material';
4
- import { DataGrid } from '@mui/x-data-grid';
3
+ import { Box, Typography, FormControl, Select, MenuItem, Button, Tooltip, CircularProgress, Alert, TextField, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, TablePagination, } from '@mui/material';
5
4
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
6
5
  import CancelIcon from '@mui/icons-material/Cancel';
7
6
  import DeleteIcon from '@mui/icons-material/Delete';
@@ -15,6 +14,10 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
15
14
  const [error, setError] = useState(null);
16
15
  const [rowActionLoading, setRowActionLoading] = useState({});
17
16
  const [searchQuery, setSearchQuery] = useState('');
17
+ const [sortField, setSortField] = useState('email');
18
+ const [sortDir, setSortDir] = useState('asc');
19
+ const [page, setPage] = useState(0);
20
+ const [pageSize, setPageSize] = useState(25);
18
21
  const controlSx = { minWidth: 140 };
19
22
  const actionButtonSx = { textTransform: 'none', minWidth: 90 };
20
23
  const loadUsers = useCallback(async () => {
@@ -42,6 +45,9 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
42
45
  useEffect(() => {
43
46
  loadUsers();
44
47
  }, [loadUsers, refreshTrigger]);
48
+ useEffect(() => {
49
+ setPage(0);
50
+ }, [searchQuery]);
45
51
  const handleDelete = async (userId) => {
46
52
  if (!window.confirm(t('UserList.DELETE_CONFIRM', 'Are you sure you want to delete this user?')))
47
53
  return;
@@ -211,16 +217,23 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
211
217
  field: 'email',
212
218
  headerName: t('Auth.EMAIL_LABEL', 'Email'),
213
219
  minWidth: 220,
214
- maxWidth: 340,
215
220
  flex: 1,
221
+ sortable: true,
222
+ align: 'left',
223
+ headerAlign: 'left',
224
+ valueGetter: (row) => { var _a; return (_a = row === null || row === void 0 ? void 0 : row.email) !== null && _a !== void 0 ? _a : ''; },
225
+ renderCell: (row) => row.email,
216
226
  },
217
227
  {
218
228
  field: 'name',
219
229
  headerName: t('Profile.NAME_LABEL', 'Name'),
220
230
  minWidth: 180,
221
- maxWidth: 260,
222
231
  flex: 0.9,
223
- valueGetter: (_value, row) => getUserDisplayName(row),
232
+ sortable: true,
233
+ align: 'left',
234
+ headerAlign: 'left',
235
+ valueGetter: (row) => getUserDisplayName(row),
236
+ renderCell: (row) => getUserDisplayName(row),
224
237
  },
225
238
  ];
226
239
  if (showNewColumn) {
@@ -228,14 +241,14 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
228
241
  field: 'is_new',
229
242
  headerName: t('UserList.NEW', 'New'),
230
243
  minWidth: 90,
231
- maxWidth: 110,
232
244
  flex: 0.35,
245
+ sortable: true,
233
246
  align: 'center',
234
247
  headerAlign: 'center',
235
- valueGetter: (_value, row) => { var _a; return Boolean(((_a = row === null || row === void 0 ? void 0 : row.profile) === null || _a === void 0 ? void 0 : _a.is_new) || (row === null || row === void 0 ? void 0 : row.is_new)); },
236
- renderCell: (params) => {
237
- var _a, _b, _c;
238
- return renderBooleanStatusIcon(Boolean(((_b = (_a = params.row) === null || _a === void 0 ? void 0 : _a.profile) === null || _b === void 0 ? void 0 : _b.is_new) || ((_c = params.row) === null || _c === void 0 ? void 0 : _c.is_new)), t('Common.YES', 'Yes'), t('Common.NO', 'No'));
248
+ valueGetter: (row) => { var _a; return Boolean(((_a = row === null || row === void 0 ? void 0 : row.profile) === null || _a === void 0 ? void 0 : _a.is_new) || (row === null || row === void 0 ? void 0 : row.is_new)); },
249
+ renderCell: (row) => {
250
+ var _a;
251
+ return renderBooleanStatusIcon(Boolean(((_a = row === null || row === void 0 ? void 0 : row.profile) === null || _a === void 0 ? void 0 : _a.is_new) || (row === null || row === void 0 ? void 0 : row.is_new)), t('Common.YES', 'Yes'), t('Common.NO', 'No'));
239
252
  },
240
253
  });
241
254
  }
@@ -244,14 +257,14 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
244
257
  field: 'successful_login',
245
258
  headerName: t('UserList.SUCCESSFUL_LOGIN', 'Successful Login'),
246
259
  minWidth: 120,
247
- maxWidth: 150,
248
260
  flex: 0.4,
261
+ sortable: true,
249
262
  align: 'center',
250
263
  headerAlign: 'center',
251
- valueGetter: (_value, row) => { var _a; return Boolean((_a = row === null || row === void 0 ? void 0 : row.successful_login) !== null && _a !== void 0 ? _a : row === null || row === void 0 ? void 0 : row.last_login); },
252
- renderCell: (params) => {
253
- var _a, _b, _c;
254
- return renderBooleanStatusIcon(Boolean((_b = (_a = params.row) === null || _a === void 0 ? void 0 : _a.successful_login) !== null && _b !== void 0 ? _b : (_c = params.row) === null || _c === void 0 ? void 0 : _c.last_login), t('Common.YES', 'Yes'), t('Common.NO', 'No'));
264
+ valueGetter: (row) => { var _a; return Boolean((_a = row === null || row === void 0 ? void 0 : row.successful_login) !== null && _a !== void 0 ? _a : row === null || row === void 0 ? void 0 : row.last_login); },
265
+ renderCell: (row) => {
266
+ var _a;
267
+ return renderBooleanStatusIcon(Boolean((_a = row === null || row === void 0 ? void 0 : row.successful_login) !== null && _a !== void 0 ? _a : row === null || row === void 0 ? void 0 : row.last_login), t('Common.YES', 'Yes'), t('Common.NO', 'No'));
255
268
  },
256
269
  });
257
270
  }
@@ -260,28 +273,26 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
260
273
  field: 'role',
261
274
  headerName: t('UserList.ROLE', 'Role'),
262
275
  minWidth: 180,
263
- maxWidth: 240,
264
276
  flex: 0.7,
265
- valueGetter: (_value, row) => (row === null || row === void 0 ? void 0 : row.role) || 'none',
266
- renderCell: (params) => {
267
- const user = params.row;
268
- return (_jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(user), children: _jsx(Select, { value: user.role || 'none', onChange: (event) => handleChangeRole(user.id, event.target.value), variant: "outlined", children: roles.map((role) => _jsx(MenuItem, { value: role, children: role }, role)) }) }));
269
- },
277
+ sortable: true,
278
+ align: 'left',
279
+ headerAlign: 'left',
280
+ valueGetter: (row) => (row === null || row === void 0 ? void 0 : row.role) || 'none',
281
+ renderCell: (row) => (_jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(row), children: _jsx(Select, { value: row.role || 'none', onChange: (event) => handleChangeRole(row.id, event.target.value), variant: "outlined", children: roles.map((role) => _jsx(MenuItem, { value: role, children: role }, role)) }) })),
270
282
  });
271
283
  }
272
284
  const mappedExtraColumns = visibleExtraColumns.map((column) => ({
273
285
  field: `extra:${column.key}`,
274
286
  headerName: typeof column.label === 'function' ? column.label(listContext) : column.label,
275
287
  minWidth: Number(column.minWidth) || 180,
276
- maxWidth: Number(column.maxWidth) || 320,
277
288
  flex: Number(column.flex) || 0.9,
278
289
  sortable: column.sortable !== false,
279
290
  align: column.align || 'left',
280
291
  headerAlign: column.align || 'left',
281
- valueGetter: (_value, row) => getExtraColumnSortValue(column, row),
282
- renderCell: (params) => column.renderCell({
283
- user: params.row,
284
- canEdit: canEdit(params.row),
292
+ valueGetter: (row) => getExtraColumnSortValue(column, row),
293
+ renderCell: (row) => column.renderCell({
294
+ user: row,
295
+ canEdit: canEdit(row),
285
296
  currentUser,
286
297
  extraContext,
287
298
  t,
@@ -296,30 +307,27 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
296
307
  field: 'actions',
297
308
  headerName: t('Common.ACTIONS', 'Actions'),
298
309
  minWidth: Math.max(220, 110 + (visibleRowActions.length + (showDeleteAction ? 1 : 0)) * 110),
299
- maxWidth: Math.max(360, 120 + (visibleRowActions.length + (showDeleteAction ? 1 : 0)) * 120),
300
310
  flex: 1.1,
301
311
  sortable: false,
302
- filterable: false,
303
- disableColumnMenu: true,
304
- renderCell: (params) => {
305
- const user = params.row;
306
- return (_jsxs(Box, { sx: { display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', py: 0.5 }, children: [visibleRowActions.map((action) => {
307
- const actionId = `${action.key}:${user.id}`;
308
- const isBusy = Boolean(rowActionLoading[actionId]);
309
- const isDisabled = typeof action.disabled === 'function'
310
- ? action.disabled({
311
- user,
312
- canEdit: canEdit(user),
313
- currentUser,
314
- extraContext,
315
- t,
316
- })
317
- : false;
318
- return (_jsx(Button, { size: "small", variant: "outlined", onClick: () => runRowAction(action, user), disabled: isBusy || isDisabled, sx: actionButtonSx, children: typeof action.label === 'function'
319
- ? action.label({ user, t, currentUser, canEdit: canEdit(user) })
320
- : action.label }, `${action.key}-${user.id}`));
321
- }), showDeleteAction && (_jsx(Tooltip, { title: t('Common.DELETE', 'Delete'), children: _jsx("span", { children: _jsx(Button, { size: "small", variant: "outlined", color: "error", startIcon: _jsx(DeleteIcon, {}), onClick: () => handleDelete(user.id), disabled: !canDelete(user), sx: actionButtonSx, children: t('Common.DELETE', 'Delete') }) }) }))] }));
322
- },
312
+ align: 'left',
313
+ headerAlign: 'left',
314
+ valueGetter: null,
315
+ renderCell: (row) => (_jsxs(Box, { sx: { display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', py: 0.5 }, children: [visibleRowActions.map((action) => {
316
+ const actionId = `${action.key}:${row.id}`;
317
+ const isBusy = Boolean(rowActionLoading[actionId]);
318
+ const isDisabled = typeof action.disabled === 'function'
319
+ ? action.disabled({
320
+ user: row,
321
+ canEdit: canEdit(row),
322
+ currentUser,
323
+ extraContext,
324
+ t,
325
+ })
326
+ : false;
327
+ return (_jsx(Button, { size: "small", variant: "outlined", onClick: () => runRowAction(action, row), disabled: isBusy || isDisabled, sx: actionButtonSx, children: typeof action.label === 'function'
328
+ ? action.label({ user: row, t, currentUser, canEdit: canEdit(row) })
329
+ : action.label }, `${action.key}-${row.id}`));
330
+ }), showDeleteAction && (_jsx(Tooltip, { title: t('Common.DELETE', 'Delete'), children: _jsx("span", { children: _jsx(Button, { size: "small", variant: "outlined", color: "error", startIcon: _jsx(DeleteIcon, {}), onClick: () => handleDelete(row.id), disabled: !canDelete(row), sx: actionButtonSx, children: t('Common.DELETE', 'Delete') }) }) }))] })),
323
331
  };
324
332
  return [...baseColumns, ...mappedExtraColumns, actionColumn];
325
333
  }, [
@@ -339,27 +347,29 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
339
347
  canEdit,
340
348
  canDelete,
341
349
  ]);
342
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('UserList.TITLE', 'All Users') }), _jsx(Box, { sx: { mb: 2, maxWidth: 420 }, children: _jsx(TextField, { fullWidth: true, size: "small", label: t('Common.SEARCH', 'Search'), placeholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'), value: searchQuery, onChange: (event) => setSearchQuery(event.target.value) }) }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(error) }), loading && (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', p: 3 }, children: _jsx(CircularProgress, {}) })), !loading && (_jsx(Box, { sx: { width: '100%', minHeight: 520 }, children: _jsx(DataGrid, { rows: filteredUsers, columns: columns, disableRowSelectionOnClick: true, showToolbar: true, getRowHeight: () => 'auto', pageSizeOptions: [10, 25, 50, 100], initialState: {
343
- sorting: { sortModel: [{ field: 'email', sort: 'asc' }] },
344
- pagination: { paginationModel: { pageSize: 25, page: 0 } },
345
- }, localeText: {
346
- noRowsLabel: t('UserList.NO_USERS', 'No users found.'),
347
- toolbarQuickFilterPlaceholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'),
348
- }, slotProps: {
349
- toolbar: {
350
- showQuickFilter: true,
351
- quickFilterProps: { debounceMs: 300 },
352
- },
353
- }, sx: {
354
- border: 1,
355
- borderColor: 'divider',
356
- '& .MuiDataGrid-cell': {
357
- display: 'flex',
358
- alignItems: 'flex-start',
359
- py: 1,
360
- },
361
- '& .MuiDataGrid-columnHeaders': {
362
- bgcolor: 'action.hover',
363
- },
364
- } }) }))] }));
350
+ const sortedUsers = useMemo(() => {
351
+ const col = columns.find((c) => c.field === sortField);
352
+ return [...filteredUsers].sort((a, b) => {
353
+ var _a, _b;
354
+ const aVal = (col === null || col === void 0 ? void 0 : col.valueGetter) ? col.valueGetter(a) : ((_a = a[sortField]) !== null && _a !== void 0 ? _a : '');
355
+ const bVal = (col === null || col === void 0 ? void 0 : col.valueGetter) ? col.valueGetter(b) : ((_b = b[sortField]) !== null && _b !== void 0 ? _b : '');
356
+ const cmp = String(aVal !== null && aVal !== void 0 ? aVal : '').localeCompare(String(bVal !== null && bVal !== void 0 ? bVal : ''), undefined, { numeric: true, sensitivity: 'base' });
357
+ return sortDir === 'asc' ? cmp : -cmp;
358
+ });
359
+ }, [filteredUsers, sortField, sortDir, columns]);
360
+ const pagedUsers = useMemo(() => sortedUsers.slice(page * pageSize, page * pageSize + pageSize), [sortedUsers, page, pageSize]);
361
+ const handleSortClick = (field) => {
362
+ if (sortField === field) {
363
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
364
+ }
365
+ else {
366
+ setSortField(field);
367
+ setSortDir('asc');
368
+ }
369
+ setPage(0);
370
+ };
371
+ return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('UserList.TITLE', 'All Users') }), _jsx(Box, { sx: { mb: 2, maxWidth: 420 }, children: _jsx(TextField, { fullWidth: true, size: "small", label: t('Common.SEARCH', 'Search'), placeholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'), value: searchQuery, onChange: (event) => setSearchQuery(event.target.value) }) }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(error) }), loading && (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', p: 3 }, children: _jsx(CircularProgress, {}) })), !loading && (_jsxs(Box, { sx: { width: '100%' }, children: [_jsx(TableContainer, { sx: { border: 1, borderColor: 'divider', borderRadius: 1 }, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { sx: { bgcolor: 'action.hover' }, children: _jsx(TableRow, { children: columns.map((col) => (_jsx(TableCell, { align: col.headerAlign || col.align || 'left', sortDirection: sortField === col.field ? sortDir : false, sx: { minWidth: col.minWidth, fontWeight: 600, whiteSpace: 'nowrap' }, children: col.sortable !== false ? (_jsx(TableSortLabel, { active: sortField === col.field, direction: sortField === col.field ? sortDir : 'asc', onClick: () => handleSortClick(col.field), children: col.headerName })) : (col.headerName) }, col.field))) }) }), _jsx(TableBody, { children: pagedUsers.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: columns.length, align: "center", sx: { py: 4, color: 'text.secondary' }, children: t('UserList.NO_USERS', 'No users found.') }) })) : (pagedUsers.map((row) => (_jsx(TableRow, { hover: true, children: columns.map((col) => (_jsx(TableCell, { align: col.align || 'left', sx: { verticalAlign: 'top', py: 1 }, children: col.renderCell(row) }, col.field))) }, row.id)))) })] }) }), _jsx(TablePagination, { component: "div", count: sortedUsers.length, page: page, onPageChange: (_, newPage) => setPage(newPage), rowsPerPage: pageSize, onRowsPerPageChange: (event) => {
372
+ setPageSize(Number(event.target.value));
373
+ setPage(0);
374
+ }, rowsPerPageOptions: [10, 25, 50, 100] })] }))] }));
365
375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.4.4",
3
+ "version": "2.5.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -12,7 +12,6 @@
12
12
  "@emotion/react": "^11.14.0",
13
13
  "@emotion/styled": "^11.14.1",
14
14
  "@mui/material": "^7.3.11",
15
- "@mui/x-data-grid": "^8.28.6",
16
15
  "axios": "^1.16.1",
17
16
  "i18next": "^25.10.10 || ^26.0.0",
18
17
  "qrcode.react": "^4.2.0",
@@ -10,7 +10,7 @@ import {
10
10
  } from '@mui/material';
11
11
  import { useTranslation } from 'react-i18next';
12
12
  import apiClient from './apiClient';
13
- import { HEADLESS_BASE } from './authConfig.jsx';
13
+ import { HEADLESS_BASE } from './authConfig';
14
14
  import { rejectReauth, resolveReauth, subscribe } from './reauth';
15
15
 
16
16
  export function ReauthModal() {
@@ -10,8 +10,15 @@ import {
10
10
  CircularProgress,
11
11
  Alert,
12
12
  TextField,
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableContainer,
17
+ TableHead,
18
+ TableRow,
19
+ TableSortLabel,
20
+ TablePagination,
13
21
  } from '@mui/material';
14
- import { DataGrid } from '@mui/x-data-grid';
15
22
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
16
23
  import CancelIcon from '@mui/icons-material/Cancel';
17
24
  import DeleteIcon from '@mui/icons-material/Delete';
@@ -20,7 +27,7 @@ import { fetchUsersList, deleteUser, updateUserRole } from '../auth/authApi';
20
27
 
21
28
  const DEFAULT_ROLES = ['none', 'student', 'teacher', 'admin'];
22
29
 
23
- export function UserListComponent({
30
+ export function UserListComponent({
24
31
  roles = DEFAULT_ROLES,
25
32
  currentUser,
26
33
  extraColumns = [],
@@ -42,6 +49,10 @@ export function UserListComponent({
42
49
  const [error, setError] = useState(null);
43
50
  const [rowActionLoading, setRowActionLoading] = useState({});
44
51
  const [searchQuery, setSearchQuery] = useState('');
52
+ const [sortField, setSortField] = useState('email');
53
+ const [sortDir, setSortDir] = useState('asc');
54
+ const [page, setPage] = useState(0);
55
+ const [pageSize, setPageSize] = useState(25);
45
56
  const controlSx = { minWidth: 140 };
46
57
  const actionButtonSx = { textTransform: 'none', minWidth: 90 };
47
58
 
@@ -69,6 +80,10 @@ export function UserListComponent({
69
80
  loadUsers();
70
81
  }, [loadUsers, refreshTrigger]);
71
82
 
83
+ useEffect(() => {
84
+ setPage(0);
85
+ }, [searchQuery]);
86
+
72
87
  const handleDelete = async (userId) => {
73
88
  if (!window.confirm(t('UserList.DELETE_CONFIRM', 'Are you sure you want to delete this user?'))) return;
74
89
  try {
@@ -106,14 +121,14 @@ export function UserListComponent({
106
121
  const defaultCanEdit = (targetUser) => {
107
122
  if (!currentUser) return false;
108
123
  if (currentUser.is_superuser) return true;
109
-
124
+
110
125
  const myRole = currentUser.role || 'none';
111
126
  const targetRole = targetUser.role || 'none';
112
127
 
113
128
  if (myRole === 'admin') return true;
114
-
129
+
115
130
  if (myRole === 'teacher') {
116
- if (targetUser.id === currentUser.id) return false;
131
+ if (targetUser.id === currentUser.id) return false;
117
132
  if (['teacher', 'admin', 'supervisor'].includes(targetRole)) return false;
118
133
  return true;
119
134
  }
@@ -267,16 +282,23 @@ export function UserListComponent({
267
282
  field: 'email',
268
283
  headerName: t('Auth.EMAIL_LABEL', 'Email'),
269
284
  minWidth: 220,
270
- maxWidth: 340,
271
285
  flex: 1,
286
+ sortable: true,
287
+ align: 'left',
288
+ headerAlign: 'left',
289
+ valueGetter: (row) => row?.email ?? '',
290
+ renderCell: (row) => row.email,
272
291
  },
273
292
  {
274
293
  field: 'name',
275
294
  headerName: t('Profile.NAME_LABEL', 'Name'),
276
295
  minWidth: 180,
277
- maxWidth: 260,
278
296
  flex: 0.9,
279
- valueGetter: (_value, row) => getUserDisplayName(row),
297
+ sortable: true,
298
+ align: 'left',
299
+ headerAlign: 'left',
300
+ valueGetter: (row) => getUserDisplayName(row),
301
+ renderCell: (row) => getUserDisplayName(row),
280
302
  },
281
303
  ];
282
304
 
@@ -285,13 +307,13 @@ export function UserListComponent({
285
307
  field: 'is_new',
286
308
  headerName: t('UserList.NEW', 'New'),
287
309
  minWidth: 90,
288
- maxWidth: 110,
289
310
  flex: 0.35,
311
+ sortable: true,
290
312
  align: 'center',
291
313
  headerAlign: 'center',
292
- valueGetter: (_value, row) => Boolean(row?.profile?.is_new || row?.is_new),
293
- renderCell: (params) => renderBooleanStatusIcon(
294
- Boolean(params.row?.profile?.is_new || params.row?.is_new),
314
+ valueGetter: (row) => Boolean(row?.profile?.is_new || row?.is_new),
315
+ renderCell: (row) => renderBooleanStatusIcon(
316
+ Boolean(row?.profile?.is_new || row?.is_new),
295
317
  t('Common.YES', 'Yes'),
296
318
  t('Common.NO', 'No'),
297
319
  ),
@@ -303,13 +325,13 @@ export function UserListComponent({
303
325
  field: 'successful_login',
304
326
  headerName: t('UserList.SUCCESSFUL_LOGIN', 'Successful Login'),
305
327
  minWidth: 120,
306
- maxWidth: 150,
307
328
  flex: 0.4,
329
+ sortable: true,
308
330
  align: 'center',
309
331
  headerAlign: 'center',
310
- valueGetter: (_value, row) => Boolean(row?.successful_login ?? row?.last_login),
311
- renderCell: (params) => renderBooleanStatusIcon(
312
- Boolean(params.row?.successful_login ?? params.row?.last_login),
332
+ valueGetter: (row) => Boolean(row?.successful_login ?? row?.last_login),
333
+ renderCell: (row) => renderBooleanStatusIcon(
334
+ Boolean(row?.successful_login ?? row?.last_login),
313
335
  t('Common.YES', 'Yes'),
314
336
  t('Common.NO', 'No'),
315
337
  ),
@@ -321,23 +343,22 @@ export function UserListComponent({
321
343
  field: 'role',
322
344
  headerName: t('UserList.ROLE', 'Role'),
323
345
  minWidth: 180,
324
- maxWidth: 240,
325
346
  flex: 0.7,
326
- valueGetter: (_value, row) => row?.role || 'none',
327
- renderCell: (params) => {
328
- const user = params.row;
329
- return (
330
- <FormControl size="small" fullWidth sx={controlSx} disabled={!canEdit(user)}>
331
- <Select
332
- value={user.role || 'none'}
333
- onChange={(event) => handleChangeRole(user.id, event.target.value)}
334
- variant="outlined"
335
- >
336
- {roles.map((role) => <MenuItem key={role} value={role}>{role}</MenuItem>)}
337
- </Select>
338
- </FormControl>
339
- );
340
- },
347
+ sortable: true,
348
+ align: 'left',
349
+ headerAlign: 'left',
350
+ valueGetter: (row) => row?.role || 'none',
351
+ renderCell: (row) => (
352
+ <FormControl size="small" fullWidth sx={controlSx} disabled={!canEdit(row)}>
353
+ <Select
354
+ value={row.role || 'none'}
355
+ onChange={(event) => handleChangeRole(row.id, event.target.value)}
356
+ variant="outlined"
357
+ >
358
+ {roles.map((role) => <MenuItem key={role} value={role}>{role}</MenuItem>)}
359
+ </Select>
360
+ </FormControl>
361
+ ),
341
362
  });
342
363
  }
343
364
 
@@ -345,16 +366,15 @@ export function UserListComponent({
345
366
  field: `extra:${column.key}`,
346
367
  headerName: typeof column.label === 'function' ? column.label(listContext) : column.label,
347
368
  minWidth: Number(column.minWidth) || 180,
348
- maxWidth: Number(column.maxWidth) || 320,
349
369
  flex: Number(column.flex) || 0.9,
350
370
  sortable: column.sortable !== false,
351
371
  align: column.align || 'left',
352
372
  headerAlign: column.align || 'left',
353
- valueGetter: (_value, row) => getExtraColumnSortValue(column, row),
354
- renderCell: (params) =>
373
+ valueGetter: (row) => getExtraColumnSortValue(column, row),
374
+ renderCell: (row) =>
355
375
  column.renderCell({
356
- user: params.row,
357
- canEdit: canEdit(params.row),
376
+ user: row,
377
+ canEdit: canEdit(row),
358
378
  currentUser,
359
379
  extraContext,
360
380
  t,
@@ -372,65 +392,61 @@ export function UserListComponent({
372
392
  field: 'actions',
373
393
  headerName: t('Common.ACTIONS', 'Actions'),
374
394
  minWidth: Math.max(220, 110 + (visibleRowActions.length + (showDeleteAction ? 1 : 0)) * 110),
375
- maxWidth: Math.max(360, 120 + (visibleRowActions.length + (showDeleteAction ? 1 : 0)) * 120),
376
395
  flex: 1.1,
377
396
  sortable: false,
378
- filterable: false,
379
- disableColumnMenu: true,
380
- renderCell: (params) => {
381
- const user = params.row;
382
-
383
- return (
384
- <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', py: 0.5 }}>
385
- {visibleRowActions.map((action) => {
386
- const actionId = `${action.key}:${user.id}`;
387
- const isBusy = Boolean(rowActionLoading[actionId]);
388
- const isDisabled = typeof action.disabled === 'function'
389
- ? action.disabled({
390
- user,
391
- canEdit: canEdit(user),
392
- currentUser,
393
- extraContext,
394
- t,
395
- })
396
- : false;
397
-
398
- return (
397
+ align: 'left',
398
+ headerAlign: 'left',
399
+ valueGetter: null,
400
+ renderCell: (row) => (
401
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', py: 0.5 }}>
402
+ {visibleRowActions.map((action) => {
403
+ const actionId = `${action.key}:${row.id}`;
404
+ const isBusy = Boolean(rowActionLoading[actionId]);
405
+ const isDisabled = typeof action.disabled === 'function'
406
+ ? action.disabled({
407
+ user: row,
408
+ canEdit: canEdit(row),
409
+ currentUser,
410
+ extraContext,
411
+ t,
412
+ })
413
+ : false;
414
+
415
+ return (
416
+ <Button
417
+ key={`${action.key}-${row.id}`}
418
+ size="small"
419
+ variant="outlined"
420
+ onClick={() => runRowAction(action, row)}
421
+ disabled={isBusy || isDisabled}
422
+ sx={actionButtonSx}
423
+ >
424
+ {typeof action.label === 'function'
425
+ ? action.label({ user: row, t, currentUser, canEdit: canEdit(row) })
426
+ : action.label}
427
+ </Button>
428
+ );
429
+ })}
430
+
431
+ {showDeleteAction && (
432
+ <Tooltip title={t('Common.DELETE', 'Delete')}>
433
+ <span>
399
434
  <Button
400
- key={`${action.key}-${user.id}`}
401
435
  size="small"
402
436
  variant="outlined"
403
- onClick={() => runRowAction(action, user)}
404
- disabled={isBusy || isDisabled}
437
+ color="error"
438
+ startIcon={<DeleteIcon />}
439
+ onClick={() => handleDelete(row.id)}
440
+ disabled={!canDelete(row)}
405
441
  sx={actionButtonSx}
406
442
  >
407
- {typeof action.label === 'function'
408
- ? action.label({ user, t, currentUser, canEdit: canEdit(user) })
409
- : action.label}
443
+ {t('Common.DELETE', 'Delete')}
410
444
  </Button>
411
- );
412
- })}
413
-
414
- {showDeleteAction && (
415
- <Tooltip title={t('Common.DELETE', 'Delete')}>
416
- <span>
417
- <Button
418
- size="small"
419
- variant="outlined"
420
- color="error"
421
- startIcon={<DeleteIcon />}
422
- onClick={() => handleDelete(user.id)}
423
- disabled={!canDelete(user)}
424
- sx={actionButtonSx}
425
- >
426
- {t('Common.DELETE', 'Delete')}
427
- </Button>
428
- </span>
429
- </Tooltip>
430
- )}
431
- </Box>
432
- );
433
- },
445
+ </span>
446
+ </Tooltip>
447
+ )}
448
+ </Box>
449
+ ),
434
450
  };
435
451
 
436
452
  return [...baseColumns, ...mappedExtraColumns, actionColumn];
@@ -452,6 +468,31 @@ export function UserListComponent({
452
468
  canDelete,
453
469
  ]);
454
470
 
471
+ const sortedUsers = useMemo(() => {
472
+ const col = columns.find((c) => c.field === sortField);
473
+ return [...filteredUsers].sort((a, b) => {
474
+ const aVal = col?.valueGetter ? col.valueGetter(a) : (a[sortField] ?? '');
475
+ const bVal = col?.valueGetter ? col.valueGetter(b) : (b[sortField] ?? '');
476
+ const cmp = String(aVal ?? '').localeCompare(String(bVal ?? ''), undefined, { numeric: true, sensitivity: 'base' });
477
+ return sortDir === 'asc' ? cmp : -cmp;
478
+ });
479
+ }, [filteredUsers, sortField, sortDir, columns]);
480
+
481
+ const pagedUsers = useMemo(
482
+ () => sortedUsers.slice(page * pageSize, page * pageSize + pageSize),
483
+ [sortedUsers, page, pageSize],
484
+ );
485
+
486
+ const handleSortClick = (field) => {
487
+ if (sortField === field) {
488
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
489
+ } else {
490
+ setSortField(field);
491
+ setSortDir('asc');
492
+ }
493
+ setPage(0);
494
+ };
495
+
455
496
  return (
456
497
  <Box>
457
498
  <Typography variant="h6" gutterBottom>{t('UserList.TITLE', 'All Users')}</Typography>
@@ -466,7 +507,7 @@ export function UserListComponent({
466
507
  onChange={(event) => setSearchQuery(event.target.value)}
467
508
  />
468
509
  </Box>
469
-
510
+
470
511
  {error && <Alert severity="error" sx={{ mb: 2 }}>{t(error)}</Alert>}
471
512
 
472
513
  {loading && (
@@ -476,40 +517,73 @@ export function UserListComponent({
476
517
  )}
477
518
 
478
519
  {!loading && (
479
- <Box sx={{ width: '100%', minHeight: 520 }}>
480
- <DataGrid
481
- rows={filteredUsers}
482
- columns={columns}
483
- disableRowSelectionOnClick
484
- showToolbar
485
- getRowHeight={() => 'auto'}
486
- pageSizeOptions={[10, 25, 50, 100]}
487
- initialState={{
488
- sorting: { sortModel: [{ field: 'email', sort: 'asc' }] },
489
- pagination: { paginationModel: { pageSize: 25, page: 0 } },
490
- }}
491
- localeText={{
492
- noRowsLabel: t('UserList.NO_USERS', 'No users found.'),
493
- toolbarQuickFilterPlaceholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'),
494
- }}
495
- slotProps={{
496
- toolbar: {
497
- showQuickFilter: true,
498
- quickFilterProps: { debounceMs: 300 },
499
- },
500
- }}
501
- sx={{
502
- border: 1,
503
- borderColor: 'divider',
504
- '& .MuiDataGrid-cell': {
505
- display: 'flex',
506
- alignItems: 'flex-start',
507
- py: 1,
508
- },
509
- '& .MuiDataGrid-columnHeaders': {
510
- bgcolor: 'action.hover',
511
- },
520
+ <Box sx={{ width: '100%' }}>
521
+ <TableContainer sx={{ border: 1, borderColor: 'divider', borderRadius: 1 }}>
522
+ <Table size="small">
523
+ <TableHead sx={{ bgcolor: 'action.hover' }}>
524
+ <TableRow>
525
+ {columns.map((col) => (
526
+ <TableCell
527
+ key={col.field}
528
+ align={col.headerAlign || col.align || 'left'}
529
+ sortDirection={sortField === col.field ? sortDir : false}
530
+ sx={{ minWidth: col.minWidth, fontWeight: 600, whiteSpace: 'nowrap' }}
531
+ >
532
+ {col.sortable !== false ? (
533
+ <TableSortLabel
534
+ active={sortField === col.field}
535
+ direction={sortField === col.field ? sortDir : 'asc'}
536
+ onClick={() => handleSortClick(col.field)}
537
+ >
538
+ {col.headerName}
539
+ </TableSortLabel>
540
+ ) : (
541
+ col.headerName
542
+ )}
543
+ </TableCell>
544
+ ))}
545
+ </TableRow>
546
+ </TableHead>
547
+ <TableBody>
548
+ {pagedUsers.length === 0 ? (
549
+ <TableRow>
550
+ <TableCell
551
+ colSpan={columns.length}
552
+ align="center"
553
+ sx={{ py: 4, color: 'text.secondary' }}
554
+ >
555
+ {t('UserList.NO_USERS', 'No users found.')}
556
+ </TableCell>
557
+ </TableRow>
558
+ ) : (
559
+ pagedUsers.map((row) => (
560
+ <TableRow key={row.id} hover>
561
+ {columns.map((col) => (
562
+ <TableCell
563
+ key={col.field}
564
+ align={col.align || 'left'}
565
+ sx={{ verticalAlign: 'top', py: 1 }}
566
+ >
567
+ {col.renderCell(row)}
568
+ </TableCell>
569
+ ))}
570
+ </TableRow>
571
+ ))
572
+ )}
573
+ </TableBody>
574
+ </Table>
575
+ </TableContainer>
576
+ <TablePagination
577
+ component="div"
578
+ count={sortedUsers.length}
579
+ page={page}
580
+ onPageChange={(_, newPage) => setPage(newPage)}
581
+ rowsPerPage={pageSize}
582
+ onRowsPerPageChange={(event) => {
583
+ setPageSize(Number(event.target.value));
584
+ setPage(0);
512
585
  }}
586
+ rowsPerPageOptions={[10, 25, 50, 100]}
513
587
  />
514
588
  </Box>
515
589
  )}