@@ -40,6 +40,7 @@ export function ColumnActionsDropdown({
|
||||
const column = header.column;
|
||||
const sorted = column.getIsSorted();
|
||||
const canFilter = showFilter ?? !!attr;
|
||||
const canSort = column.getCanSort();
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -63,20 +64,24 @@ export function ColumnActionsDropdown({
|
||||
<DropdownMenuLabel className="text-xs">Column Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUp className="mr-2 size-4" />
|
||||
Sort Asc
|
||||
</DropdownMenuItem>
|
||||
{canSort && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUp className="mr-2 size-4" />
|
||||
Sort Asc
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDown className="mr-2 size-4" />
|
||||
Sort Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDown className="mr-2 size-4" />
|
||||
Sort Desc
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => column.clearSorting()}>
|
||||
<X className="mr-2 size-4" />
|
||||
Clear Sort
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.clearSorting()}>
|
||||
<X className="mr-2 size-4" />
|
||||
Clear Sort
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canFilter && (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import api from "@/utils/api.util";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ─── Upload progress stream (Express -> Garage, real bytes) ───────────────────
|
||||
//
|
||||
// Native EventSource can't set the Authorization header this app authenticates
|
||||
// with, so GET /admin/assets/upload-progress/:uploadId is consumed via a
|
||||
// manually-parsed, authenticated fetch() stream instead of EventSource.
|
||||
// Returns a stop() function. Failures here are swallowed on purpose — this is
|
||||
// a best-effort visual on top of the real upload, never load-bearing for it.
|
||||
function streamUploadProgress(uploadId, token, onProgress) {
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_URL}/admin/assets/upload-progress/${uploadId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const chunks = buffer.split("\n\n");
|
||||
buffer = chunks.pop(); // keep the last, possibly-incomplete chunk for next read
|
||||
for (const chunk of chunks) {
|
||||
const line = chunk.split("\n").find((l) => l.startsWith("data: "));
|
||||
if (!line) continue;
|
||||
const data = JSON.parse(line.slice(6));
|
||||
onProgress(data);
|
||||
if (data.done) return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") console.warn("[ASSET][UPLOAD PROGRESS STREAM]", err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
const AssetsContext = createContext(null);
|
||||
|
||||
export function useAssets() {
|
||||
@@ -31,6 +76,7 @@ const cacheKeyFor = (scope, { page, limit, filters, sort }) =>
|
||||
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
|
||||
|
||||
export function AssetsProvider({ children }) {
|
||||
const { accessTokenRef } = useAuth();
|
||||
const [assets, setAssets] = useState([]);
|
||||
const [attributes, setAttributes] = useState([]);
|
||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||
@@ -186,26 +232,51 @@ export function AssetsProvider({ children }) {
|
||||
);
|
||||
|
||||
// ─── POST /api/admin/assets ───────────────────────────────────────────────
|
||||
//
|
||||
// onProgress?: ({ phase: 'uploading'|'storing'|'done'|'error', pct }) => void
|
||||
// Two honest, sequential phases — not one blended/estimated number:
|
||||
// "uploading" — browser -> this backend, real bytes sent (axios onUploadProgress).
|
||||
// "storing" — this backend -> Garage, the actual S3 PUT, streamed live over
|
||||
// SSE (see streamUploadProgress above / uploadProgress.service.js
|
||||
// on the backend). Real numbers from the real transfer, both legs.
|
||||
const uploadAsset = useCallback(
|
||||
({ file, thumbnail, ...rest }) =>
|
||||
({ file, thumbnail, onProgress, ...rest }) =>
|
||||
request(async () => {
|
||||
const uploadId = nanoid();
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (thumbnail) form.append("thumbnail", thumbnail);
|
||||
form.append("uploadId", uploadId);
|
||||
Object.entries(rest).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) form.append(k, v);
|
||||
});
|
||||
|
||||
const res = await api.post("/admin/assets", form);
|
||||
const asset = res.data?.data?.data ?? null;
|
||||
if (asset) {
|
||||
setAssets((prev) => [asset, ...prev]);
|
||||
invalidateListCache();
|
||||
toast("Asset uploaded successfully.");
|
||||
const stopStream = onProgress
|
||||
? streamUploadProgress(uploadId, accessTokenRef.current, onProgress)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const res = await api.post("/admin/assets", form, {
|
||||
onUploadProgress: onProgress
|
||||
? (evt) => {
|
||||
const pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0;
|
||||
onProgress({ phase: "uploading", pct });
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const asset = res.data?.data?.data ?? null;
|
||||
if (asset) {
|
||||
setAssets((prev) => [asset, ...prev]);
|
||||
invalidateListCache();
|
||||
toast("Asset uploaded successfully.");
|
||||
}
|
||||
onProgress?.({ phase: "done", pct: 100 });
|
||||
return res.data;
|
||||
} finally {
|
||||
stopStream?.();
|
||||
}
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
[request, accessTokenRef]
|
||||
);
|
||||
|
||||
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// hooks/useAssetFetchState.js
|
||||
//
|
||||
// Fetches a single asset by id and reports a render-safe status for the
|
||||
// admin ViewXAsset pages. Guards the ~1s window where AssetsContext's
|
||||
// shared selectedAsset still holds the *previous* asset (from an earlier
|
||||
// /view/*/:assetId navigation) while the new fetch is in flight — without
|
||||
// this, the stale asset (and its already-resolved preview image/token)
|
||||
// briefly renders under the new URL before the real data arrives.
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
|
||||
export function useAssetFetchState(assetId) {
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [fetchedAssetId, setFetchedAssetId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetId) return;
|
||||
let active = true;
|
||||
fetchAsset(assetId).finally(() => {
|
||||
if (active) setFetchedAssetId(assetId);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [assetId, fetchAsset]);
|
||||
|
||||
const isCurrent = !!selectedAsset && String(selectedAsset.asset_id) === String(assetId);
|
||||
const hasAttemptedCurrent = fetchedAssetId === assetId;
|
||||
|
||||
return {
|
||||
asset: isCurrent ? selectedAsset : null,
|
||||
loading: loading || (!isCurrent && !hasAttemptedCurrent),
|
||||
notFound: !loading && hasAttemptedCurrent && !isCurrent,
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,15 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
|
||||
// re-fire the effect (and its setState calls) on every render, looping
|
||||
// forever since each firing produces a new src ("" vs null) that never
|
||||
// stabilizes.
|
||||
const { asset_id, storage_provider, file_url, thumbnail_url } = asset ?? {};
|
||||
//
|
||||
// stream_token must be carried through: getAsset()/getAssets() already
|
||||
// embed a valid token on S3-backed rows (attachStreamTokens), and
|
||||
// resolveAssetSrc() checks it first. Dropping it here forced every
|
||||
// preview — on every View/Edit asset page — through the async
|
||||
// fetchAssetPreviewSrc() mint round-trip even when a usable token was
|
||||
// already sitting on the asset, which is most of the visible ~1s delay
|
||||
// before the real image/video appears.
|
||||
const { asset_id, storage_provider, file_url, thumbnail_url, stream_token } = asset ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
setSrc(null);
|
||||
@@ -30,7 +38,7 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
|
||||
|
||||
if (!asset_id && !file_url && !thumbnail_url) return;
|
||||
|
||||
const currentAsset = { asset_id, storage_provider, file_url, thumbnail_url };
|
||||
const currentAsset = { asset_id, storage_provider, file_url, thumbnail_url, stream_token };
|
||||
|
||||
const fastSrc = resolveAssetSrc(currentAsset);
|
||||
if (fastSrc) {
|
||||
@@ -50,7 +58,7 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [asset_id, storage_provider, file_url, thumbnail_url, scope]);
|
||||
}, [asset_id, storage_provider, file_url, thumbnail_url, stream_token, scope]);
|
||||
|
||||
return { src, thumbnailUrl, loading };
|
||||
}
|
||||
|
||||
@@ -110,23 +110,14 @@ export default function UsersTable() {
|
||||
fetchUsers({ page: 1, limit: pagination.limit });
|
||||
};
|
||||
|
||||
// ─── Attach filterId + filterValue to each stat so TableDashboard
|
||||
// knows which column/value to apply when clicked ──────────────────────
|
||||
const dashboardStats = (usersDashboard?.stats ?? []).map((s) => ({ // ← was dashboard?.users?.stats
|
||||
// ─── Attach filterId/filterValue (or onClick) to each stat ────────────────
|
||||
const statsWithFilter = (usersDashboard?.stats ?? []).map((s) => ({
|
||||
...s,
|
||||
filterId: s.key === "archived" || s.key === "total" ? null : "is_active",
|
||||
filterValue: s.key === "active" ? ["true"]
|
||||
: s.key === "inactive" ? ["false"]
|
||||
: null,
|
||||
filterId: s.key === "active" ? "is_active" : null,
|
||||
filterValue: s.key === "active" ? ["true"] : null,
|
||||
onClick: s.key === "inactive" ? () => navigate("/admin/users/archived") : undefined,
|
||||
}));
|
||||
|
||||
// Override verified → correct column
|
||||
const statsWithFilter = dashboardStats.map((s) =>
|
||||
s.key === "verified"
|
||||
? { ...s, filterId: "is_verified", filterValue: ["true"] }
|
||||
: s
|
||||
);
|
||||
|
||||
// ─── Attach filterId to each breakdown so clicking a slice filters ────────
|
||||
const dashboardBreakdowns = (usersDashboard?.breakdowns ?? []).map((b) => ({
|
||||
...b,
|
||||
|
||||
@@ -35,9 +35,15 @@ const cellOverrides = {
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
const dataColumns = buildColumns(visibleAttributes, { cellOverrides }).map((col) =>
|
||||
// "groups" is a joined association, not a real orderable column on the
|
||||
// Users table — sorting by it would error server-side.
|
||||
col.id === "groups" ? { ...col, enableSorting: false } : col
|
||||
);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
...dataColumns,
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -122,6 +123,7 @@ export default function AddAsset() {
|
||||
const fileRef = useRef(null);
|
||||
const thumbnailRef = useRef(null);
|
||||
const [thumbKey, setThumbKey] = useState(0);
|
||||
const [progress, setProgress] = useState(null); // { phase: 'uploading'|'storing'|'done'|'error', pct } | null
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -182,6 +184,7 @@ export default function AddAsset() {
|
||||
|
||||
if (hasFileError) return;
|
||||
|
||||
setProgress({ phase: "uploading", pct: 0 });
|
||||
const result = await uploadAsset({
|
||||
file: fileRef.current,
|
||||
thumbnail: thumbnailRef.current ?? undefined,
|
||||
@@ -191,11 +194,20 @@ export default function AddAsset() {
|
||||
is_public: data.is_public === "true",
|
||||
storage_provider: data.storage_provider,
|
||||
createdBy: user?.user_id,
|
||||
onProgress: setProgress,
|
||||
});
|
||||
setProgress(null);
|
||||
|
||||
if (result) { bypassOnce(); navigate(-1); }
|
||||
};
|
||||
|
||||
const progressLabel = {
|
||||
uploading: "Uploading to server…",
|
||||
storing: "Storing to server...",
|
||||
done: "Done.",
|
||||
error: "Upload failed.",
|
||||
}[progress?.phase];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||
|
||||
@@ -352,6 +364,17 @@ export default function AddAsset() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Upload progress (real — see AdminAssetsContext.uploadAsset) ── */}
|
||||
{progress && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{progressLabel}</span>
|
||||
<span>{progress.pct ?? 0}%</span>
|
||||
</div>
|
||||
<Progress value={progress.pct ?? 0} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, Music2, Download } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { downloadAsset } from "@/utils/media.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -34,18 +33,14 @@ export default function ViewAudioAsset() {
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||
const { src: streamUrl, thumbnailUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) fetchAsset(assetId);
|
||||
}, [assetId]);
|
||||
|
||||
if (loading) {
|
||||
return <AssetPageLoader />;
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||
<p>Asset not found.</p>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, FileText, Download } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { downloadAsset } from "@/utils/media.util";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -32,18 +31,14 @@ export default function ViewDocumentAsset() {
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) fetchAsset(assetId);
|
||||
}, [assetId]);
|
||||
|
||||
if (loading) {
|
||||
return <AssetPageLoader />;
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||
<p>Asset not found.</p>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// modules/admin/pages/assets/ViewImageAsset.jsx
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { downloadAsset } from "@/utils/media.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -30,18 +29,14 @@ export default function ViewImageAsset() {
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) fetchAsset(assetId);
|
||||
}, [assetId]);
|
||||
|
||||
if (loading) {
|
||||
return <AssetPageLoader />;
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||
<p>Asset not found.</p>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { downloadAsset } from "@/utils/media.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -39,18 +38,14 @@ export default function ViewVideoAsset() {
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) fetchAsset(assetId);
|
||||
}, [assetId]);
|
||||
|
||||
if (loading) {
|
||||
return <AssetPageLoader />;
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||
<p>Asset not found.</p>
|
||||
|
||||
@@ -391,7 +391,7 @@ export default function AddStaffUserPage() {
|
||||
};
|
||||
|
||||
const res = await addStaffUser(payload);
|
||||
if (res) { bypassOnce(); navigate("/admin/users/all"); }
|
||||
if (res) { bypassOnce(); navigate("/admin/users"); }
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { House, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight, ShieldBan, ShieldCheck } from "lucide-react";
|
||||
import { House, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight, ShieldBan, ShieldCheck, Pencil } from "lucide-react";
|
||||
import { ROLE_CONFIG } from "@/data/profile.data";
|
||||
import { BADGE_STYLES } from "@/utils/table.util";
|
||||
import { getActionBadge } from "@/data/activity.data";
|
||||
@@ -107,6 +107,13 @@ export default function ViewUser() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/users/edit/${userId}`)}
|
||||
>
|
||||
<Pencil className="size-4 mr-1.5" /> Edit Info
|
||||
</Button>
|
||||
{user.is_banned ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -12,6 +12,7 @@ import ProfilePage from '@/components/generic/Profile'
|
||||
import UserList from '../pages/users/UserList'
|
||||
import AddUser from '../pages/users/AddStaffUser'
|
||||
import ViewUser from '../pages/users/ViewUser'
|
||||
import EditUser from '../pages/users/EditUser'
|
||||
|
||||
// User Groups
|
||||
import GroupList from '../pages/user_groups/GroupList'
|
||||
@@ -149,6 +150,7 @@ export const AdminRoutes = {
|
||||
{ index: true, element: <UserList /> },
|
||||
{ path: 'add/staff', element: <AddUser /> },
|
||||
{ path: 'view/:userId', element: <ViewUser /> },
|
||||
{ path: 'edit/:id', element: <EditUser /> },
|
||||
{ path: 'archived', element: <ArchivedUserList /> },
|
||||
{ path: ':userId/activity', element: <UserActivityPage /> },
|
||||
]
|
||||
|
||||
+22
-3
@@ -3,15 +3,34 @@ import * as XLSX from "xlsx";
|
||||
|
||||
const SKIP_IDS = new Set(["select", "actions"]);
|
||||
|
||||
// ─── Flatten a value into something a spreadsheet cell can display ────────────
|
||||
// Array-of-object columns (e.g. `groups`: [{group_id, name, group_code}]) would
|
||||
// otherwise hit Array.prototype.toString → "[object Object]" per item.
|
||||
function flattenForExport(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (item && typeof item === "object")
|
||||
? (item.name ?? item.label ?? item.group_code ?? JSON.stringify(item))
|
||||
: item)
|
||||
.join(", ");
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return value.name ?? value.label ?? JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── Resolve dot-notation or direct key from a row ────────────────────────────
|
||||
function resolveValue(row, key) {
|
||||
if (!key) return "";
|
||||
|
||||
// ─── Try direct key first e.g. "email", "acc_type" ────────────────────────
|
||||
if (key in row) return row[key] ?? "";
|
||||
const raw = key in row
|
||||
? row[key]
|
||||
// ─── Dot-notation fallback e.g. "personal_info.name.full_name" ──────────
|
||||
: key.split(".").reduce((acc, part) => acc?.[part] ?? "", row);
|
||||
|
||||
// ─── Dot-notation fallback e.g. "personal_info.name.full_name" ────────────
|
||||
return key.split(".").reduce((acc, part) => acc?.[part] ?? "", row) ?? "";
|
||||
return flattenForExport(raw) ?? "";
|
||||
}
|
||||
|
||||
// ─── Flatten a nested row based on attributes ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user