add key()

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-13 12:47:04 +08:00
parent e9b99f8243
commit bfc4b9fc67
15 changed files with 221 additions and 76 deletions
@@ -40,6 +40,7 @@ export function ColumnActionsDropdown({
const column = header.column; const column = header.column;
const sorted = column.getIsSorted(); const sorted = column.getIsSorted();
const canFilter = showFilter ?? !!attr; const canFilter = showFilter ?? !!attr;
const canSort = column.getCanSort();
return ( return (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}> <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
@@ -63,6 +64,8 @@ export function ColumnActionsDropdown({
<DropdownMenuLabel className="text-xs">Column Actions</DropdownMenuLabel> <DropdownMenuLabel className="text-xs">Column Actions</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{canSort && (
<>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}> <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp className="mr-2 size-4" /> <ArrowUp className="mr-2 size-4" />
Sort Asc Sort Asc
@@ -77,6 +80,8 @@ export function ColumnActionsDropdown({
<X className="mr-2 size-4" /> <X className="mr-2 size-4" />
Clear Sort Clear Sort
</DropdownMenuItem> </DropdownMenuItem>
</>
)}
{canFilter && ( {canFilter && (
<> <>
+74 -3
View File
@@ -1,7 +1,52 @@
import { createContext, useCallback, useContext, useRef, useState } from "react"; import { createContext, useCallback, useContext, useRef, useState } from "react";
import { nanoid } from "nanoid";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useAuth } from "@/contexts/AuthContext";
import { toast } from "sonner"; 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); const AssetsContext = createContext(null);
export function useAssets() { export function useAssets() {
@@ -31,6 +76,7 @@ const cacheKeyFor = (scope, { page, limit, filters, sort }) =>
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`; `${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
export function AssetsProvider({ children }) { export function AssetsProvider({ children }) {
const { accessTokenRef } = useAuth();
const [assets, setAssets] = useState([]); const [assets, setAssets] = useState([]);
const [attributes, setAttributes] = useState([]); const [attributes, setAttributes] = useState([]);
const [pagination, setPagination] = useState(PAGINATION_INIT); const [pagination, setPagination] = useState(PAGINATION_INIT);
@@ -186,26 +232,51 @@ export function AssetsProvider({ children }) {
); );
// ─── POST /api/admin/assets ─────────────────────────────────────────────── // ─── 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( const uploadAsset = useCallback(
({ file, thumbnail, ...rest }) => ({ file, thumbnail, onProgress, ...rest }) =>
request(async () => { request(async () => {
const uploadId = nanoid();
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
if (thumbnail) form.append("thumbnail", thumbnail); if (thumbnail) form.append("thumbnail", thumbnail);
form.append("uploadId", uploadId);
Object.entries(rest).forEach(([k, v]) => { Object.entries(rest).forEach(([k, v]) => {
if (v !== undefined && v !== null) form.append(k, v); if (v !== undefined && v !== null) form.append(k, v);
}); });
const res = await api.post("/admin/assets", form); 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; const asset = res.data?.data?.data ?? null;
if (asset) { if (asset) {
setAssets((prev) => [asset, ...prev]); setAssets((prev) => [asset, ...prev]);
invalidateListCache(); invalidateListCache();
toast("Asset uploaded successfully."); toast("Asset uploaded successfully.");
} }
onProgress?.({ phase: "done", pct: 100 });
return res.data; return res.data;
} finally {
stopStream?.();
}
}), }),
[request] [request, accessTokenRef]
); );
// ─── PATCH /api/admin/assets/:assetId ──────────────────────────────────── // ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
+33
View File
@@ -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,
};
}
+11 -3
View File
@@ -22,7 +22,15 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
// re-fire the effect (and its setState calls) on every render, looping // re-fire the effect (and its setState calls) on every render, looping
// forever since each firing produces a new src ("" vs null) that never // forever since each firing produces a new src ("" vs null) that never
// stabilizes. // 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(() => { useEffect(() => {
setSrc(null); setSrc(null);
@@ -30,7 +38,7 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
if (!asset_id && !file_url && !thumbnail_url) return; 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); const fastSrc = resolveAssetSrc(currentAsset);
if (fastSrc) { if (fastSrc) {
@@ -50,7 +58,7 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
}); });
return () => { cancelled = true; }; 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 }; return { src, thumbnailUrl, loading };
} }
@@ -110,23 +110,14 @@ export default function UsersTable() {
fetchUsers({ page: 1, limit: pagination.limit }); fetchUsers({ page: 1, limit: pagination.limit });
}; };
// ─── Attach filterId + filterValue to each stat so TableDashboard // ─── Attach filterId/filterValue (or onClick) to each stat ────────────────
// knows which column/value to apply when clicked ────────────────────── const statsWithFilter = (usersDashboard?.stats ?? []).map((s) => ({
const dashboardStats = (usersDashboard?.stats ?? []).map((s) => ({ // ← was dashboard?.users?.stats
...s, ...s,
filterId: s.key === "archived" || s.key === "total" ? null : "is_active", filterId: s.key === "active" ? "is_active" : null,
filterValue: s.key === "active" ? ["true"] filterValue: s.key === "active" ? ["true"] : null,
: s.key === "inactive" ? ["false"] onClick: s.key === "inactive" ? () => navigate("/admin/users/archived") : undefined,
: null,
})); }));
// 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 ──────── // ─── Attach filterId to each breakdown so clicking a slice filters ────────
const dashboardBreakdowns = (usersDashboard?.breakdowns ?? []).map((b) => ({ const dashboardBreakdowns = (usersDashboard?.breakdowns ?? []).map((b) => ({
...b, ...b,
@@ -35,9 +35,15 @@ const cellOverrides = {
export function buildDataColumns(attributes, rowActions) { export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden); 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 [ return [
buildSelectionColumn(), buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }), ...dataColumns,
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }), buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
]; ];
} }
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Progress } from "@/components/ui/progress";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -122,6 +123,7 @@ export default function AddAsset() {
const fileRef = useRef(null); const fileRef = useRef(null);
const thumbnailRef = useRef(null); const thumbnailRef = useRef(null);
const [thumbKey, setThumbKey] = useState(0); const [thumbKey, setThumbKey] = useState(0);
const [progress, setProgress] = useState(null); // { phase: 'uploading'|'storing'|'done'|'error', pct } | null
const { const {
register, register,
@@ -182,6 +184,7 @@ export default function AddAsset() {
if (hasFileError) return; if (hasFileError) return;
setProgress({ phase: "uploading", pct: 0 });
const result = await uploadAsset({ const result = await uploadAsset({
file: fileRef.current, file: fileRef.current,
thumbnail: thumbnailRef.current ?? undefined, thumbnail: thumbnailRef.current ?? undefined,
@@ -191,11 +194,20 @@ export default function AddAsset() {
is_public: data.is_public === "true", is_public: data.is_public === "true",
storage_provider: data.storage_provider, storage_provider: data.storage_provider,
createdBy: user?.user_id, createdBy: user?.user_id,
onProgress: setProgress,
}); });
setProgress(null);
if (result) { bypassOnce(); navigate(-1); } if (result) { bypassOnce(); navigate(-1); }
}; };
const progressLabel = {
uploading: "Uploading to server…",
storing: "Storing to server...",
done: "Done.",
error: "Upload failed.",
}[progress?.phase];
return ( return (
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6"> <div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
@@ -352,6 +364,17 @@ export default function AddAsset() {
</div> </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 ── */} {/* ── Actions ── */}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button <Button
@@ -1,11 +1,10 @@
// modules/admin/pages/assets/ViewAudioAsset.jsx // modules/admin/pages/assets/ViewAudioAsset.jsx
import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Music2, Download } from "lucide-react"; import { ArrowLeft, Lock, Globe, Music2, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc"; import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util"; import { downloadAsset } from "@/utils/media.util";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -34,18 +33,14 @@ export default function ViewAudioAsset() {
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat(); const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets(); const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl, thumbnailUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" }); const { src: streamUrl, thumbnailUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
if (loading) { if (loading) {
return <AssetPageLoader />; return <AssetPageLoader />;
} }
if (!selectedAsset) { if (notFound) {
return ( return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p> <p>Asset not found.</p>
@@ -1,11 +1,10 @@
// modules/admin/pages/assets/ViewDocumentAsset.jsx // modules/admin/pages/assets/ViewDocumentAsset.jsx
import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, FileText, Download } from "lucide-react"; import { ArrowLeft, Lock, Globe, FileText, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc"; import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util"; import { downloadAsset } from "@/utils/media.util";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -32,18 +31,14 @@ export default function ViewDocumentAsset() {
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat(); const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets(); const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" }); const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
if (loading) { if (loading) {
return <AssetPageLoader />; return <AssetPageLoader />;
} }
if (!selectedAsset) { if (notFound) {
return ( return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p> <p>Asset not found.</p>
@@ -1,11 +1,10 @@
// modules/admin/pages/assets/ViewImageAsset.jsx // modules/admin/pages/assets/ViewImageAsset.jsx
import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Download } from "lucide-react"; import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc"; import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util"; import { downloadAsset } from "@/utils/media.util";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -30,18 +29,14 @@ export default function ViewImageAsset() {
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat(); const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets(); const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" }); const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
if (loading) { if (loading) {
return <AssetPageLoader />; return <AssetPageLoader />;
} }
if (!selectedAsset) { if (notFound) {
return ( return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p> <p>Asset not found.</p>
@@ -1,11 +1,10 @@
// modules/admin/pages/assets/ViewVideoAsset.jsx // modules/admin/pages/assets/ViewVideoAsset.jsx
import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Download } from "lucide-react"; import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc"; import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util"; import { downloadAsset } from "@/utils/media.util";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -39,18 +38,14 @@ export default function ViewVideoAsset() {
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat(); const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets(); const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" }); const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
if (loading) { if (loading) {
return <AssetPageLoader />; return <AssetPageLoader />;
} }
if (!selectedAsset) { if (notFound) {
return ( return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p> <p>Asset not found.</p>
@@ -391,7 +391,7 @@ export default function AddStaffUserPage() {
}; };
const res = await addStaffUser(payload); const res = await addStaffUser(payload);
if (res) { bypassOnce(); navigate("/admin/users/all"); } if (res) { bypassOnce(); navigate("/admin/users"); }
}); });
return ( return (
+8 -1
View File
@@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Spinner } from "@/components/ui/spinner"; 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 { ROLE_CONFIG } from "@/data/profile.data";
import { BADGE_STYLES } from "@/utils/table.util"; import { BADGE_STYLES } from "@/utils/table.util";
import { getActionBadge } from "@/data/activity.data"; import { getActionBadge } from "@/data/activity.data";
@@ -107,6 +107,13 @@ export default function ViewUser() {
</div> </div>
</div> </div>
<div className="flex gap-2"> <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 ? ( {user.is_banned ? (
<Button <Button
size="sm" size="sm"
+2
View File
@@ -12,6 +12,7 @@ import ProfilePage from '@/components/generic/Profile'
import UserList from '../pages/users/UserList' import UserList from '../pages/users/UserList'
import AddUser from '../pages/users/AddStaffUser' import AddUser from '../pages/users/AddStaffUser'
import ViewUser from '../pages/users/ViewUser' import ViewUser from '../pages/users/ViewUser'
import EditUser from '../pages/users/EditUser'
// User Groups // User Groups
import GroupList from '../pages/user_groups/GroupList' import GroupList from '../pages/user_groups/GroupList'
@@ -149,6 +150,7 @@ export const AdminRoutes = {
{ index: true, element: <UserList /> }, { index: true, element: <UserList /> },
{ path: 'add/staff', element: <AddUser /> }, { path: 'add/staff', element: <AddUser /> },
{ path: 'view/:userId', element: <ViewUser /> }, { path: 'view/:userId', element: <ViewUser /> },
{ path: 'edit/:id', element: <EditUser /> },
{ path: 'archived', element: <ArchivedUserList /> }, { path: 'archived', element: <ArchivedUserList /> },
{ path: ':userId/activity', element: <UserActivityPage /> }, { path: ':userId/activity', element: <UserActivityPage /> },
] ]
+22 -3
View File
@@ -3,15 +3,34 @@ import * as XLSX from "xlsx";
const SKIP_IDS = new Set(["select", "actions"]); 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 ──────────────────────────── // ─── Resolve dot-notation or direct key from a row ────────────────────────────
function resolveValue(row, key) { function resolveValue(row, key) {
if (!key) return ""; if (!key) return "";
// ─── Try direct key first e.g. "email", "acc_type" ──────────────────────── // ─── 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 flattenForExport(raw) ?? "";
return key.split(".").reduce((acc, part) => acc?.[part] ?? "", row) ?? "";
} }
// ─── Flatten a nested row based on attributes ───────────────────────────────── // ─── Flatten a nested row based on attributes ─────────────────────────────────