diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/imports/api/asset-assignment-history.js b/imports/api/asset-assignment-history.js index 6003d14..07620d8 100644 --- a/imports/api/asset-assignment-history.js +++ b/imports/api/asset-assignment-history.js @@ -1,4 +1,10 @@ import {Mongo} from "meteor/mongo"; +import {Meteor} from "meteor/meteor"; +import {check} from "meteor/check"; +import {Assets} from "/imports/api/assets"; +import {Students} from "/imports/api/students"; +import {Staff} from "/imports/api/staff"; +import {AssetTypes} from "/imports/api/asset-types"; export const AssetAssignmentHistory = new Mongo.Collection('assetAssignmentHistory'); @@ -17,4 +23,62 @@ startCondition: One of the condition options: [New, Like New, Good, Okay, Damage endCondition: One of the condition options: [New, Like New, Good, Okay, Damaged] (see assets.unassign for details). startConditionDetails: An optional text block for details on the condition. endConditionDetails: An optional text block for details on the condition. - */ \ No newline at end of file + */ + + +if (Meteor.isServer) { + Meteor.methods({ + /** + * Collects historical data on asset assignments. + * @param params An object with a single attribute. The attribute must be one of: assetId, serial, staffId, or studentId. It will find all Assignment data for the given attribute value. + * @returns {any} Array of Asset Assignment History objects. + */ + 'AssetAssignmentHistory.get'(params) { + if(Roles.userIsInRole(Meteor.userId(), "laptop-management", {anyScope:true})) { + let query = {}; + + if(params.studentId) check(params.studentId, String) + if(params.staffId) check(params.staffId, String) + if(params.assetId) check(params.assetId, String) + if(params.serial) check(params.serial, String) + + if(params.serial) query.serial = params.serial; + else if(params.assetId) query.assetId = params.assetId; + else if(params.studentId) { + query.assigneeId = params.studentId + query.assigneeType = "Student" + } + else if(params.staffId) { + query.assigneeId = params.staffId + query.assigneeType = "Staff" + } + else { + query = undefined; + } + + if(query) { + //Sort by the last time the record was updated from most to least recent. + let result = AssetAssignmentHistory.find(query, {sort: {endDate: -1}}).fetch(); + + //Add some additional data to the records. + for(let next of result) { + if(next.serial) { + next.asset = Assets.findOne({serial: next.serial}); + } + + if(next.asset) { + next.assetType = AssetTypes.findOne({_id: next.asset.assetType}) + } + + if(next.assigneeId) { + next.assignee = next.asset.assigneeType === "Student" ? Students.findOne({_id: next.assigneeId}) : Staff.findOne({_id: next.assigneeId}) + } + } + + return result; + } else return null; + } + else return null; + } + }); +} \ No newline at end of file diff --git a/imports/api/data-collection.js b/imports/api/data-collection.js index be6fbfe..34265ea 100644 --- a/imports/api/data-collection.js +++ b/imports/api/data-collection.js @@ -46,6 +46,9 @@ if (Meteor.isServer) { if(Roles.userIsInRole(Meteor.userId(), "laptop-management", {anyScope:true})) { let query = {}; + if(params.studentId) check(params.studentId, String) + if(params.staffId) check(params.staffId, String) + // For asset ID's, we need to get the serial from the asset collection first. if(params.assetId) { let asset = Assets.findOne({assetId : params.assetId}); @@ -55,6 +58,8 @@ if (Meteor.isServer) { params.regex = false; } } + // console.log('chromebook data') + // console.log(params) if (params.deviceId) query.deviceId = params.regex ? { $regex: params.deviceId, @@ -64,14 +69,19 @@ if (Meteor.isServer) { $regex: params.serial, $options: "i" } : params.serial; - // else if (params.assetId) { - // let asset = Assets.findOne({assetId: params.assetId}); - // - // if(asset.serial) { - // // An exact search. - // query.serial = asset.serial; - // } - // } + else if(params.studentId) { + const student = Students.findOne({_id: params.studentId}) + + console.log(student) + if(student) query.email = student.email; + else query = undefined + } + else if(params.staffId) { + const staff = Staff.findOne({_id: params.staffId}) + + if(staff) query.email = staff.email; + else query = undefined + } else if (params.email) query.email = params.regex ? { $regex: params.email, $options: "i" @@ -82,6 +92,9 @@ if (Meteor.isServer) { else { query = undefined; } + + // console.log("query") + // console.log(query) if(query) { // console.log("Collecting Chromebook Data: "); @@ -113,6 +126,8 @@ if (Meteor.isServer) { } } + // console.log('returning') + // console.log(result) return result; } else return null; } diff --git a/imports/api/users.js b/imports/api/users.js new file mode 100644 index 0000000..3a5262a --- /dev/null +++ b/imports/api/users.js @@ -0,0 +1,72 @@ +import { Meteor } from 'meteor/meteor'; +import { Roles } from 'meteor/alanning:roles'; +import { check } from 'meteor/check'; + +// console.log("Setting Up Users...") + +if (Meteor.isServer) { + Meteor.publish(null, function() { + if(this.userId) { + return Meteor.roleAssignment.find({'user._id': this.userId}); + } + else { + this.ready(); + } + }); + + Meteor.publish(null, function() { + return Meteor.roles.find({}); + }); + + Meteor.publish("allUsers", function() { + // console.log(Meteor.isServer); + // console.log("AllUsers"); + // console.log("Meteor.userId(): " + Meteor.userId()); + // // console.log(Roles.userIsInRole(Meteor.userId(), "laptop-management")); + // console.log(Meteor.roleAssignment.find({ 'user._id': Meteor.userId() }).fetch()); + // console.log(Roles.userIsInRole(Meteor.user(), "admin", {anyScope:true})); + + // Note: For some reason the {anyScope: true} is necessary on the server for the function to actually check roles. + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + //console.log(Meteor.users.find({}).fetch()); + return Meteor.users.find({}); + } + else { + return []; + } + }); + Meteor.publish("allRoleAssignments", function() { + // Note: For some reason the {anyScope: true} is necessary on the server for the function to actually check roles. + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + return Meteor.roleAssignment.find({}); + } + else { + return []; + } + }); + + Meteor.methods({ + 'users.setUserRoles'(userId, roles) { + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + check(userId, String); + check(roles, Array); + Roles.setUserRoles(userId, roles, {anyScope: true}); + } + }, + // 'tasks.setPrivate'(taskId, setToPrivate) { + // check(taskId, String); + // check(setToPrivate, Boolean); + // + // const task = Tasks.findOne(taskId); + // + // // Make sure only the task owner can make a task private + // if (task.owner !== this.userId) { + // throw new Meteor.Error('not-authorized'); + // } + // + // Tasks.update(taskId, { $set: { private: setToPrivate } }); + // }, + }); +} + +// console.log("Users setup.") \ No newline at end of file diff --git a/imports/ui/pages/Assignments/ByPerson.jsx b/imports/ui/pages/Assignments/ByPerson.jsx index d974a4c..d1a920c 100644 --- a/imports/ui/pages/Assignments/ByPerson.jsx +++ b/imports/ui/pages/Assignments/ByPerson.jsx @@ -3,16 +3,11 @@ import React, { useState, useEffect } from 'react'; import { useTracker } from 'meteor/react-meteor-data'; import { useTheme } from '@mui/material/styles'; import _ from 'lodash'; -import SimpleTable from "/imports/ui/util/SimpleTable"; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; -import Select from '@mui/material/Select'; -import Chip from '@mui/material/Chip'; import MenuItem from '@mui/material/MenuItem'; import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material"; import Box from "@mui/material/Box"; -import OutlinedInput from '@mui/material/OutlinedInput'; -import FormControl from '@mui/material/FormControl'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import Dialog from '@mui/material/Dialog'; @@ -222,7 +217,7 @@ const AssignmentsByPerson = () => { {people.map((next, i) => { return ( {setSelectedPerson(next)}}> - + ) })} diff --git a/imports/ui/pages/History.jsx b/imports/ui/pages/History.jsx index 3be07a3..070a58a 100644 --- a/imports/ui/pages/History.jsx +++ b/imports/ui/pages/History.jsx @@ -3,6 +3,8 @@ import React, {lazy, Suspense, useState} from 'react'; import { useTracker } from 'meteor/react-meteor-data'; import _ from 'lodash'; import TabNav from '../util/TabNav'; +import {Route, Routes} from "react-router-dom"; +import Search from './History/Search' export default () => { let tabs = [ @@ -16,15 +18,23 @@ export default () => { href: 'chromebookUsage' }, { - title: "Asset Assignments", + title: "Asset History", getElement: () => { - const AssetAssignments = lazy(()=>import('./History/AssetAssignments')) - return + const AssetHistory = lazy(()=>import('./History/AssetHistory')) + return }, - path: '/assetAssignments', - href: 'assetAssignments' + path: '/assetHistory', + href: 'assetHistory' }, ] - return + return ( + <> + + + + }/> + + + ) } \ No newline at end of file diff --git a/imports/ui/pages/History/AssetAssignments.jsx b/imports/ui/pages/History/AssetAssignments.jsx deleted file mode 100644 index 7248c25..0000000 --- a/imports/ui/pages/History/AssetAssignments.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import React, { useState, useEffect } from 'react'; -import { useTracker } from 'meteor/react-meteor-data'; -import { useTheme } from '@mui/material/styles'; -import _ from 'lodash'; -import SimpleTable from "/imports/ui/util/SimpleTable"; -import TextField from "@mui/material/TextField"; -import Button from "@mui/material/Button"; -import Select from '@mui/material/Select'; -import Chip from '@mui/material/Chip'; -import MenuItem from '@mui/material/MenuItem'; -import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material"; -import Box from "@mui/material/Box"; -import OutlinedInput from '@mui/material/OutlinedInput'; -import FormControl from '@mui/material/FormControl'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; \ No newline at end of file diff --git a/imports/ui/pages/History/AssetHistory.jsx b/imports/ui/pages/History/AssetHistory.jsx new file mode 100644 index 0000000..7833811 --- /dev/null +++ b/imports/ui/pages/History/AssetHistory.jsx @@ -0,0 +1,125 @@ +import { Meteor } from 'meteor/meteor'; +import React, { useState, useEffect } from 'react'; +import { useTracker } from 'meteor/react-meteor-data'; +import { useTheme } from '@mui/material/styles'; +import _ from 'lodash'; +import SimpleTable from "/imports/ui/util/SimpleTable"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import Select from '@mui/material/Select'; +import Chip from '@mui/material/Chip'; +import MenuItem from '@mui/material/MenuItem'; +import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material"; +import Box from "@mui/material/Box"; +import OutlinedInput from '@mui/material/OutlinedInput'; +import FormControl from '@mui/material/FormControl'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import {useNavigate} from "react-router-dom"; +import {Students} from "/imports/api/students"; +import {Staff} from "/imports/api/staff"; +import {Assets} from "/imports/api/assets"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import Paper from "@mui/material/Paper"; +import InputBase from "@mui/material/InputBase"; +import IconButton from "@mui/material/IconButton"; +import SearchIcon from "@mui/icons-material/Search"; + +export default () => { + const navigate = useNavigate() + const [resultType, setResultType] = useState("usage") + const [searchType, setSearchType] = useState("email") + const [value, setValue] = useState("") + const search = () => { + if(searchType === 'email' || searchType === 'firstName' || searchType === 'lastName') { + if (value && value.length > 1) { + let query = searchType === 'email' ? {email: {$regex: value, $options: 'i'}} : searchType === 'firstName' ? {firstName: {$regex: value, $options: 'i'}} : {lastName: {$regex: value, $options: 'i'}} + let students = Students.find(query).fetch() + let staff = Staff.find(query).fetch() + let all = [...staff, ...students] + + if (all.length > 1) { + setPeopleToPickFrom(all) + setOpenPickPersonDialog(true) + } else if (all.length === 1) { + navigate("/history/search?resultType=" + encodeURIComponent(resultType) + "&" + (students.length ? "studentId" : 'staffId') + "=" + encodeURIComponent(all[0]._id)); + } + } + } + else if(searchType === 'assetID' || searchType === 'serial') { + let asset = Assets.findOne(searchType === 'assetID' ? {assetId: value} : {serial : value}); + + if(asset) { + if(searchType === 'assetID') + navigate("/history/search?resultType=" + encodeURIComponent(resultType) + "&assetId=" + encodeURIComponent(asset.assetId)) + else + navigate("/history/search?resultType=" + encodeURIComponent(resultType) + "&serial=" + encodeURIComponent(asset.serial)) + } + } + } + + Meteor.subscribe('students'); + Meteor.subscribe('staff'); + Meteor.subscribe('assets'); + + const [openPickPersonDialog, setOpenPickPersonDialog] = useState(false) + const [peopleToPickFrom, setPeopleToPickFrom] = useState([]) + const pickPersonClosed = (cause, person) => { + if(openPickPersonDialog) setOpenPickPersonDialog(false) + + if(person && person._id) { + navigate("/history/search?resultType=" + encodeURIComponent(resultType) + "&" + (person.grade ? "studentId" : 'staffId') + "=" + encodeURIComponent(person._id)); + } + } + + return ( + <> + + Pick One + + + {peopleToPickFrom.map((next, i) => { + return ( + {pickPersonClosed("selection", next)}}> + + + ) + })} + + + + + + + +
+ +
+ setResultType(type)} aria-label="Result Type"> + Usage History + Assignment History + +
+
+ setSearchType(type)} aria-label="Search Type"> + Email + First Name + Last Name + Asset ID + Serial + +
+
+ {setValue(e.target.value)}} sx={{ ml: 1, flex: 1 }} placeholder="Value" inputProps={{ 'aria-label': 'Search Value' }}/> + + + +
+
+
+ + ) +} \ No newline at end of file diff --git a/imports/ui/pages/History/ChromebookUsage.jsx b/imports/ui/pages/History/ChromebookUsage.jsx index e8987b9..5636a1e 100644 --- a/imports/ui/pages/History/ChromebookUsage.jsx +++ b/imports/ui/pages/History/ChromebookUsage.jsx @@ -9,7 +9,6 @@ import Button from "@mui/material/Button"; import Select from '@mui/material/Select'; import Chip from '@mui/material/Chip'; import MenuItem from '@mui/material/MenuItem'; -import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material"; import Paper from '@mui/material/Paper'; import InputBase from '@mui/material/InputBase'; import IconButton from '@mui/material/IconButton'; @@ -19,20 +18,76 @@ import OutlinedInput from '@mui/material/OutlinedInput'; import FormControl from '@mui/material/FormControl'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import { useNavigate } from "react-router-dom"; +import {Students} from "/imports/api/students"; +import {Staff} from "/imports/api/staff"; +import {conditions} from "/imports/api/assets"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import Dialog from "@mui/material/Dialog"; +import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material"; export default () => { + const navigate = useNavigate() + const [value, setValue] = useState("") + const search = () => { + if(value && value.length > 1) { + let students = Students.find({email: {$regex: value, $options: 'i'}}).fetch() + let staff = Staff.find({email: {$regex: value, $options: 'i'}}).fetch() + let all = [...staff, ...students] + + if(all.length > 1) { + setPeopleToPickFrom(all) + setOpenPickPersonDialog(true) + } + else if(all.length === 1) { + navigate("/history/search?" + (students.length ? "studentId" : 'staffId') + "=" + encodeURIComponent(all[0]._id)); + } + } + } + + Meteor.subscribe('students'); + Meteor.subscribe('staff'); + + const [openPickPersonDialog, setOpenPickPersonDialog] = useState(false) + const [peopleToPickFrom, setPeopleToPickFrom] = useState([]) + const pickPersonClosed = (cause, person) => { + if(openPickPersonDialog) setOpenPickPersonDialog(false) + + if(person && person._id) { + navigate("/history/search?" + (person.grade ? "studentId" : 'staffId') + "=" + encodeURIComponent(person._id)); + } + } + return ( -
- - - - - - -
+ <> + + Pick One + + + {peopleToPickFrom.map((next, i) => { + return ( + {pickPersonClosed("selection", next)}}> + + + ) + })} + + + + + + + +
+ + {setValue(e.target.value)}} sx={{ ml: 1, flex: 1 }} placeholder="Email" inputProps={{ 'aria-label': 'Search Email' }}/> + + + + +
+ ) } \ No newline at end of file diff --git a/imports/ui/pages/History/Search.jsx b/imports/ui/pages/History/Search.jsx new file mode 100644 index 0000000..3e8628e --- /dev/null +++ b/imports/ui/pages/History/Search.jsx @@ -0,0 +1,135 @@ +import { Meteor } from 'meteor/meteor'; +import React, { useState, useEffect } from 'react'; +import { useTracker } from 'meteor/react-meteor-data'; +import _ from 'lodash'; +// import queryString from 'query-string' +import {Link, useSearchParams} from "react-router-dom"; + +const RenderUsage = ({data}) => { + return ( + <> +
    + {data.map((next, i) => ( +
  • + {next.person && ( + <> + User: {next.person.firstName} {next.person.lastName} {next.person.grade ? "~ " + next.person.grade : ""} ({next.email})
    + + )} + Device ID: {next.deviceId}
    + Serial: {next.serial}
    + {next.asset && ( + <> + Asset ID: {next.asset.assetId}
    + + )} + {new Date(next.startTime).toLocaleDateString("en-US") + "-" + new Date(next.endTime).toLocaleDateString("en-US")} + {next.assetType && ( + <> +
    Asset Type: {next.assetType.name} + + )} + {next.assignedTo && ( + <> +
    Currently assigned to: {next.assignedTo.firstName} {next.assignedTo.lastName} {next.assignedTo.grade ? "~ " + next.assignedTo.grade : ""} ({next.assignedTo.email}) + + )} +
  • + ))} +
+ + ) +} + +const RenderAssignments = ({data}) => { + return ( + <> +
    + {data.map((next, i) => ( +
  • + {next.assignee && ( + <> + User: {next.assignee.firstName} {next.assignee.lastName} {next.assignee.grade ? "~ " + next.assignee.grade : ""} ({next.assignee.email})
    + + )} + Serial: {next.serial}
    + {next.asset && ( + <> + Asset ID: {next.asset.assetId}
    + + )} + {next.assetType && ( + <> + Asset Type: {next.assetType.name}
    + + )} + {new Date(next.startDate).toLocaleDateString("en-US") + "-" + new Date(next.endDate).toLocaleDateString("en-US")}
    + Comment: {next.comment}
    + Start Condition: {next.startCondition}
    + Details: {next.startConditionDetails}
    + End Condition: {next.endCondition}
    + Details: {next.endConditionDetails}
    +
  • + ))} +
+ + ) +} + +export default () => { + // const query = queryString.parse(search) + const [data, setData] = useState([]) + const [search, setSearch] = useSearchParams() + + useEffect(() => { + let args; + + if(search.get('resultType') === 'usage') { + if(search.get('studentId')) { + args = {studentId: search.get('studentId')} + } + else if(search.get('staffId')) { + args = {staffId: search.get('staffId')} + } + else if(search.get('email')) { + args = {email: search.get('email')} + } + else if(search.get('deviceId')) { + args = {deviceId: search.get('deviceId')} + } + else if(search.get('serial')) { + args = {serial: search.get('serial')} + } + else if(search.get('assetId')) { + args = {assetId: search.get('assetId')} + } + + Meteor.call('DataCollection.chromebookData', args, (err, result) => { + if (err) console.error(err) + else setData(result) + }) + } + else { + if(search.get('studentId')) { + args = {studentId: search.get('studentId')} + } + else if(search.get('staffId')) { + args = {staffId: search.get('staffId')} + } + else if(search.get('serial')) { + args = {serial: search.get('serial')} + } + else if(search.get('assetId')) { + args = {assetId: search.get('assetId')} + } + + Meteor.call('AssetAssignmentHistory.get', args, (err, result) => { + if (err) console.error(err) + else setData(result) + }) + } + + }, [search]) + + return (search.get('resultType') === 'usage' ? : ) +} \ No newline at end of file diff --git a/package.json b/package.json index f582900..d6f07f1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "meteor-node-stubs": "^1.0.0", "moment": "^2.29.2", "mongodb": "^4.4.1", + "query-string": "^7.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0",