This commit is contained in:
rgrgogu
2026-05-07 12:36:55 +08:00
parent 95f838376e
commit e23d9b9ec2
8 changed files with 188 additions and 54 deletions
@@ -54,6 +54,7 @@ export default function DataTable({
onRefsReady?.({ onRefsReady?.({
getFilters: () => filtersRef.current, getFilters: () => filtersRef.current,
getSort: () => sortRef.current, getSort: () => sortRef.current,
resetSelection: () => table.resetRowSelection(), // ← expose this
}); });
onFetch({ page: 1, limit: pagination.limit, filters: [], sort: [] }); onFetch({ page: 1, limit: pagination.limit, filters: [], sort: [] });
+62 -15
View File
@@ -97,21 +97,6 @@ export const UserProvider = ({ children }) => {
[request] [request]
); );
// ─── DELETE /api/admin/users/:id (soft delete) ────────────────────────────
const deactivateUser = useCallback(
(userId) =>
request(async () => {
const res = await api.delete(`${BASE}/users/${userId}`);
setUsers((prev) =>
prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u))
);
return res.data;
}),
[request]
);
// ─── GET /api/admin/users/archived ──────────────────────────────────────────── // ─── GET /api/admin/users/archived ────────────────────────────────────────────
const fetchArchivedUsers = useCallback( const fetchArchivedUsers = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) => ({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
@@ -135,6 +120,44 @@ export const UserProvider = ({ children }) => {
[request] [request]
); );
// ─── DELETE /api/admin/users/:id (soft delete) ────────────────────────────
const deactivateUser = useCallback(
(userId) =>
request(async () => {
const res = await api.delete(`${BASE}/users/${userId}`);
setUsers((prev) =>
prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u))
);
return res.data;
}),
[request]
);
// ─── DELETE /api/admin/users/bulk ─────────────────────────────────────────────
const deactivateUsers = useCallback(
({ ids }) =>
request(async () => {
const res = await api.delete(`${BASE}/users/bulk`, { data: { ids } });
const { deactivated_ids } = res.data?.data ?? {}; // ← unwrap nested data
if (deactivated_ids?.length) {
setUsers((prev) =>
prev.map((u) =>
deactivated_ids.includes(u.user_id)
? { ...u, is_active: false }
: u
)
);
}
return res.data;
}),
[request]
);
// ─── POST /api/admin/users/:id/restore ──────────────────────────────────── // ─── POST /api/admin/users/:id/restore ────────────────────────────────────
const restoreUser = useCallback( const restoreUser = useCallback(
(userId) => (userId) =>
@@ -150,6 +173,28 @@ export const UserProvider = ({ children }) => {
[request] [request]
); );
const restoreUsers = useCallback(
({ ids }) =>
request(async () => {
const res = await api.post(`${BASE}/users/bulk/restore`, { ids });
const { restored_ids } = res.data?.data ?? {}; // ← same unwrap
if (restored_ids?.length) {
setUsers((prev) =>
prev.map((u) =>
restored_ids.includes(u.user_id)
? { ...u, is_active: true }
: u
)
);
}
return res.data;
}),
[request]
);
// ─── GET /api/admin/users/:id/sessions ──────────────────────────────────── // ─── GET /api/admin/users/:id/sessions ────────────────────────────────────
const fetchUserSessions = useCallback( const fetchUserSessions = useCallback(
(userId) => (userId) =>
@@ -209,7 +254,9 @@ export const UserProvider = ({ children }) => {
fetchUser, fetchUser,
updateUser, updateUser,
deactivateUser, deactivateUser,
deactivateUsers,
restoreUser, restoreUser,
restoreUsers,
fetchUserSessions, fetchUserSessions,
terminateSession, terminateSession,
}} }}
@@ -1,4 +1,3 @@
// ─── components/ArchiveUserDialog.jsx ────────────────────────────────────────
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -12,11 +11,23 @@ import {
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useUsers } from "@/contexts/AdminUserContext"; import { useUsers } from "@/contexts/AdminUserContext";
export function ArchiveUserDialog({ open, onOpenChange, user, onSuccess }) { /**
const { deactivateUser, loading } = useUsers(); * Unified archive dialog — works for both single and bulk.
*
* Single: <ArchiveUserDialog user={rowObject} ... />
* Bulk: <ArchiveUserDialog ids={[1, 2, 3]} ... />
*/
export function ArchiveUserDialog({ open, onOpenChange, user, ids, onSuccess }) {
const { deactivateUser, deactivateUsers, loading } = useUsers();
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const handleArchive = async () => { const handleArchive = async () => {
const res = await deactivateUser(user?.user_id); const res = isBulk
? await deactivateUsers({ ids })
: await deactivateUser(user?.user_id);
if (res) { if (res) {
onOpenChange(false); onOpenChange(false);
onSuccess?.(); onSuccess?.();
@@ -27,11 +38,15 @@ export function ArchiveUserDialog({ open, onOpenChange, user, onSuccess }) {
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Archive User</AlertDialogTitle> <AlertDialogTitle>
Archive {isBulk ? `${count} Users` : "User"}
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to archive{" "} Are you sure you want to archive{" "}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{user?.personal_info?.name?.full_name ?? user?.email} {isBulk
? `${count} selected user${count !== 1 ? "s" : ""}`
: (user?.personal_info?.name?.full_name ?? user?.email)}
</span> </span>
? They will be deactivated and lose access immediately. ? They will be deactivated and lose access immediately.
</AlertDialogDescription> </AlertDialogDescription>
@@ -44,7 +59,7 @@ export function ArchiveUserDialog({ open, onOpenChange, user, onSuccess }) {
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{loading ? <Spinner className="size-4 mr-2" /> : null} {loading ? <Spinner className="size-4 mr-2" /> : null}
Archive Archive{isBulk ? ` ${count} User${count !== 1 ? "s" : ""}` : ""}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -13,9 +13,10 @@ import { buildRowActions } from "../config/archive/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util"; import { getTimestamp } from "@/utils/timestamp.util";
export default function UsersTable() { export default function ArchiveUsersTable() {
const [restoreTarget, setRestoreTarget] = useState(null); const [restoreTarget, setRestoreTarget] = useState(null); // single: row object
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [] }); const [restoreIds, setRestoreIds] = useState(null); // bulk: array of ids
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {} });
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
@@ -26,7 +27,6 @@ export default function UsersTable() {
loading, loading,
fetchUserFieldValues, fetchUserFieldValues,
fetchArchivedUsers, fetchArchivedUsers,
restoreUser
} = useUsers(); } = useUsers();
// Shared export config — passed into toolbar + selection configs // Shared export config — passed into toolbar + selection configs
@@ -37,17 +37,26 @@ export default function UsersTable() {
sheetName: "ArchivedUsers", sheetName: "ArchivedUsers",
}; };
console.log(users)
const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), }); const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), });
const toolbarActions = buildToolbarActions({ const toolbarActions = buildToolbarActions({
fetchArchivedUsers, pagination, exportConfig, navigate, fetchArchivedUsers, pagination, exportConfig, navigate,
getFilters: () => tableRefsRef.current.getFilters(), getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(), getSort: () => tableRefsRef.current.getSort(),
}); });
const selectionActions = buildSelectionActions({ exportConfig, restoreUser }); const selectionActions = buildSelectionActions({
exportConfig,
restoreUser: (row) => setRestoreTarget(row), // single — open dialog with row
restoreUsers: (ids) => setRestoreIds(ids), // bulk — open dialog with ids
});
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]); const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
const handleRestoreSuccess = () => {
setRestoreTarget(null);
setRestoreIds(null);
tableRefsRef.current.resetSelection?.(); // ← clear selection
fetchArchivedUsers({ page: 1, limit: pagination.limit });
};
return ( return (
<> <>
<DataTable <DataTable
@@ -77,14 +86,20 @@ export default function UsersTable() {
recordLabel="user" recordLabel="user"
emptyMessage="No users match the current filters." emptyMessage="No users match the current filters."
/> />
{/* Single restore */}
<RestoreUserDialog <RestoreUserDialog
open={!!restoreTarget} open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)} onOpenChange={(v) => !v && setRestoreTarget(null)}
user={restoreTarget} user={restoreTarget}
onSuccess={() => { onSuccess={handleRestoreSuccess}
setRestoreTarget(null); />
fetchArchivedUsers({ page: 1, limit: pagination.limit });
}} {/* Bulk restore */}
<RestoreUserDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
onSuccess={handleRestoreSuccess}
/> />
</> </>
); );
@@ -12,11 +12,23 @@ import {
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useUsers } from "@/contexts/AdminUserContext"; import { useUsers } from "@/contexts/AdminUserContext";
export function RestoreUserDialog({ open, onOpenChange, user, onSuccess }) { /**
const { restoreUser, loading } = useUsers(); * Unified restore dialog — works for both single and bulk.
*
* Single: <RestoreUserDialog user={rowObject} ... />
* Bulk: <RestoreUserDialog ids={[1, 2, 3]} ... />
*/
export function RestoreUserDialog({ open, onOpenChange, user, ids, onSuccess }) {
const { restoreUser, restoreUsers, loading } = useUsers();
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const handleRestore = async () => { const handleRestore = async () => {
const res = await restoreUser(user?.user_id); const res = isBulk
? await restoreUsers({ ids })
: await restoreUser(user?.user_id);
if (res) { if (res) {
onOpenChange(false); onOpenChange(false);
onSuccess?.(); onSuccess?.();
@@ -27,13 +39,15 @@ export function RestoreUserDialog({ open, onOpenChange, user, onSuccess }) {
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Restore User</AlertDialogTitle> <AlertDialogTitle>Restore {isBulk ? `${count} Users` : "User"}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to restore{" "} Are you sure you want to restore{" "}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{user?.personal_info?.name?.full_name ?? user?.email} {isBulk
? `${count} selected user${count !== 1 ? "s" : ""}`
: (user?.personal_info?.name?.full_name ?? user?.email)}
</span> </span>
? They will regain access to their account immediately. ? They will regain access to their account{isBulk && count !== 1 ? "s" : ""} immediately.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -44,7 +58,7 @@ export function RestoreUserDialog({ open, onOpenChange, user, onSuccess }) {
className="bg-emerald-600 text-white hover:bg-emerald-700" className="bg-emerald-600 text-white hover:bg-emerald-700"
> >
{loading ? <Spinner className="size-4 mr-2" /> : null} {loading ? <Spinner className="size-4 mr-2" /> : null}
Restore Restore{isBulk ? ` ${count} User${count !== 1 ? "s" : ""}` : ""}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+25 -8
View File
@@ -14,8 +14,9 @@ import { buildRowActions } from "../config/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util"; import { getTimestamp } from "@/utils/timestamp.util";
export default function UsersTable() { export default function UsersTable() {
const [archiveTarget, setArchiveTarget] = useState(null); const [archiveTarget, setArchiveTarget] = useState(null); // single: row object
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [] }); const [archiveIds, setArchiveIds] = useState(null); // bulk: array of ids
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {} });
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
@@ -26,7 +27,6 @@ export default function UsersTable() {
loading, loading,
fetchUsers, fetchUsers,
fetchUserFieldValues, fetchUserFieldValues,
deactivateUser
} = useUsers(); } = useUsers();
// Shared export config — passed into toolbar + selection configs // Shared export config — passed into toolbar + selection configs
@@ -43,9 +43,20 @@ export default function UsersTable() {
getFilters: () => tableRefsRef.current.getFilters(), getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(), getSort: () => tableRefsRef.current.getSort(),
}); });
const selectionActions = buildSelectionActions({ exportConfig, deactivateUser }); const selectionActions = buildSelectionActions({
exportConfig,
archiveUser: (row) => setArchiveTarget(row), // single
archiveUsers: (ids) => setArchiveIds(ids), // bulk
});
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]); const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
const handleArchiveSuccess = () => {
setArchiveTarget(null);
setArchiveIds(null);
tableRefsRef.current.resetSelection?.(); // ← clear selection
fetchUsers({ page: 1, limit: pagination.limit });
};
return ( return (
<> <>
<DataTable <DataTable
@@ -75,14 +86,20 @@ export default function UsersTable() {
recordLabel="user" recordLabel="user"
emptyMessage="No users match the current filters." emptyMessage="No users match the current filters."
/> />
{/* Single archive */}
<ArchiveUserDialog <ArchiveUserDialog
open={!!archiveTarget} open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)} onOpenChange={(v) => !v && setArchiveTarget(null)}
user={archiveTarget} user={archiveTarget}
onSuccess={() => { onSuccess={handleArchiveSuccess}
setArchiveTarget(null); />
fetchUsers({ page: 1, limit: pagination.limit });
}} {/* Bulk archive */}
<ArchiveUserDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
onSuccess={handleArchiveSuccess}
/> />
</> </>
); );
@@ -1,5 +1,5 @@
// config/selection.config.jsx // config/selection.config.jsx
import { Download, Archive, Trash2 } from "lucide-react"; import { Download, ArchiveRestore } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util"; import { exportTableToExcel } from "@/utils/excel.util";
/** /**
@@ -8,7 +8,27 @@ import { exportTableToExcel } from "@/utils/excel.util";
* @param {Function} deps.archiveUser Archive handler from useManagement * @param {Function} deps.archiveUser Archive handler from useManagement
* @param {Function} deps.deleteUser Delete handler from useManagement * @param {Function} deps.deleteUser Delete handler from useManagement
*/ */
export function buildSelectionActions({ exportConfig, archiveUser, deleteUser }) { export function buildSelectionActions({ exportConfig, restoreUser, restoreUsers }) {
return [ return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }),
},
{
key: "restore-selected",
label: "Restore",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
className:
"text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-700",
onClick: (rows) => {
const ids = rows.map((r) => r.user_id);
ids.length === 1
? restoreUser(rows[0]) // opens single dialog
: restoreUsers(ids); // opens bulk dialog
},
},
]; ];
} }
@@ -8,7 +8,7 @@ import { exportTableToExcel } from "@/utils/excel.util";
* @param {Function} deps.archiveUser Archive handler from useManagement * @param {Function} deps.archiveUser Archive handler from useManagement
* @param {Function} deps.deleteUser Delete handler from useManagement * @param {Function} deps.deleteUser Delete handler from useManagement
*/ */
export function buildSelectionActions({ exportConfig, archiveUser }) { export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers }) {
return [ return [
{ {
key: "export-selected", key: "export-selected",
@@ -22,7 +22,12 @@ export function buildSelectionActions({ exportConfig, archiveUser }) {
label: "Archive", label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />, icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => archiveUser({ ids: rows.map((r) => r.id) }), onClick: (rows) => {
const ids = rows.map((r) => r.user_id);
ids.length === 1
? archiveUser(rows[0]) // opens single dialog
: archiveUsers(ids); // opens bulk dialog
},
hidden: (rows) => rows.every((r) => r.status === "archived"), hidden: (rows) => rows.every((r) => r.status === "archived"),
}, },
]; ];