From dcf5f43fd5d7a64f2333a701c2c7f91725e8fc11 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Thu, 14 May 2026 12:59:31 +0800 Subject: [PATCH] Adjusted --- src/components/generic/DashboardGrid.jsx | 2 +- src/contexts/AdminAssetsContext.jsx | 169 ++++++--- src/contexts/AdminUserContext.jsx | 145 +++----- src/contexts/AdminUserGroupContext.jsx | 226 +++++------ src/contexts/provider/AdminProvider.jsx | 13 +- src/data/adminTiles.data.js | 43 +-- .../components/assets/ArchivedAssetsTable.jsx | 137 +++++++ .../admin/components/assets/AssetsTable.jsx | 143 +++++++ .../config/assets/archive/columns.config.jsx | 45 +++ .../assets/archive/rowActions.config.jsx | 21 ++ .../assets/archive/selection.config.jsx | 31 ++ .../config/assets/archive/toolbar.config.jsx | 42 +++ .../admin/config/assets/columns.config.jsx | 45 +++ .../admin/config/assets/rowActions.config.jsx | 34 ++ .../admin/config/assets/selection.config.jsx | 38 ++ .../admin/config/assets/toolbar.config.jsx | 60 +++ .../config/user_groups/toolbar.config.jsx | 2 +- .../admin/config/users/toolbar.config.jsx | 2 +- src/modules/admin/layouts/AdminLayout.jsx | 4 - ...agementLayout.jsx => NavigationLayout.jsx} | 4 +- src/modules/admin/pages/AdminDashboard.jsx | 55 ++- src/modules/admin/pages/assets/AddAsset.jsx | 351 ++++++++++++++++++ .../admin/pages/assets/ArchivedAssets.jsx | 26 ++ src/modules/admin/pages/assets/AssetList.jsx | 25 ++ src/modules/admin/pages/assets/EditAsset.jsx | 345 +++++++++++++++++ .../admin/pages/assets/ViewDocumentAsset.jsx | 133 +++++++ .../admin/pages/assets/ViewImageAsset.jsx | 125 +++++++ .../admin/pages/assets/ViewVideoAsset.jsx | 161 ++++++++ .../pages/user_groups/ArchivedGroupList.jsx | 4 +- .../admin/pages/user_groups/GroupList.jsx | 2 +- .../admin/pages/user_groups/ViewGroup.jsx | 4 +- .../admin/pages/users/ArchivedUserList.jsx | 4 +- src/modules/admin/pages/users/EditUser.jsx | 2 +- .../admin/pages/users/UserDashboard.jsx | 6 - src/modules/admin/pages/users/UserList.jsx | 2 +- src/modules/admin/pages/users/ViewUser.jsx | 2 +- src/modules/admin/routes/AdminRoutes.jsx | 70 ++-- src/utils/table.util.jsx | 33 +- 38 files changed, 2174 insertions(+), 382 deletions(-) create mode 100644 src/modules/admin/components/assets/ArchivedAssetsTable.jsx create mode 100644 src/modules/admin/components/assets/AssetsTable.jsx create mode 100644 src/modules/admin/config/assets/archive/columns.config.jsx create mode 100644 src/modules/admin/config/assets/archive/rowActions.config.jsx create mode 100644 src/modules/admin/config/assets/archive/selection.config.jsx create mode 100644 src/modules/admin/config/assets/archive/toolbar.config.jsx create mode 100644 src/modules/admin/config/assets/columns.config.jsx create mode 100644 src/modules/admin/config/assets/rowActions.config.jsx create mode 100644 src/modules/admin/config/assets/selection.config.jsx create mode 100644 src/modules/admin/config/assets/toolbar.config.jsx rename src/modules/admin/layouts/{UserManagementLayout.jsx => NavigationLayout.jsx} (63%) create mode 100644 src/modules/admin/pages/assets/AddAsset.jsx create mode 100644 src/modules/admin/pages/assets/ArchivedAssets.jsx create mode 100644 src/modules/admin/pages/assets/AssetList.jsx create mode 100644 src/modules/admin/pages/assets/EditAsset.jsx create mode 100644 src/modules/admin/pages/assets/ViewDocumentAsset.jsx create mode 100644 src/modules/admin/pages/assets/ViewImageAsset.jsx create mode 100644 src/modules/admin/pages/assets/ViewVideoAsset.jsx delete mode 100644 src/modules/admin/pages/users/UserDashboard.jsx diff --git a/src/components/generic/DashboardGrid.jsx b/src/components/generic/DashboardGrid.jsx index 620f36a..1071b00 100644 --- a/src/components/generic/DashboardGrid.jsx +++ b/src/components/generic/DashboardGrid.jsx @@ -11,7 +11,7 @@ export default function DashboardGrid({ sections = [] }) { } return ( -
+
{sections.map(({ title, description, tiles }) => (
diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx index c626b96..f058a63 100644 --- a/src/contexts/AdminAssetsContext.jsx +++ b/src/contexts/AdminAssetsContext.jsx @@ -10,11 +10,22 @@ export function useAssets() { return ctx; } +// ─── Initial States ──────────────────────────────────────────────────────────── +const PAGINATION_INIT = { + page: 1, + limit: 10, + totalRecords: 0, + totalPages: 0, + hasPrevPage: false, + hasNextPage: false, +}; + export function AssetsProvider({ children }) { - const [assets, setAssets] = useState([]); - const [pagination, setPagination] = useState(null); + const [assets, setAssets] = useState([]); + const [attributes, setAttributes] = useState([]); + const [pagination, setPagination] = useState(PAGINATION_INIT); const [selectedAsset, setSelectedAsset] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(false); const request = useCallback(async (fn) => { setLoading(true); @@ -31,12 +42,23 @@ export function AssetsProvider({ children }) { // ─── GET /api/admin/assets ──────────────────────────────────────────────── const fetchAssets = useCallback( - (params = {}) => + ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => request(async () => { - const res = await api.get("/admin/assets", { params }); - setAssets(res.data?.result?.data ?? []); - setPagination(res.data?.result?.pagination ?? null); - return res.data; + const { data } = await api.get("/admin/assets", { + params: { + page, limit, + filters: filters.length ? JSON.stringify(filters) : undefined, + sort: sort.length ? JSON.stringify(sort) : undefined, + }, + }); + + const result = data?.data; + + setAssets(result?.data ?? []); + setPagination(result?.pagination ?? PAGINATION_INIT); + setAttributes(result.attributes); + + return data.data; }), [request] ); @@ -46,16 +68,34 @@ export function AssetsProvider({ children }) { (assetId) => request(async () => { const res = await api.get(`/admin/assets/${assetId}`); - setSelectedAsset(res.data?.result?.data ?? null); + setSelectedAsset(res.data?.data?.data ?? null); return res.data; }), [request] ); + // ─── GET /api/admin/assets/archived ─────────────────────────────────────── + const fetchArchivedAssets = useCallback( + ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => + request(async () => { + const { data } = await api.get("/admin/assets/archived", { + params: { + page, limit, + filters: filters.length ? JSON.stringify(filters) : undefined, + sort: sort.length ? JSON.stringify(sort) : undefined, + }, + }); + const final_data = data?.data; + + setAssets(final_data?.data ?? []); + setPagination(final_data?.pagination ?? PAGINATION_INIT); + setAttributes(final_data.attributes); + return data; + }), + [request] + ); + // ─── POST /api/admin/assets ─────────────────────────────────────────────── - // fields: { file (File), thumbnail? (File), uploadedBy, display_name, - // description, owner_type, owner_id, is_public, access_level, - // storage_provider, storage_bucket, storage_key } const uploadAsset = useCallback( ({ file, thumbnail, ...rest }) => request(async () => { @@ -66,56 +106,44 @@ export function AssetsProvider({ children }) { if (v !== undefined && v !== null) form.append(k, v); }); - const res = await api.post("/admin/assets", form, { - headers: { "Content-Type": "multipart/form-data" }, - }); - const asset = res.data?.result?.data ?? null; - if (asset) setAssets((prev) => [asset, ...prev]); - return res.data; - }), - [request] - ); - - // ─── PATCH /api/admin/assets/:assetId/thumbnail ─────────────────────────── - const updateThumbnail = useCallback( - (assetId, thumbnailFile) => - request(async () => { - const form = new FormData(); - form.append("thumbnail", thumbnailFile); - - const res = await api.patch(`/admin/assets/${assetId}/thumbnail`, form, { - headers: { "Content-Type": "multipart/form-data" }, - }); - const asset = res.data?.result?.data ?? null; + const res = await api.post("/admin/assets", form); + const asset = res.data?.data?.data ?? null; if (asset) { - setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a))); - setSelectedAsset((prev) => (prev?.asset_id === assetId ? asset : prev)); + setAssets((prev) => [asset, ...prev]); + toast.success("Asset uploaded successfully."); } return res.data; }), [request] ); - // ─── PUT /api/admin/assets/:assetId ─────────────────────────────────────── - // Allowed: display_name, description, owner_type, owner_id, is_public, - // access_level, thumbnail_url, width, height, duration, - // frame_rate, bitrate, video_codec, audio_codec + // ─── PATCH /api/admin/assets/:assetId ──────────────────────────────────── const updateAsset = useCallback( - (assetId, fields) => + (assetId, fields, file = null) => request(async () => { - const res = await api.put(`/admin/assets/${assetId}`, fields); - const asset = res.data?.result?.data ?? null; + const formData = new FormData(); + Object.entries(fields).forEach(([key, value]) => { + if (value !== undefined && value !== null) formData.append(key, value); + }); + if (file) formData.append("file", file); + + const res = await api.patch(`/admin/assets/${assetId}`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + + const asset = res.data?.data?.data ?? null; if (asset) { setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a))); - setSelectedAsset((prev) => (prev?.asset_id === assetId ? asset : prev)); + setSelectedAsset(asset); + toast.success("Asset updated successfully."); } return res.data; }), [request] ); - // ─── DELETE /api/admin/assets/:assetId ──────────────────────────────────── - const deleteAsset = useCallback( + // ─── DELETE /api/admin/assets/:assetId ─────────────────────────────────── + const archiveAsset = useCallback( (assetId, { deletedBy } = {}) => request(async () => { const res = await api.delete(`/admin/assets/${assetId}`, { @@ -123,51 +151,82 @@ export function AssetsProvider({ children }) { }); setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev)); + toast.success("Asset archived."); return res.data; }), [request] ); - // ─── DELETE /api/admin/assets (bulk) ────────────────────────────────────── - const deleteAssets = useCallback( - (ids = [], { deletedBy } = {}) => + // ─── DELETE /api/admin/assets/bulk ─────────────────────────────────────── + const archiveAssets = useCallback( + ({ ids }, { deletedBy } = {}) => request(async () => { - const res = await api.delete("/admin/assets", { + const res = await api.delete("/admin/assets/bulk", { data: { ids, deletedBy }, }); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); + toast.success(`${ids.length} asset(s) archived.`); return res.data; }), [request] ); - // ─── PATCH /api/admin/assets/:assetId/restore ───────────────────────────── + // ─── PATCH /api/admin/assets/:assetId/restore ──────────────────────────── const restoreAsset = useCallback( (assetId) => request(async () => { const res = await api.patch(`/admin/assets/${assetId}/restore`); - const asset = res.data?.result?.data ?? null; - if (asset) setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a))); + const asset = res.data?.data?.data ?? null; + if (asset) { + setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); + toast.success("Asset restored."); + } return res.data; }), [request] ); + // ─── PATCH /api/admin/assets/bulk-restore ──────────────────────────────── + const restoreAssets = useCallback( + ({ ids }) => + request(async () => { + const res = await api.patch("/admin/assets/bulk-restore", { ids }); + setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); + toast.success(`${ids.length} asset(s) restored.`); + return res.data; + }), + [request] + ); + + // ─── GET /api/admin/assets/field-values ────────────────────────────────────── + const fetchAssetFieldValues = useCallback( + (field) => + request(async () => { + const res = await api.get("/admin/assets/field-values", { params: { field } }); + return res.data?.data; + }), + [request] + ); + return ( {children} diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx index c17d648..be9087c 100644 --- a/src/contexts/AdminUserContext.jsx +++ b/src/contexts/AdminUserContext.jsx @@ -1,6 +1,6 @@ -// ─── UserContext.jsx ─────────────────────────────────────────────────────────── import { createContext, useContext, useState, useCallback } from "react"; import api from "@/utils/api.util"; +import { toast } from "sonner"; const UserContext = createContext(null); @@ -10,7 +10,6 @@ export const useUsers = () => { return ctx; }; -// ─── Initial States ──────────────────────────────────────────────────────────── const PAGINATION_INIT = { page: 1, limit: 10, @@ -23,7 +22,6 @@ const PAGINATION_INIT = { const BASE = "/admin"; export const UserProvider = ({ children }) => { - // ─── State ───────────────────────────────────────────────────────────────── const [users, setUsers] = useState([]); const [user, setUser] = useState(null); const [sessions, setSessions] = useState([]); @@ -32,7 +30,6 @@ export const UserProvider = ({ children }) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // ─── Helpers ─────────────────────────────────────────────────────────────── const request = useCallback(async (fn) => { setLoading(true); setError(null); @@ -41,6 +38,7 @@ export const UserProvider = ({ children }) => { } catch (err) { const message = err?.response?.data?.message || err.message || "Something went wrong."; setError(message); + toast.error(message); return null; } finally { setLoading(false); @@ -53,24 +51,41 @@ export const UserProvider = ({ children }) => { request(async () => { const { data } = await api.get(`${BASE}/users`, { params: { - page, - limit, + page, limit, filters: filters.length ? JSON.stringify(filters) : undefined, - sort: sort.length ? JSON.stringify(sort) : undefined, + sort: sort.length ? JSON.stringify(sort) : undefined, }, }); - - const final_data = data?.data + const final_data = data?.data; setUsers(final_data?.data ?? []); setPagination(final_data?.pagination ?? PAGINATION_INIT); setAttributes(final_data?.attributes ?? []); - return data?.data; }), [request] ); - // ─── GET /api/admin/users/:id ────────────────────────────────────────────── + // ─── GET /api/admin/users/archived ──────────────────────────────────────── + const fetchArchivedUsers = useCallback( + ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => + request(async () => { + const { data } = await api.get(`${BASE}/users/archived`, { + params: { + page, limit, + filters: filters.length ? JSON.stringify(filters) : undefined, + sort: sort.length ? JSON.stringify(sort) : undefined, + }, + }); + const final_data = data?.data; + setUsers(final_data?.data ?? []); + setPagination(final_data?.pagination ?? PAGINATION_INIT); + setAttributes(final_data?.attributes ?? []); + return data?.data; + }), + [request] + ); + + // ─── GET /api/admin/users/:id ───────────────────────────────────────────── const fetchUser = useCallback( (userId) => request(async () => { @@ -81,88 +96,59 @@ export const UserProvider = ({ children }) => { [request] ); - // ─── POST /api/admin/users/staff ────────────────────────────────────────────── + // ─── POST /api/admin/users/staff ────────────────────────────────────────── const addStaffUser = useCallback( (payload) => request(async () => { const res = await api.post(`${BASE}/users/staff`, payload); + toast.success("Staff user added successfully."); return res.data; }), [request] ); - // ─── PUT /api/admin/users/:id ────────────────────────────────────────────── + // ─── PUT /api/admin/users/:id ───────────────────────────────────────────── const updateUser = useCallback( (userId, payload) => request(async () => { const res = await api.put(`${BASE}/users/${userId}`, payload); - - // Sync local list if user exists in it setUsers((prev) => prev.map((u) => (u.user_id === userId ? { ...u, ...res.data?.data } : u)) ); - + toast.success("User updated successfully."); return res.data; }), [request] ); - // ─── GET /api/admin/users/archived ──────────────────────────────────────────── - const fetchArchivedUsers = useCallback( - ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => - request(async () => { - const { data } = await api.get(`${BASE}/users/archived`, { - params: { - page, - limit, - filters: filters.length ? JSON.stringify(filters) : undefined, - sort: sort.length ? JSON.stringify(sort) : undefined, - }, - }); - - const final_data = data?.data - setUsers(final_data?.data ?? []); - setPagination(final_data?.pagination ?? PAGINATION_INIT); - setAttributes(final_data?.attributes ?? []); - - return res.data; - }), - [request] - ); - - // ─── DELETE /api/admin/users/:id (soft delete) ──────────────────────────── + // ─── DELETE /api/admin/users/:id ────────────────────────────────────────── 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)) ); - + toast.success("User deactivated."); return res.data; }), [request] ); - // ─── DELETE /api/admin/users/bulk ───────────────────────────────────────────── + // ─── 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 - + const { deactivated_ids } = res.data?.data ?? {}; if (deactivated_ids?.length) { setUsers((prev) => prev.map((u) => - deactivated_ids.includes(u.user_id) - ? { ...u, is_active: false } - : u + deactivated_ids.includes(u.user_id) ? { ...u, is_active: false } : u ) ); + toast.success(`${deactivated_ids.length} user(s) deactivated.`); } - return res.data; }), [request] @@ -173,33 +159,29 @@ export const UserProvider = ({ children }) => { (userId) => request(async () => { const res = await api.post(`${BASE}/users/${userId}/restore`); - setUsers((prev) => prev.map((u) => (u.user_id === userId ? { ...u, is_active: true } : u)) ); - + toast.success("User restored."); return res.data; }), [request] ); + // ─── POST /api/admin/users/bulk/restore ─────────────────────────────────── const restoreUsers = useCallback( ({ ids }) => request(async () => { const res = await api.post(`${BASE}/users/bulk/restore`, { ids }); - - const { restored_ids } = res.data?.data ?? {}; // ← same unwrap - + const { restored_ids } = res.data?.data ?? {}; if (restored_ids?.length) { setUsers((prev) => prev.map((u) => - restored_ids.includes(u.user_id) - ? { ...u, is_active: true } - : u + restored_ids.includes(u.user_id) ? { ...u, is_active: true } : u ) ); + toast.success(`${restored_ids.length} user(s) restored.`); } - return res.data; }), [request] @@ -221,17 +203,16 @@ export const UserProvider = ({ children }) => { (userId, sessionId) => request(async () => { const res = await api.delete(`${BASE}/users/${userId}/sessions/${sessionId}`); - setSessions((prev) => prev.map((s) => (s.session_id === sessionId ? { ...s, is_active: false } : s)) ); - + toast.success("Session terminated."); return res.data; }), [request] ); - // ─── GET /api/admin/users/field-values?field=acc_type ───────────────────────── + // ─── GET /api/admin/users/field-values ──────────────────────────────────── const fetchUserFieldValues = useCallback( (field) => request(async () => { @@ -241,37 +222,17 @@ export const UserProvider = ({ children }) => { [request] ); - // ─── Provider ────────────────────────────────────────────────────────────── return ( - + {children} ); diff --git a/src/contexts/AdminUserGroupContext.jsx b/src/contexts/AdminUserGroupContext.jsx index cd82e41..95dc4e0 100644 --- a/src/contexts/AdminUserGroupContext.jsx +++ b/src/contexts/AdminUserGroupContext.jsx @@ -1,19 +1,8 @@ -/*********************************************************************************************************************************************************************** - * File Name: AdminUserGroupContext.jsx - * Type of Program: Context - * Description: Admin-level user group management context. - * Covers: list groups, get single group + members, create, update, - * deactivate, restore, add/remove members. - * - * Author: rgrgogu - ***********************************************************************************************************************************************************************/ import { createContext, useCallback, useContext, useState } from "react"; import api from "@/utils/api.util"; import { toast } from "sonner"; const BASE = "/admin"; - -// ─── Context ────────────────────────────────────────────────────────────────── const UserGroupContext = createContext(null); export function useUserGroups() { @@ -22,18 +11,16 @@ export function useUserGroups() { return ctx; } -// ─── Provider ───────────────────────────────────────────────────────────────── export function UserGroupProvider({ children }) { const [groups, setGroups] = useState([]); - const [group, setGroup] = useState(null); // single group + its members - const [members, setMembers] = useState([]); // members inside current group - const [usersIn, setUsersIn] = useState([]); // users already in group (for remove) - const [usersNotIn, setUsersNotIn] = useState([]); // users not in group (for add) + const [group, setGroup] = useState(null); + const [members, setMembers] = useState([]); + const [usersIn, setUsersIn] = useState([]); + const [usersNotIn, setUsersNotIn] = useState([]); const [attributes, setAttributes] = useState([]); const [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0, totalPages: 1 }); const [loading, setLoading] = useState(false); - // ─── Generic request wrapper ──────────────────────────────────────────────── const request = useCallback(async (fn) => { setLoading(true); try { @@ -54,40 +41,49 @@ export function UserGroupProvider({ children }) { const res = await api.get(`${BASE}/groups`, { params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) }, }); - const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; - setGroups(data ?? []); setAttributes(attrs ?? []); setPagination(pg ?? { page, limit, total: 0, totalPages: 1 }); - return res.data; }), [request] ); - // ─── GET /api/admin/groups/:gid ──────────────────────────────────────────── + // ─── GET /api/admin/groups/archived ─────────────────────────────────────── + const fetchArchivedGroups = useCallback( + ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => + request(async () => { + const res = await api.get(`${BASE}/groups/archived`, { + params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) }, + }); + const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; + setGroups(data ?? []); + setAttributes(attrs ?? []); + setPagination(pg ?? { page, limit, total: 0, totalPages: 1 }); + return res.data; + }), + [request] + ); + + // ─── GET /api/admin/groups/:gid ─────────────────────────────────────────── const fetchGroup = useCallback( (gid, paginationParams = {}) => request(async () => { const { page = 1, limit = 10, filters = [], sort = [] } = paginationParams; - const res = await api.get(`${BASE}/groups/${gid}`, { params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) }, }); - const { group: g, members: m } = res.data?.data ?? {}; - setGroup(g ?? null); setMembers(m?.data ?? []); setPagination(m?.pagination ?? { page, limit, total: 0, totalPages: 1 }); - return res.data; }), [request] ); - // ─── GET /api/admin/groups/:gid/users ────────────────────────────────────── + // ─── GET /api/admin/groups/:gid/users ───────────────────────────────────── const fetchUsersInGroup = useCallback( (gid) => request(async () => { @@ -98,7 +94,7 @@ export function UserGroupProvider({ children }) { [request] ); - // ─── GET /api/admin/groups/:gid/users/add ────────────────────────────────── + // ─── GET /api/admin/groups/:gid/users/add ───────────────────────────────── const fetchUsersNotInGroup = useCallback( (gid) => request(async () => { @@ -109,80 +105,115 @@ export function UserGroupProvider({ children }) { [request] ); + // ─── GET /api/admin/groups/field-values ─────────────────────────────────── + const fetchGroupFieldValues = useCallback( + (field) => + request(async () => { + const res = await api.get(`${BASE}/groups/field-values`, { params: { field } }); + return res.data; + }), + [request] + ); + // ─── POST /api/admin/groups ──────────────────────────────────────────────── const createGroup = useCallback( ({ name, description }) => request(async () => { const res = await api.post(`${BASE}/groups`, { name, description }); - setGroups((prev) => [res.data?.data, ...prev]); - toast.success("Group created successfully."); return res.data; }), [request] ); - // ─── PUT /api/admin/groups/:gid ──────────────────────────────────────────── + // ─── PUT /api/admin/groups/:gid ─────────────────────────────────────────── const updateGroup = useCallback( (gid, { name, description }) => request(async () => { const res = await api.put(`${BASE}/groups/${gid}`, { name, description }); - setGroups((prev) => prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g)) ); - - // Update single group view if open setGroup((prev) => (prev?.group_id === gid ? { ...prev, ...res.data?.data } : prev)); - toast.success("Group updated successfully."); return res.data; }), [request] ); - // ─── PATCH /api/admin/groups/:gid/deactivate ─────────────────────────────── + // ─── PATCH /api/admin/groups/:gid/deactivate ────────────────────────────── const deactivateGroup = useCallback( (gid) => request(async () => { const res = await api.patch(`${BASE}/groups/${gid}/deactivate`); - setGroups((prev) => prev.map((g) => (g.group_id === gid ? { ...g, is_active: false } : g)) ); - toast.success("Group deactivated."); return res.data; }), [request] ); + // ─── DELETE /api/admin/groups/bulk ──────────────────────────────────────── + const deactivateGroups = useCallback( + ({ ids }) => + request(async () => { + const res = await api.delete(`${BASE}/groups/bulk`, { data: { ids } }); + const { deactivated_ids } = res.data?.data ?? {}; + if (deactivated_ids?.length) { + setGroups((prev) => + prev.map((g) => + deactivated_ids.includes(g.group_id) ? { ...g, is_active: false } : g + ) + ); + toast.success(`${deactivated_ids.length} group(s) deactivated.`); + } + return res.data; + }), + [request] + ); + // ─── PATCH /api/admin/groups/:gid/restore ───────────────────────────────── const restoreGroup = useCallback( (gid) => request(async () => { const res = await api.patch(`${BASE}/groups/${gid}/restore`); - setGroups((prev) => prev.map((g) => (g.group_id === gid ? { ...g, is_active: true } : g)) ); - toast.success("Group restored."); return res.data; }), [request] ); + // ─── POST /api/admin/groups/bulk/restore ────────────────────────────────── + const restoreGroups = useCallback( + ({ ids }) => + request(async () => { + const res = await api.post(`${BASE}/groups/bulk/restore`, { ids }); + const { restored_ids } = res.data?.data ?? {}; + if (restored_ids?.length) { + setGroups((prev) => + prev.map((g) => + restored_ids.includes(g.group_id) ? { ...g, is_active: true } : g + ) + ); + toast.success(`${restored_ids.length} group(s) restored.`); + } + return res.data; + }), + [request] + ); + // ─── POST /api/admin/groups/:gid/users ──────────────────────────────────── const addUsersToGroup = useCallback( (gid, user_ids) => request(async () => { const res = await api.post(`${BASE}/groups/${gid}/users`, { user_ids }); - - // Remove added users from usersNotIn list setUsersNotIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); - toast.success("Users added to group."); return res.data; }), @@ -194,116 +225,25 @@ export function UserGroupProvider({ children }) { (gid, user_ids) => request(async () => { const res = await api.delete(`${BASE}/groups/${gid}/users`, { data: { user_ids } }); - - // Remove from members list setMembers((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); setUsersIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); - toast.success("Users removed from group."); return res.data; }), [request] ); - // ─── DELETE /api/admin/groups/bulk ──────────────────────────────────────────── - const deactivateGroups = useCallback( - ({ ids }) => - request(async () => { - const res = await api.delete(`${BASE}/groups/bulk`, { data: { ids } }); - const { deactivated_ids } = res.data?.data ?? {}; - - if (deactivated_ids?.length) { - setGroups((prev) => - prev.map((g) => - deactivated_ids.includes(g.group_id) ? { ...g, is_active: false } : g - ) - ); - } - - return res.data; - }), - [request] - ); - - // ─── POST /api/admin/groups/bulk/restore ────────────────────────────────────── - const restoreGroups = useCallback( - ({ ids }) => - request(async () => { - const res = await api.post(`${BASE}/groups/bulk/restore`, { ids }); - const { restored_ids } = res.data?.data ?? {}; - - if (restored_ids?.length) { - setGroups((prev) => - prev.map((g) => - restored_ids.includes(g.group_id) ? { ...g, is_active: true } : g - ) - ); - } - - return res.data; - }), - [request] - ); - - // ─── GET /api/admin/groups/field-values ─────────────────────────────────────── - const fetchGroupFieldValues = useCallback( - (field) => - request(async () => { - const res = await api.get(`${BASE}/groups/field-values`, { params: { field } }); - return res.data; - }), - [request] - ); - - const fetchArchivedGroups = useCallback( - ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => - request(async () => { - const res = await api.get(`${BASE}/groups/archived`, { - params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) }, - }); - - const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; - - setGroups(data ?? []); - setAttributes(attrs ?? []); - setPagination(pg ?? { page, limit, total: 0, totalPages: 1 }); - - return res.data; - }), - [request] - ); - return ( - + {children} ); diff --git a/src/contexts/provider/AdminProvider.jsx b/src/contexts/provider/AdminProvider.jsx index bc8b21e..e6f6200 100644 --- a/src/contexts/provider/AdminProvider.jsx +++ b/src/contexts/provider/AdminProvider.jsx @@ -1,4 +1,5 @@ // ─── AdminProvider.jsx ───────────────────────────────────────────────────────── +import { AssetsProvider } from "../AdminAssetsContext"; import { AdminDashboardProvider } from "../AdminDashboardContext" import { UserProvider } from "../AdminUserContext"; import { UserGroupProvider } from "../AdminUserGroupContext"; @@ -6,11 +7,13 @@ import { UserGroupProvider } from "../AdminUserGroupContext"; export const AdminProvider = ({ children }) => { return ( - - - {children} - - + + + + {children} + + + ); }; \ No newline at end of file diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js index f0c0ed1..8afaaef 100644 --- a/src/data/adminTiles.data.js +++ b/src/data/adminTiles.data.js @@ -1,35 +1,32 @@ -import { Users, GitFork } from "lucide-react"; +// data/adminTiles.data.js -export const USER_MANAGEMENT = [ +import { Users, GitFork, FolderOpen } from "lucide-react"; + +export const ADMIN_SECTIONS = [ { + id: "section-users", + tab: "User Management", title: "User Management", description: "Manage people across different systems", tiles: [ - { key: "users", label: "Users", icon: Users, link: "all" }, - { key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" }, + { key: "users", label: "Users", icon: Users, link: "/admin/users" }, + { key: "user-groups", label: "User Groups", icon: GitFork, link: "/admin/groups" }, ], }, { + id: "section-assets", + tab: "Asset Management", title: "Asset Management", - description: "Manage people across different systems", + description: "Upload images, documents and videos", tiles: [ - { key: "users", label: "Assets", icon: Users, link: "all" }, - { key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" }, + { key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" }, ], }, -] - -export const CONTENT_MANAGEMENT = [ - // { - // title: "User Management", - // description: "Manage people with their account permissions here.", - // tiles: [ - // { key: "users", label: "Users", icon: Users, link: "all" }, - // { key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" }, - // ], - // }, -] - -export const SITE_CONTENT = [ - -] \ No newline at end of file + { + id: "section-site", + tab: "Site Content", + title: "Site Content", + description: "Manage public-facing content", + tiles: [], + }, +]; \ No newline at end of file diff --git a/src/modules/admin/components/assets/ArchivedAssetsTable.jsx b/src/modules/admin/components/assets/ArchivedAssetsTable.jsx new file mode 100644 index 0000000..ba8b1ae --- /dev/null +++ b/src/modules/admin/components/assets/ArchivedAssetsTable.jsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAssets } from "@/contexts/AdminAssetsContext"; + +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; + +import { buildDataColumns, columnPinning } from "../../config/assets/archive/columns.config"; +import { buildToolbarActions } from "../../config/assets/archive/toolbar.config"; +import { buildRowActions } from "../../config/assets/archive/rowActions.config"; +import { buildSelectionActions } from "../../config/assets/archive/selection.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function ArchivedAssetsTable() { + const [restoreTarget, setRestoreTarget] = useState(null); + const [restoreIds, setRestoreIds] = useState(null); + + const tableRefsRef = useRef({ + getFilters: () => [], + getSort: () => [], + resetSelection: () => {}, + setFilters: () => {}, + }); + + const navigate = useNavigate(); + + const { + assets, attributes, pagination, setPagination, loading, + fetchArchivedAssets, restoreAsset, restoreAssets, fetchAssetFieldValues + } = useAssets(); + + useEffect(() => { + fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 }); + }, []); + + const handleRefsReady = (refs) => { + tableRefsRef.current = refs; + }; + + const exportConfig = { + allData: assets, + attributes, + filename: `${getTimestamp()}_ArchivedAssets`, + sheetName: "Archived Assets", + }; + + const rowActions = buildRowActions({ + onRestore: (row) => setRestoreTarget(row), + }); + + const toolbarActions = buildToolbarActions({ + fetchAssets: fetchArchivedAssets, + pagination, + exportConfig, + navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const selectionActions = buildSelectionActions({ + exportConfig, + onSingleRestore: (row) => setRestoreTarget(row), + onBulkRestore: (ids) => setRestoreIds(ids), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const columns = useMemo( + () => buildDataColumns(attributes, rowActions), + [attributes] + ); + + const handleRestoreSuccess = () => { + setRestoreTarget(null); + setRestoreIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + return ( + <> + ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="archived asset" + emptyMessage="No archived assets found." + /> + + {/* ── Single restore ── */} + !v && setRestoreTarget(null)} + entity={restoreTarget} + entityLabel="Asset" + getName={(a) => a?.display_name ?? a?.original_name} + onRestore={(a) => restoreAsset(a?.asset_id)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + {/* ── Bulk restore ── */} + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Asset" + onRestore={(ids) => restoreAssets(ids)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + ); +} \ No newline at end of file diff --git a/src/modules/admin/components/assets/AssetsTable.jsx b/src/modules/admin/components/assets/AssetsTable.jsx new file mode 100644 index 0000000..3a6a203 --- /dev/null +++ b/src/modules/admin/components/assets/AssetsTable.jsx @@ -0,0 +1,143 @@ +// modules/admin/components/assets/AssetsTable.jsx + +import { useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAssets } from "@/contexts/AdminAssetsContext"; + +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; + +import { buildDataColumns, columnPinning } from "../../config/assets/columns.config"; +import { buildToolbarActions } from "../../config/assets/toolbar.config"; +import { buildSelectionActions } from "../../config/assets/selection.config"; +import { buildRowActions } from "../../config/assets/rowActions.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function AssetsTable() { + const [archiveTarget, setArchiveTarget] = useState(null); + const [archiveIds, setArchiveIds] = useState(null); + + const tableRefsRef = useRef({ + getFilters: () => [], + getSort: () => [], + resetSelection: () => {}, + setFilters: () => {}, + }); + + const navigate = useNavigate(); + + const { + assets, attributes, pagination, setPagination, loading, + fetchAssets, archiveAsset, archiveAssets, fetchAssetFieldValues + } = useAssets(); + + const handleRefsReady = (refs) => { + tableRefsRef.current = refs; + }; + + const exportConfig = { + allData: assets, + attributes, + filename: `${getTimestamp()}_Assets`, + sheetName: "Assets", + }; + + const resolveViewPath = (row) => { + switch (row.file_type) { + case "video": return `view/video/${row.asset_id}`; + case "image": return `view/image/${row.asset_id}`; + case "document": return `view/document/${row.asset_id}`; + default: return `view/image/${row.asset_id}`; + } + }; + + const rowActions = buildRowActions({ + onView: (row) => navigate(resolveViewPath(row)), + onEdit: (row) => navigate(`edit/${row.asset_id}`), + onArchive: (row) => setArchiveTarget(row), + }); + + const toolbarActions = buildToolbarActions({ + fetchAssets, pagination, exportConfig, navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const selectionActions = buildSelectionActions({ + exportConfig, + onArchive: (row) => setArchiveTarget(row), + onArchiveMany: (ids) => setArchiveIds(ids), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const columns = useMemo( + () => buildDataColumns(attributes, rowActions), + [attributes] + ); + + const handleArchiveSuccess = () => { + setArchiveTarget(null); + setArchiveIds(null); + tableRefsRef.current.resetSelection?.(); + fetchAssets({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + return ( + <> + ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="asset" + emptyMessage="No assets match the current filters." + /> + + {/* ── Single archive ── */} + !v && setArchiveTarget(null)} + entity={archiveTarget} + entityLabel="Asset" + getName={(a) => a?.display_name ?? a?.original_name} + onArchive={(a) => archiveAsset(a?.asset_id)} + loading={loading} + onSuccess={handleArchiveSuccess} + /> + + {/* ── Bulk archive ── */} + !v && setArchiveIds(null)} + ids={archiveIds ?? []} + entityLabel="Asset" + onArchive={(ids) => archiveAssets(ids)} + loading={loading} + onSuccess={handleArchiveSuccess} + /> + + ); +} \ No newline at end of file diff --git a/src/modules/admin/config/assets/archive/columns.config.jsx b/src/modules/admin/config/assets/archive/columns.config.jsx new file mode 100644 index 0000000..eb49137 --- /dev/null +++ b/src/modules/admin/config/assets/archive/columns.config.jsx @@ -0,0 +1,45 @@ +// config/columns.config.jsx +// Column definitions and pinning config for the Users table. + +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; +import { Badge } from "@/components/ui/badge"; +import { Users } from "lucide-react"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +// ─── Custom cell overrides ──────────────────────────────────────────────────── +const cellOverrides = { + // memberCount: (info) => { + // const count = parseInt(info.getValue() ?? 0, 10); + // return ( + //
+ // + // + // {count} {count === 1 ? "member" : "members"} + // + //
+ // ); + // }, +}; + +/** + * Builds the full column array for the Users table. + * + * @param {Array} attributes Field definitions from the server (drives data columns) + * @param {Array} rowActions Row-level kebab action definitions + * @returns {Array} TanStack column definitions + */ +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/assets/archive/rowActions.config.jsx b/src/modules/admin/config/assets/archive/rowActions.config.jsx new file mode 100644 index 0000000..03112a9 --- /dev/null +++ b/src/modules/admin/config/assets/archive/rowActions.config.jsx @@ -0,0 +1,21 @@ +// modules/admin/config/assets/rowActions.config.jsx +import { RotateCcw } from "lucide-react"; + +/** + * @param {Object} deps + * @param {Function} deps.onView (row) → void — navigate to view page + * @param {Function} deps.onEdit (row) → void — navigate to edit page + * @param {Function} deps.onArchive (row) → void — open archive dialog + */ +export function buildRowActions({ onRestore }) { + return [ + { + key: "restore", + label: "Restore", + className: "text-emerald-600 focus:text-emerald-600", + icon: , + onClick: (row) => onRestore(row), + hidden: (row) => row.is_active, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/assets/archive/selection.config.jsx b/src/modules/admin/config/assets/archive/selection.config.jsx new file mode 100644 index 0000000..63420d2 --- /dev/null +++ b/src/modules/admin/config/assets/archive/selection.config.jsx @@ -0,0 +1,31 @@ +// config/assets/archive/selection.config.jsx +import { Download, ArchiveRestore } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRestore, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "restore-selected", + label: "Restore", + icon: , + 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.asset_id); + ids.length === 1 + ? onSingleRestore(rows[0]) + : onBulkRestore(ids); + }, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/assets/archive/toolbar.config.jsx b/src/modules/admin/config/assets/archive/toolbar.config.jsx new file mode 100644 index 0000000..0e2f24c --- /dev/null +++ b/src/modules/admin/config/assets/archive/toolbar.config.jsx @@ -0,0 +1,42 @@ +// ─── config/toolbar.config.jsx ──────────────────────────────────────────────── +import { RefreshCw, Download, Plus, Archive } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Function} deps.fetchAssets + * @param {Object} deps.pagination + * @param {Object} deps.exportConfig + * @param {Function} deps.navigate + * @param {Function} deps.getFilters + * @param {Function} deps.getSort + * @param {Function} deps.getTableInstance + */ +export function buildToolbarActions({ fetchAssets, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => + fetchAssets({ + page: 1, + limit: pagination.limit, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => + exportTableToExcel({ + ...exportConfig, + tableInstance: table ?? getTableInstance(), + }), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/assets/columns.config.jsx b/src/modules/admin/config/assets/columns.config.jsx new file mode 100644 index 0000000..eb49137 --- /dev/null +++ b/src/modules/admin/config/assets/columns.config.jsx @@ -0,0 +1,45 @@ +// config/columns.config.jsx +// Column definitions and pinning config for the Users table. + +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; +import { Badge } from "@/components/ui/badge"; +import { Users } from "lucide-react"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +// ─── Custom cell overrides ──────────────────────────────────────────────────── +const cellOverrides = { + // memberCount: (info) => { + // const count = parseInt(info.getValue() ?? 0, 10); + // return ( + //
+ // + // + // {count} {count === 1 ? "member" : "members"} + // + //
+ // ); + // }, +}; + +/** + * Builds the full column array for the Users table. + * + * @param {Array} attributes Field definitions from the server (drives data columns) + * @param {Array} rowActions Row-level kebab action definitions + * @returns {Array} TanStack column definitions + */ +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/assets/rowActions.config.jsx b/src/modules/admin/config/assets/rowActions.config.jsx new file mode 100644 index 0000000..e336d20 --- /dev/null +++ b/src/modules/admin/config/assets/rowActions.config.jsx @@ -0,0 +1,34 @@ +// modules/admin/config/assets/rowActions.config.jsx + +import { Eye, Pencil, Archive } from "lucide-react"; + +/** + * @param {Object} deps + * @param {Function} deps.onView (row) → void — navigate to view page + * @param {Function} deps.onEdit (row) → void — navigate to edit page + * @param {Function} deps.onArchive (row) → void — open archive dialog + */ +export function buildRowActions({ onView, onEdit, onArchive }) { + return [ + { + key: "view", + label: "View", + icon: , + onClick: (row) => onView(row), + }, + { + key: "edit", + label: "Edit", + icon: , + onClick: (row) => onEdit(row), + }, + { + key: "archive", + label: "Archive", + icon: , + className: "text-destructive", + onClick: (row) => onArchive(row), + separator: true + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/assets/selection.config.jsx b/src/modules/admin/config/assets/selection.config.jsx new file mode 100644 index 0000000..39643d8 --- /dev/null +++ b/src/modules/admin/config/assets/selection.config.jsx @@ -0,0 +1,38 @@ +// config/selection.config.jsx +import { Download, Archive, Trash2 } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Object} deps.exportConfig + * @param {Function} deps.onArchive (row) → void single archive dialog + * @param {Function} deps.onArchiveMany (ids[]) → void bulk archive dialog + * @param {Function} deps.getTableInstance + */ +export function buildSelectionActions({ exportConfig, onArchive, onArchiveMany, getTableInstance, }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "archive-selected", + label: "Archive", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.asset_id); + ids.length === 1 + ? onArchive(rows[0]) + : onArchiveMany(ids); + }, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/assets/toolbar.config.jsx b/src/modules/admin/config/assets/toolbar.config.jsx new file mode 100644 index 0000000..0bf4b2c --- /dev/null +++ b/src/modules/admin/config/assets/toolbar.config.jsx @@ -0,0 +1,60 @@ +// ─── config/toolbar.config.jsx ──────────────────────────────────────────────── +import { RefreshCw, Download, Plus, Archive } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Function} deps.fetchAssets + * @param {Object} deps.pagination + * @param {Object} deps.exportConfig + * @param {Function} deps.navigate + * @param {Function} deps.getFilters + * @param {Function} deps.getSort + * @param {Function} deps.getTableInstance + */ +export function buildToolbarActions({ fetchAssets, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => + fetchAssets({ + page: 1, + limit: pagination.limit, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => + exportTableToExcel({ + ...exportConfig, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "add-asset", + type: "button", + icon: , + label: "Add Asset", + variant: "default", + className: "text-primary-foreground", + onClick: () => navigate("add"), + }, + { + key: "archived-users", + type: "button", + icon: , + label: "Archived Assets", + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/assets/archived"), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/user_groups/toolbar.config.jsx b/src/modules/admin/config/user_groups/toolbar.config.jsx index 202e896..fa49f13 100644 --- a/src/modules/admin/config/user_groups/toolbar.config.jsx +++ b/src/modules/admin/config/user_groups/toolbar.config.jsx @@ -41,7 +41,7 @@ export function buildToolbarActions({ fetchGroups, pagination, exportConfig, onA label: "Archived Groups", variant: "secondary", className: "border border-border", - onClick: () => navigate("/admin/users/groups/archived"), + onClick: () => navigate("/admin/groups/archived"), }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/users/toolbar.config.jsx b/src/modules/admin/config/users/toolbar.config.jsx index 975f183..2dd8fdc 100644 --- a/src/modules/admin/config/users/toolbar.config.jsx +++ b/src/modules/admin/config/users/toolbar.config.jsx @@ -41,7 +41,7 @@ export function buildToolbarActions({ fetchUsers, pagination, exportConfig, navi label: "Archived Users", variant: "secondary", className: "border border-border", - onClick: () => navigate("/admin/users/all/archived"), + onClick: () => navigate("/admin/users/archived"), }, ]; } \ No newline at end of file diff --git a/src/modules/admin/layouts/AdminLayout.jsx b/src/modules/admin/layouts/AdminLayout.jsx index f3a7a19..8bec925 100644 --- a/src/modules/admin/layouts/AdminLayout.jsx +++ b/src/modules/admin/layouts/AdminLayout.jsx @@ -19,7 +19,6 @@ import { Badge } from "@/components/ui/badge" import { Toaster } from "sonner" import { cn } from "@/lib/utils" -import AdminSideTabs from "../components/AdminSideTabs" import UserMenu from "@/components/generic/UserMenu" import { ROLE_CONFIG } from "@/data/profile.data" @@ -78,9 +77,6 @@ const AdminLayout = () => {
-
- -
{/* ─── All admin contexts live here, scoped to admin routes only ── */} diff --git a/src/modules/admin/layouts/UserManagementLayout.jsx b/src/modules/admin/layouts/NavigationLayout.jsx similarity index 63% rename from src/modules/admin/layouts/UserManagementLayout.jsx rename to src/modules/admin/layouts/NavigationLayout.jsx index 2c7e172..d780094 100644 --- a/src/modules/admin/layouts/UserManagementLayout.jsx +++ b/src/modules/admin/layouts/NavigationLayout.jsx @@ -1,7 +1,7 @@ import React from 'react' import { Outlet } from 'react-router-dom' -const UserManagementLayout = () => { +const NavigationLayout = () => { return (
@@ -9,4 +9,4 @@ const UserManagementLayout = () => { ) } -export default UserManagementLayout \ No newline at end of file +export default NavigationLayout \ No newline at end of file diff --git a/src/modules/admin/pages/AdminDashboard.jsx b/src/modules/admin/pages/AdminDashboard.jsx index 4f78ec1..4629a3f 100644 --- a/src/modules/admin/pages/AdminDashboard.jsx +++ b/src/modules/admin/pages/AdminDashboard.jsx @@ -1,9 +1,54 @@ // modules/admin/pages/UsersDashboard.jsx +import { Tabs, TabsList, TabsTrigger } from "@/components/custom/original-tabs"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import DashboardGrid from "@/components/generic/DashboardGrid"; +import { ADMIN_SECTIONS } from "@/data/adminTiles.data"; + +function scrollTo(sectionId) { + document.getElementById(sectionId)?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + export default function AdminDashboard() { - return ( -
- AdminDashboard -
- ); + return ( +
+ {/* ── Sticky tab bar — driven by the same array ── */} +
+ + + + {ADMIN_SECTIONS.map((s) => ( + s.id === ADMIN_SECTIONS[0].id + ? window.scrollTo({ top: 0, behavior: "smooth" }) + : scrollTo(s.id) + } + className="data-[state=active]:bg-muted data-[state=active]:shadow-none hover:bg-muted text-muted-foreground data-[state=active]:text-foreground" + > + {s.tab} + + ))} + + + + +
+ + {/* ── Sections — same array, one DashboardGrid per entry ── */} + {ADMIN_SECTIONS.map((s) => ( +
+ {s.tiles.length > 0 ? ( + + ) : ( +
+

{s.title}

+

No items yet.

+
+ )} +
+ ))} +
+ ); } \ No newline at end of file diff --git a/src/modules/admin/pages/assets/AddAsset.jsx b/src/modules/admin/pages/assets/AddAsset.jsx new file mode 100644 index 0000000..026141c --- /dev/null +++ b/src/modules/admin/pages/assets/AddAsset.jsx @@ -0,0 +1,351 @@ +// modules/admin/pages/assets/AddAsset.jsx + +import { useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { useForm, Controller } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image } from "lucide-react"; + +import { useAssets } from "@/contexts/AdminAssetsContext"; +import { useAuth } from "@/contexts/AuthContext"; +import { Button } from "@/components/ui/button"; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +// ─── Derive file_type from MIME type ────────────────────────────────────────── + +function resolveFileType(mimeType = "") { + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document"; + return "image"; +} + +// ─── Schema ─────────────────────────────────────────────────────────────────── + +const schema = z.object({ + display_name: z.string().min(1, "Display name is required."), + description: z.string().optional(), + is_public: z.enum(["true", "false"]), + storage_provider: z.enum(["chibisafe", "local", "s3"]), +}); + +// ─── File type icon ─────────────────────────────────────────────────────────── + +function FileTypeIcon({ mimeType = "" }) { + if (mimeType.startsWith("video/")) return ; + if (mimeType.startsWith("image/")) return ; + return ; +} + +// ─── Drop zone ──────────────────────────────────────────────────────────────── + +function DropZone({ label, accept, file, onFile, onClear, error }) { + const inputRef = useRef(null); + + return ( +
e.preventDefault()} + onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onFile(f); }} + onClick={() => !file && inputRef.current?.click()} + className={[ + "rounded-lg border-2 border-dashed transition-colors cursor-pointer", + error ? "border-destructive/60 bg-destructive/5" : "", + file ? "border-border bg-muted/30 cursor-default" : "", + !file && !error ? "border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20" : "", + ].join(" ")} + > + { if (e.target.files[0]) onFile(e.target.files[0]); }} + /> + {file ? ( +
+ +
+

{file.name}

+

+ {(file.size / 1024).toFixed(1)} KB · {file.type} +

+
+ +
+ ) : ( +
+ +

+ Drag & drop or browse to upload +
+ {label} +

+
+ )} +
+ ); +} + +// ─── Field error ────────────────────────────────────────────────────────────── + +function FieldError({ message }) { + if (!message) return null; + return

{message}

; +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function AddAsset() { + const navigate = useNavigate(); + const { uploadAsset, loading } = useAssets(); + const { user } = useAuth(); + + const fileRef = useRef(null); + const thumbnailRef = useRef(null); + const [thumbKey, setThumbKey] = useState(0); + + const { + register, + control, + handleSubmit, + setValue, + watch, + setError, + clearErrors, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + display_name: "", + description: "", + is_public: "false", + storage_provider: "chibisafe", + }, + }); + + const file = watch("_file"); + const isVideo = file?.type?.startsWith("video/"); + + // ── Auto-derive file_type from MIME ─────────────────────────────────────── + const fileType = file ? resolveFileType(file.type) : null; + + const setFile = (f) => { + fileRef.current = f; + setValue("_file", f); + if (!watch("display_name")) setValue("display_name", f.name); + clearErrors("_file"); + }; + + const setThumbnail = (f) => { + thumbnailRef.current = f; + setValue("_thumbnail", f); + clearErrors("_thumbnail"); + }; + + const onSubmit = async (data) => { + let hasFileError = false; + + if (!fileRef.current) { + setError("_file", { message: "A file is required." }); + hasFileError = true; + } + + if (isVideo && !thumbnailRef.current) { + setError("_thumbnail", { message: "A thumbnail is required for video uploads." }); + hasFileError = true; + } + + if (hasFileError) return; + + const result = await uploadAsset({ + file: fileRef.current, + thumbnail: thumbnailRef.current ?? undefined, + display_name: data.display_name, + description: data.description ?? "", + file_type: fileType, + is_public: data.is_public === "true", + storage_provider: data.storage_provider, + createdBy: user?.user_id, + }); + + if (result) navigate(-1); + }; + + return ( +
+ + {/* ── Header ── */} +
+ +
+

Add Asset

+

Upload a new file to the asset library.

+
+
+ +
+
+ + {/* ── File ── */} +
+ + { + fileRef.current = null; + setValue("_file", null); + setValue("display_name", ""); + clearErrors("_file"); + }} + error={errors._file?.message} + /> + +
+ + {/* ── Thumbnail (video only) ── */} + {isVideo && ( +
+ + { + thumbnailRef.current = null; + setValue("_thumbnail", null); + clearErrors("_thumbnail"); + setThumbKey((k) => k + 1); + }} + error={errors._thumbnail?.message} + /> + +
+ )} + + {/* ── Display Name ── */} +
+ + + +
+ + {/* ── Description ── */} +
+ +