diff --git a/client/src/components/Home/PinnedProjects/PinnedProjects.tsx b/client/src/components/Home/PinnedProjects/PinnedProjects.tsx index f667eae0..36d75692 100644 --- a/client/src/components/Home/PinnedProjects/PinnedProjects.tsx +++ b/client/src/components/Home/PinnedProjects/PinnedProjects.tsx @@ -20,6 +20,7 @@ interface PinnedProjectsInterface {} const PinnedProjects: React.FC = () => { const { openModal, closeAllModals } = useModals(); const isTailwindLg = useMediaQuery({ minWidth: 1024 }); + const isXL = useMediaQuery({ minWidth: 1280 }); const { data, isLoading } = usePinnedProjects(); const onShowPinnedModal = () => { @@ -40,7 +41,7 @@ const PinnedProjects: React.FC = () => { const panes = useMemo(() => { if (!data) return []; - const classList = "xl:!ml-24 2xl:!ml-4 3xl:!ml-0 !max-h-[500px] overflow-y-auto xl:!mr-1" + const classList = "xl:!ml-24 2xl:!ml-4 3xl:!ml-0 !max-h-[500px] xl:!mr-1 !pt-1" const allItemsLength = data ?.map((i) => i.projects?.length) .reduce((acc, curr) => acc + (curr || 0), 0); @@ -64,7 +65,7 @@ const PinnedProjects: React.FC = () => { return ; } return ( - + {i.projects?.map((item) => typeof item === "string" ? null : ( @@ -86,7 +87,7 @@ const PinnedProjects: React.FC = () => { return ; } return ( - + {data?.map((i) => { if (typeof i.projects === "string") return null; return i.projects?.map((item) => @@ -99,12 +100,11 @@ const PinnedProjects: React.FC = () => { ); }, }); - return items; - }, [data]); + }, [data, isXL]); return ( - 0} loading={isLoading} className="!pb-10"> + 0} loading={isLoading} className="!pb-10 mt-4 flex-1 flex flex-col min-h-0">

@@ -128,10 +128,18 @@ const PinnedProjects: React.FC = () => { />

- +
+
+ +
+
); }; diff --git a/client/src/components/projects/ProjectCard/index.tsx b/client/src/components/projects/ProjectCard/index.tsx index ebce355e..3402f824 100644 --- a/client/src/components/projects/ProjectCard/index.tsx +++ b/client/src/components/projects/ProjectCard/index.tsx @@ -38,7 +38,7 @@ const ProjectCard = ({ return ( -
+
import("./AssignTicketModal")); +const SupportCenterSettingsModal = lazy( + () => import("./SupportCenterSettingsModal"), +); + +type SupportMetrics = { + totalOpenTickets: number; + lastSevenTicketCount: number; + avgDaysToClose: string; +}; + +const StaffDashboard = () => { + const { handleGlobalError } = useGlobalError(); + const { addNotification } = useNotifications(); + const queryClient = useQueryClient(); + const user = useTypedSelector((state) => state.user); + const { debounce } = useDebounce(); + + const [queryInputString, setQueryInputString] = useState(""); + const [query, setQuery] = useState(""); + const [activePage, setActivePage] = useState(1); + const [activeSort, setActiveSort] = useState("opened"); + const [totalPages, setTotalPages] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(25); + const [totalItems, setTotalItems] = useState(0); + const [showSettingsModal, setShowSettingsModal] = useState(false); + const [showAssignModal, setShowAssignModal] = useState(false); + const [selectedTicketId, setSelectedTicketId] = useState(""); + const [assigneeFilter, setAssigneeFilter] = useState(""); + const [priorityFilter, setPriorityFilter] = useState(""); + const [categoryFilter, setCategoryFilter] = useState(""); + const [filterOptions, setFilterOptions] = useState<{ + assignee: GenericKeyTextValueObj[]; + priority: GenericKeyTextValueObj[]; + category: GenericKeyTextValueObj[]; + }>({ + assignee: [], + priority: [], + category: [], + }); + + const { data: openTickets, isFetching } = useQuery({ + queryKey: [ + "openTickets", + activePage, + itemsPerPage, + activeSort, + assigneeFilter, + priorityFilter, + categoryFilter, + query, + ], + queryFn: () => + getOpenTickets({ + query: queryInputString, + page: activePage, + items: itemsPerPage, + sort: activeSort, + assigneeFilter, + priorityFilter, + categoryFilter, + }), + keepPreviousData: true, + staleTime: 1000 * 60 * 2, // 2 minutes + }); + + const { data: supportMetrics, isFetching: isFetchingMetrics } = + useQuery({ + queryKey: ["supportMetrics"], + queryFn: getSupportMetrics, + staleTime: 1000 * 60 * 2, // 2 minutes + }); + + useEffect(() => { + setActivePage(1); // Reset to first page when itemsPerPage changes + }, [itemsPerPage]); + + async function getOpenTickets({ + query, + page, + items, + sort, + assigneeFilter, + priorityFilter, + categoryFilter, + }: { + query: string; + page: number; + items: number; + sort: string; + assigneeFilter?: string; + priorityFilter?: string; + categoryFilter?: string; + }) { + try { + const res = await axios.get("/support/ticket/open", { + params: { + ...(query?.length > 3 && { query: query }), + page: page, + limit: items, + sort: sort, + ...(assigneeFilter && { assignee: assigneeFilter }), + ...(priorityFilter && { priority: priorityFilter }), + ...(categoryFilter && { category: categoryFilter }), + }, + }); + if (res.data.err) { + throw new Error(res.data.errMsg); + } + + if (!res.data.tickets || !Array.isArray(res.data.tickets)) { + throw new Error("Invalid response from server"); + } + + setTotalItems(res.data.total); + setTotalPages(Math.ceil(res.data.total / items)); + + const CLEAR_OPTION = { key: "clear", text: "Clear", value: "" }; + if (res.data.filters) { + const _assignee = Array.isArray(res.data.filters.assignee) + ? [CLEAR_OPTION, ...res.data.filters.assignee] + : []; + const _priority = Array.isArray(res.data.filters.priority) + ? [CLEAR_OPTION, ...res.data.filters.priority] + : []; + const _category = Array.isArray(res.data.filters.category) + ? [CLEAR_OPTION, ...res.data.filters.category] + : []; + + setFilterOptions({ + assignee: _assignee, + priority: _priority, + category: _category, + }); + } else { + setFilterOptions({ + assignee: [], + priority: [], + category: [], + }); + } + + return (res.data.tickets as SupportTicket[]) ?? []; + } catch (err) { + handleGlobalError(err); + return []; + } + } + + async function getSupportMetrics(): Promise { + try { + const res = await axios.get("/support/metrics"); + if (res.data.err) { + throw new Error(res.data.errMsg); + } + + if (!res.data.metrics) { + throw new Error("Invalid response from server"); + } + + // Convert avgMins to days + const avgMins = res.data.metrics.avgMinsToClose ?? 0; + const avgDays = avgMins / (60 * 24); + return { + totalOpenTickets: res.data.metrics.totalOpenTickets ?? 0, + lastSevenTicketCount: res.data.metrics.lastSevenTicketCount ?? 0, + avgDaysToClose: Math.floor(avgDays).toString() ?? "0", + }; + } catch (err) { + handleGlobalError(err); + return { + totalOpenTickets: 0, + lastSevenTicketCount: 0, + avgDaysToClose: "0", + }; + } + } + + const debouncedQueryUpdate = debounce( + (searchString: string) => setQuery(searchString), + 300, + ); + + function openAssignModal(ticketId: string) { + setSelectedTicketId(ticketId); + setShowAssignModal(true); + } + + function onCloseAssignModal() { + setShowAssignModal(false); + setSelectedTicketId(""); + queryClient.invalidateQueries(["openTickets"]); + } + + function handleFilterChange(filter: string, value: string) { + setActivePage(1); // Reset to first page on filter change + switch (filter) { + case "assignee": + setAssigneeFilter(value); + break; + case "priority": + setPriorityFilter(value); + break; + case "category": + setCategoryFilter(value); + break; + default: + break; + } + } + + function getAssigneeName(uuid: string) { + const user = openTickets?.find((t) => + t.assignedUsers?.find((u) => u.uuid === uuid), + ); + if (user) { + return user.assignedUsers?.find((u) => u.uuid === uuid)?.firstName; + } else { + return ""; + } + } + + const DashboardMetric = ({ + metric, + title, + loading = false, + }: { + metric: string; + title: string; + loading?: boolean; + }) => ( +
+

{loading ? : metric}

+

{title}

+
+ ); + + return ( +
+
+

Staff Dashboard

+
+ + {user.isSuperAdmin && ( + + )} +
+
+
+ + + +
+
+

Open/In Progress Tickets

+
+
+ + + {filterOptions.assignee.map((a) => ( + handleFilterChange("assignee", a.value)} + > + {a.text} + + ))} + + + + + {filterOptions.priority.map((p) => ( + handleFilterChange("priority", p.value)} + > + {p.text} + + ))} + + + + + {filterOptions.category.map((c) => ( + handleFilterChange("category", c.value)} + > + {c.text} + + ))} + + +
+ { + setQueryInputString(e.target.value); + debouncedQueryUpdate(e.target.value); + }} + /> +
+ + + loading={isFetching} + data={openTickets} + columns={[ + { + accessor: "uuid", + title: "ID", + render(record, index) { + return ( + <> + {record.uuid.slice(-7)} + + {({ copied, copy }) => ( + { + copy(); + addNotification({ + message: "Ticket ID copied to clipboard", + type: "success", + duration: 2000, + }); + }} + color={copied ? "green" : "blue"} + /> + )} + + + ); + }, + }, + { + accessor: "timeOpened", + title: "Date Opened", + render(record) { + return format( + parseISO(record.timeOpened), + "MM/dd/yyyy hh:mm aa", + ); + }, + }, + { + accessor: "title", + title: "Subject", + className: "!w-full !max-w-[40rem] break-words truncate", + render(record) { + return record.title; + }, + }, + { + accessor: "category", + title: "Category", + render(record) { + return getPrettySupportTicketCategory(record.category); + }, + }, + { + accessor: "user", + title: "Requester", + render(record) { + return getRequesterText(record); + }, + }, + { + accessor: "assignedUsers", + title: "Assigned To", + render(record) { + return record.assignedUsers + ? record.assignedUsers.map((u) => u.firstName).join(", ") + : "Unassigned"; + }, + }, + { + accessor: "priority", + render(record) { + return capitalizeFirstLetter(record.priority); + }, + }, + { + accessor: "status", + render(record) { + return ; + }, + }, + { + accessor: "actions", + render(record) { + return ( +
+ + {record.status === "open" && ( + + )} +
+ ); + }, + }, + ]} + /> + +
+ setShowSettingsModal(false)} + /> + {selectedTicketId && ( + + )} +
+ ); +}; + +export default StaffDashboard; diff --git a/client/src/screens/conductor/Home/index.tsx b/client/src/screens/conductor/Home/index.tsx index 7fd58aec..665cf6b9 100644 --- a/client/src/screens/conductor/Home/index.tsx +++ b/client/src/screens/conductor/Home/index.tsx @@ -199,11 +199,11 @@ const Home = () => {
)} -
+
-
+
diff --git a/client/src/screens/conductor/controlpanel/CentralIdentity/CentralIdentityUsers.tsx b/client/src/screens/conductor/controlpanel/CentralIdentity/CentralIdentityUsers.tsx index 0be94e01..d27838a1 100644 --- a/client/src/screens/conductor/controlpanel/CentralIdentity/CentralIdentityUsers.tsx +++ b/client/src/screens/conductor/controlpanel/CentralIdentity/CentralIdentityUsers.tsx @@ -48,7 +48,6 @@ const CentralIdentityUsers = () => { { key: "email", text: "Email" }, { key: "userType", text: "User Type" }, { key: "verification", text: "Verification Status" }, - { key: "studentId", text: "Student ID" }, { key: "Auth Source", text: "Auth Source" }, { key: "Actions", text: "Actions" }, ]; @@ -236,7 +235,7 @@ const CentralIdentityUsers = () => { { setSearchInput(e.target.value); getUsersDebounced(e.target.value); diff --git a/client/src/screens/conductor/support/SupportCreateTicket.tsx b/client/src/screens/conductor/support/SupportCreateTicket.tsx index ede1247f..81d504fa 100644 --- a/client/src/screens/conductor/support/SupportCreateTicket.tsx +++ b/client/src/screens/conductor/support/SupportCreateTicket.tsx @@ -6,6 +6,7 @@ import CreateTicketFlow from "../../../components/support/CreateTicketFlow"; import useSystemAnnouncement from "../../../hooks/useSystemAnnouncement"; import SystemAnnouncement from "../../../components/util/SystemAnnouncement"; import { useDocumentTitle } from "usehooks-ts"; +import { Message } from 'semantic-ui-react'; const SupportCreateTicket = () => { useDocumentTitle("LibreTexts | Contact Support"); @@ -46,6 +47,27 @@ const SupportCreateTicket = () => {

Submit a request to get help from our team.

+
+ + +
+ Important: +

+ Please do not submit a support ticket for account creation. You can visit {' '} + + https://register.libretexts.org + + {' '} to create an account and complete instructor verification (if applicable). +

+
+
+
+
<> {!isLoggedIn && !guestMode && (