From 3c76d5e6a0d931e89ca72ec86f971be033dda71d Mon Sep 17 00:00:00 2001 From: "DESKTOP-C9V2M01\\Zeeri" Date: Fri, 16 Jun 2023 11:52:48 -0700 Subject: [PATCH] Updated meteor; Modified the assignments byPerson page considerably to improve the workflow; Added an external id to sites; Added an import for students; Improved the students page. --- .meteor/packages | 7 +- .meteor/release | 2 +- .meteor/versions | 48 ++--- imports/api/assets.js | 19 ++ imports/api/sites.js | 8 +- imports/api/students.js | 232 +++++++++++++++------- imports/ui/App.jsx | 2 +- imports/ui/pages/Admin/Sites.jsx | 10 +- imports/ui/pages/Admin/Students.jsx | 121 ++++++++++- imports/ui/pages/Assignments.jsx | 9 + imports/ui/pages/Assignments/ByAsset.jsx | 32 ++- imports/ui/pages/Assignments/ByPerson.jsx | 129 ++++++++---- imports/ui/pages/Assignments/Report.jsx | 168 ++++++++++++++++ imports/ui/pages/Home.jsx | 53 ++++- package.json | 4 +- 15 files changed, 664 insertions(+), 180 deletions(-) create mode 100644 imports/ui/pages/Assignments/Report.jsx diff --git a/.meteor/packages b/.meteor/packages index d9eb973..6e283e7 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -6,14 +6,14 @@ meteor-base@1.5.1 # Packages every Meteor app needs to have mobile-experience@1.1.0 # Packages for a great mobile UX -mongo@1.16.5 # The database Meteor supports right now +mongo@1.16.6 # The database Meteor supports right now jquery # Wrapper package for npm-installed jquery reactive-var@1.0.12 # Reactive variable for tracker -standard-minifier-css@1.9.0 # CSS minifier run for production mode +standard-minifier-css@1.9.2 # CSS minifier run for production mode standard-minifier-js@2.8.1 # JS minifier run for production mode es5-shim@4.8.0 # ECMAScript 5 compatibility for older browsers -ecmascript@0.16.6 # Enable ECMAScript2015+ syntax in app code +ecmascript@0.16.7 # Enable ECMAScript2015+ syntax in app code typescript@4.9.4 # Enable TypeScript syntax in .ts and .tsx modules shell-server@0.5.0 # Server-side component of the `meteor shell` command @@ -27,3 +27,4 @@ google-config-ui@1.0.3 # Adds the UI for logging in via Google alanning:roles # Adds roles to the user msavin:mongol # Free version of MeteorToys - Provides access to the client side MongoDB for debugging. (Ctrl-M to activate :: https://atmospherejs.com/msavin/mongol) +session diff --git a/.meteor/release b/.meteor/release index 31dd9a2..e8cfc7e 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@2.11.0 +METEOR@2.12 diff --git a/.meteor/versions b/.meteor/versions index 5bfabae..45a310f 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,4 +1,4 @@ -accounts-base@2.2.7 +accounts-base@2.2.8 accounts-google@1.4.0 accounts-oauth@1.4.2 accounts-password@2.3.4 @@ -7,30 +7,30 @@ accounts-ui-unstyled@1.7.0 alanning:roles@3.4.0 allow-deny@1.1.1 autoupdate@1.8.0 -babel-compiler@7.10.3 +babel-compiler@7.10.4 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 -blaze@2.6.1 +blaze@2.6.2 blaze-tools@1.1.3 boilerplate-generator@1.7.1 caching-compiler@1.2.2 caching-html-compiler@1.2.1 -callback-hook@1.5.0 +callback-hook@1.5.1 check@1.3.2 ddp@1.4.1 ddp-client@2.6.1 ddp-common@1.4.0 -ddp-rate-limiter@1.1.1 -ddp-server@2.6.0 +ddp-rate-limiter@1.2.0 +ddp-server@2.6.1 diff-sequence@1.1.2 -dynamic-import@0.7.2 -ecmascript@0.16.6 -ecmascript-runtime@0.8.0 +dynamic-import@0.7.3 +ecmascript@0.16.7 +ecmascript-runtime@0.8.1 ecmascript-runtime-client@0.12.1 ecmascript-runtime-server@0.11.0 ejson@1.1.3 -email@2.2.4 +email@2.2.5 es5-shim@4.8.0 fetch@0.1.3 geojson-utils@1.0.11 @@ -46,31 +46,31 @@ launch-screen@1.3.0 less@4.0.0 localstorage@1.2.0 logging@1.3.2 -meteor@1.11.1 +meteor@1.11.2 meteor-base@1.5.1 meteortoys:toykit@10.0.0 -minifier-css@1.6.2 +minifier-css@1.6.4 minifier-js@2.7.5 -minimongo@1.9.2 +minimongo@1.9.3 mobile-experience@1.1.0 mobile-status-bar@1.1.0 modern-browsers@0.1.9 modules@0.19.0 modules-runtime@0.13.1 -mongo@1.16.5 +mongo@1.16.6 mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 msavin:mongol@10.0.1 -npm-mongo@4.14.0 +npm-mongo@4.16.0 oauth@2.2.0 oauth2@1.3.2 -observe-sequence@1.0.20 +observe-sequence@1.0.21 ordered-dict@1.1.0 promise@0.12.2 random@1.2.1 -rate-limit@1.0.9 -react-fast-refresh@0.2.6 +rate-limit@1.1.1 +react-fast-refresh@0.2.7 react-meteor-data@2.7.2 reactive-dict@1.3.1 reactive-var@1.0.12 @@ -81,19 +81,19 @@ service-configuration@1.3.1 session@1.2.1 sha@1.0.9 shell-server@0.5.0 -socket-stream-client@0.5.0 +socket-stream-client@0.5.1 spacebars@1.3.0 spacebars-compiler@1.3.1 -standard-minifier-css@1.9.0 +standard-minifier-css@1.9.2 standard-minifier-js@2.8.1 static-html@1.3.2 templating@1.4.2 templating-compiler@1.4.1 -templating-runtime@1.6.1 +templating-runtime@1.6.3 templating-tools@1.2.2 -tracker@1.3.1 +tracker@1.3.2 typescript@4.9.4 -underscore@1.0.12 +underscore@1.0.13 url@1.3.2 -webapp@1.13.4 +webapp@1.13.5 webapp-hashing@1.1.1 diff --git a/imports/api/assets.js b/imports/api/assets.js index 5e1a4fa..f388846 100644 --- a/imports/api/assets.js +++ b/imports/api/assets.js @@ -10,6 +10,7 @@ import {AssetAssignmentHistory} from "/imports/api/asset-assignment-history"; export const Assets = new Mongo.Collection('assets'); export const conditions = ['New','Like New','Good','Okay','Damaged', 'Missing', 'Decommissioned'] +export const functionalConditions = ['New','Like New','Good','Okay'] /* const AssetsSchema = new SimpleSchema({ @@ -135,6 +136,24 @@ Meteor.methods({ } else throw new Meteor.Error("User Permission Error"); }, + 'assets.updateCondition'(_id, condition, conditionDetails) { + console.log("updating condtition: " + condition + " / " + conditionDetails) + check(_id, String) + check(condition, String) + if(conditionDetails) check(conditionDetails, String) + + if(!conditions.includes(condition)) { + //Should never happen. + console.error("Invalid condition option in assets.update(..)"); + throw new Meteor.Error("Invalid condition option."); + } + + if(Roles.userIsInRole(Meteor.userId(), "laptop-management", {anyScope:true})) { + console.log("updating .... ") + Assets.update({_id}, {$set: {condition, conditionDetails}}); + } + else throw new Meteor.Error("User Permission Error"); + }, 'assets.remove'(_id) { check(_id, String); diff --git a/imports/api/sites.js b/imports/api/sites.js index efe2f49..1aee66b 100644 --- a/imports/api/sites.js +++ b/imports/api/sites.js @@ -13,14 +13,14 @@ if (Meteor.isServer) { }); } Meteor.methods({ - 'sites.update'(_id, name) { + 'sites.update'(_id, name, externalId) { if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { - Sites.update({_id}, {$set: {name}}); + Sites.update({_id}, {$set: {name, externalId}}); } }, - 'sites.add'(name) { + 'sites.add'(name, externalId) { if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { - Sites.insert({name}); + Sites.insert({name, externalId}); } }, 'sites.remove'(_id) { diff --git a/imports/api/students.js b/imports/api/students.js index 6fdc83c..ace50a7 100644 --- a/imports/api/students.js +++ b/imports/api/students.js @@ -17,19 +17,23 @@ if (Meteor.isServer) { }); Meteor.methods({ - 'students.add'(id, firstName, lastName, email, siteId, grade) { + 'students.add'(id, firstName, firstNameAlias, lastName, email, siteId, grade, active) { if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { - Students.insert({id, firstName, lastName, email, siteId, grade}); + Students.insert({id, firstName, firstNameAlias, lastName, email, siteId, grade, active, activeChangeTimestamp: active ? "" : new Date()}); } }, - 'students.update'(_id, id, firstName, lastName, email, siteId, grade) { + 'students.update'(_id, id, firstName, firstNameAlias, lastName, email, siteId, grade, active) { if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { - Students.update({_id}, {$set: {id, firstName, lastName, email, siteId, grade}}); + Students.update({_id}, {$set: {id, firstName, firstNameAlias, lastName, email, siteId, grade, active, activeChangeTimestamp: active ? "" : new Date()}}); } }, 'students.remove'(_id) { + // Does not actually remove the student (not currently possible. Does set the student to not-active. + // If we want to remove students we should allow it for non-active students if there are no assets assigned. + // We may want to do this automatically, perhaps for students that have been non-active for a long period of time. if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { - //TODO: Need to first verify there are no checked out assets to the staff member. + // Set the student as non-active and set the timestamp for the change (so we know how long they have been inactive for - so we can potentially automatically remove them later. + Students.update({_id}, {$set: {active: false, activeChangeTimestamp: new Date()}}) } }, 'students.getPossibleGrades'() { @@ -56,95 +60,183 @@ if (Meteor.isServer) { * Expects the CSV string to contain comma delimited data in the form: * email, student ID, first name, last name, grade, first name alias, last name alias * - * The query in Aeries is: `LIST STU ID SEM FN LN NG FNA`. - * A more complete Aeries query: `LIST STU STU.ID STU.SEM STU.FN STU.LN STU.NG BY STU.NG STU.SEM IF STU.NG >= 7 AND NG <= 12 AND STU.NS = 5` + * The query in Aeries is: `LIST STU NS ID SEM FN LN NG FNA IF NG <= 12`. + * A more complete Aeries query (for grades 7-12 in school 5): `LIST STU STU.NS STU.ID STU.SEM STU.FN STU.LN STU.NG BY STU.NG STU.SEM IF STU.NG >= 7 AND NG <= 12 AND STU.NS = 5` * Note that FNA (First Name Alias) is optional. * Note that you might want to include a school ID in the IF if you have multiple schools in the district. - * The query in SQL is: `SELECT [STU].[ID] AS [Student ID], [STU].[SEM] AS [StuEmail], STU.FN AS [First Name], STU.LN AS [Last Name], [STU].[GR] AS [Grade], [STU].[FNA] AS [First Name Alias], [STU].[LNA] AS [Last Name Alias] FROM (SELECT [STU].* FROM STU WHERE [STU].DEL = 0) STU WHERE ( [STU].SC = 5) ORDER BY [STU].[LN], [STU].[FN];`. + * The query in SQL is: `SELECT [STU].[NS] AS [Next Schl], [STU].[ID] AS [Student ID], [STU].[SEM] AS [StuEmail], STU.FN AS [First Name], STU.LN AS [Last Name], [STU].[GR] AS [Grade], [STU].[FNA] AS [First Name Alias], [STU].[LNA] AS [Last Name Alias] FROM (SELECT [STU].* FROM STU WHERE [STU].DEL = 0) STU WHERE ( [STU].SC = 5) ORDER BY [STU].[LN], [STU].[FN];`. * Run the query in Aeries as a `Report`, select TXT, and upload here. + * + * Note: The headers for the CSV are not important and will be ignored. The order of the data is very important. * * Aeries adds a header per 'page' of data (I think 35 entries per page). * Example: * Anderson Valley Jr/Sr High School,6/11/2022 * 2021-2022,Page 1 * Student ID, Email, First Name,Last Name,Grade,(opt) First Name Alias - * @type: Currently only supports 'CSV' or 'Aeries Text Report' + * @type: Currently only supports 'csv' or 'aeries-txt' + * + * TODO: We are assuming that we are importing all active students from the external system. Any other assumption would require too much in the way of GUI + * TODO: Import should have a site id column that is the external site id. + * TODO: Each imported student should be attached to the correct site + * TODO: Any students not imported should be marked as deactivated */ - 'students.loadCsv'(csv, type, siteId) { - if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { - check(csv, String); - check(siteId, String); + 'students.loadCsv'(csv, type, test) { + try { + if (Roles.userIsInRole(Meteor.userId(), "admin", {anyScope: true})) { + check(csv, String) - let site = Sites.findOne({_id: siteId}); + let sites = Sites.find().fetch() + let sitesByExternalId = {} - if(site) { - let cleanCsv; - let lines = csv.split(/\r?\n/); - let pageHeader = type === 'Aeries Text Report' ? lines[0] : null; // Skip the repeating header lines for an Aeries text report. - let skip = type === 'CSV' ? 1 : 0; // Skip the first line of a CSV file (headers). + // Map all sites by external ID so we can quickly find the site for each imported student. + for (let site of sites) + if (site.externalId) + sitesByExternalId[site.externalId] = site + + //Note: Only include active students since we don't want to repeatedly make students non-active (resetting the timestamp). + let existingStudents = Students.find({active: true}).fetch() + let existingStudentIds = new Set() + + // Collect all pre-existing student ID's. Will remove them as we import, and use the remaining set to de-activate the students no longer in the district. + for (let student of existingStudents) + existingStudentIds.add(student.id) + + let cleanCsv + let lines = csv.split(/\r?\n/) + let pageHeader = type === 'aeries-txt' ? lines[0] : null // Skip the repeating header lines for an Aeries text report. + let skip = type === 'csv' ? 1 : 0 // Skip the first line of a CSV file (headers). // Remove headers from the CSV. - for(const line of lines) { - if (skip > 0) skip--; + for (const line of lines) { + if (skip > 0) skip-- else if (pageHeader && line === pageHeader) { - skip = 2; + skip = 2 } else { - if(!cleanCsv) cleanCsv = ""; - else cleanCsv += '\r\n'; - cleanCsv += line; + if (!cleanCsv) cleanCsv = "" + else cleanCsv += '\r\n' + cleanCsv += line } } - const bound = Meteor.bindEnvironment((callback) => {callback();}); + const bound = Meteor.bindEnvironment((callback) => { + callback(); + }) - parse(cleanCsv, {}, function(err, records) { + parse(cleanCsv, {}, function (err, records) { bound(() => { - if(err) console.error(err); - else { - let foundIds = new Set(); - let duplicates = []; - console.log("Found " + records.length + " records."); - - for(const values of records) { - let id = values[0]; - let email = values[1]; - let firstName = values[2]; - let lastName = values[3]; - let grade = parseInt(values[4], 10); - let firstNameAlias = ""; - let active = true; - - if(values.length > 5) firstNameAlias = values[5]; - - let student = {siteId, email, id, firstName, lastName, grade, firstNameAlias, active}; - - // Track the student ID's and record duplicates. This is used to ensure our counts are accurate later. - if(foundIds.has(student.id)) { - duplicates.push(student.id); - } - else { - foundIds.add(student.id); - } - - try { - Students.upsert({id: student.id}, {$set: student}); - } - catch(err) { - console.error(err); - } - } - - console.log(duplicates.length + " records were duplicates:"); - console.log(duplicates); - } - }); + readCsv(err, records, sitesByExternalId, existingStudentIds, test) + }) }) } - else { - console.log("Failed to find the site with the ID: " + siteId); - } + } catch(err) { + console.log(err) } } - }); + }) + + /** + * Reads the CSV file containing Student data and updates and adds students to the system. Students not in the CSV are marked as non-active. + * @param err + * @param records + * @param sitesByExternalId + * @param existingStudentIds + * @param test + * @returns {string} + */ + const readCsv = (err, records, sitesByExternalId, existingStudentIds, test) => { + let output = "" + + if (err) console.error(err) + else { + let foundIds = new Set() + let duplicates = [] + let count = 0 + let nonActiveCount = 0 + + if (test) + output += "Found " + records.length + " records.\r\n" + + try { + for(const values of records) { + let nextSchool = values[0] + let siteId = sitesByExternalId[nextSchool] ? sitesByExternalId[nextSchool]._id : null + let id = values[1] + let email = values[2] + let firstName = values[3] + let lastName = values[4] + let grade = parseInt(values[5], 10) + let firstNameAlias = "" + let active = true + + if (values.length > 6) firstNameAlias = values[6]; + + // Ignore students at a site not in the system. + if(siteId) { + let student = { siteId, email, id, firstName, lastName, grade, firstNameAlias, active, activeChangeTimestamp: ""} + + // Track the student ID's and record duplicates. This is used to ensure our counts are accurate later. + // Note: We should never have duplicates in a perfect system, but in reality we do seem to end up with some duplicates in the SIS system's data. + // There can be perfectly understandable reasons for this, so we will ignore them here since it shouldn't affect us. + if (foundIds.has(student.id)) { + duplicates.push(student.id) + } else { + foundIds.add(student.id) + } + + count++ + + if (!test) { + try { + existingStudentIds.delete(student.id) + Students.upsert({id: student.id}, {$set: student}) + } catch (err) { + console.log("Error while calling Students.upsert(..)") + console.error(err) + } + } else { + if (existingStudentIds.has(student.id)) { + existingStudentIds.delete(student.id) + output += "Updating existing student: " + student + "\r\n" + } else output += "Adding student: " + student + "\r\n" + } + } + } + } catch(err) { + console.log("Caught exception (while processing students imported via CSV): ") + console.log(err) + } + + // Change active status for all remaining students in the set (ones who were not in the import). + for (let studentId of existingStudentIds) { + nonActiveCount++ + + if (test) { + output += "Changing active status for student: " + Students.findOne({id: studentId}) + "\r\n" + } else { + try { + Students.update({id: studentId}, { + $set: { + active: false, + activeChangeTimestamp: new Date() + } + }) + } catch (err) { + console.log("Student ID: " + studentId) + console.log("Error updating Student to be non-active:") + console.log(err) + } + } + } + + console.log(duplicates.length + " records were duplicates:") + console.log(duplicates) + console.log("") + console.log("Added or updated " + count + " students.") + console.log("Update " + nonActiveCount + " students to non-active status.") + } + + return output + } } \ No newline at end of file diff --git a/imports/ui/App.jsx b/imports/ui/App.jsx index 5d7b3b5..5adc1c3 100644 --- a/imports/ui/App.jsx +++ b/imports/ui/App.jsx @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; import {Roles} from 'meteor/alanning:roles'; -import React, { useState } from 'react'; +import React, {useEffect, useState} from 'react'; import {createTheme, ThemeProvider} from '@mui/material/styles' import { useTracker } from 'meteor/react-meteor-data'; import _ from 'lodash'; diff --git a/imports/ui/pages/Admin/Sites.jsx b/imports/ui/pages/Admin/Sites.jsx index ae60a66..9dbbaf0 100644 --- a/imports/ui/pages/Admin/Sites.jsx +++ b/imports/ui/pages/Admin/Sites.jsx @@ -28,14 +28,15 @@ const cssButtonContainer = { const SiteEditor = ({value, close}) => { const [name, setName] = useState(value.name || "") + const [externalId, setExternalId] = useState(value.externalId || "") const applyChanges = () => { close() //TODO Should invert this and only close if there was success on the server. if(value._id) - Meteor.call("sites.update", value._id, name); + Meteor.call("sites.update", value._id, name, externalId); else - Meteor.call("sites.add", name); + Meteor.call("sites.add", name, externalId); } const rejectChanges = () => { close() @@ -45,6 +46,7 @@ const SiteEditor = ({value, close}) => {

Site Editor

{setName(e.target.value)}}/> + {setExternalId(e.target.value)}}/>
@@ -68,6 +70,10 @@ export default () => { name: "Name", value: (row) => row.name, }, + { + name: "External ID", + value: (row) => row.externalId, + }, ] const options = { diff --git a/imports/ui/pages/Admin/Students.jsx b/imports/ui/pages/Admin/Students.jsx index d1f7a29..c26033e 100644 --- a/imports/ui/pages/Admin/Students.jsx +++ b/imports/ui/pages/Admin/Students.jsx @@ -9,6 +9,17 @@ import Select from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import {Students} from "/imports/api/students"; import {Sites} from "/imports/api/sites"; +import Box from "@mui/material/Box"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import ToggleButton from "@mui/material/ToggleButton"; +import {InputLabel, List, ListItemButton, ListItemText, Switch} from "@mui/material"; +import FormControl from "@mui/material/FormControl"; +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 FormControlLabel from "@mui/material/FormControlLabel"; +import Checkbox from "@mui/material/Checkbox"; const cssSitesSelect = { margin: '0.6rem 0', @@ -24,7 +35,7 @@ const cssFieldColumnContainer = { } const cssGridFieldContainer = { display: 'grid', - gridTemplateColumns: "1fr 1fr 1fr", + gridTemplateColumns: "1fr 1fr 1fr 1fr", columnGap: '1rem', rowGap: '0.4rem', marginBottom: '1.5rem' @@ -39,8 +50,10 @@ const StudentEditor = ({value, close, defaultSiteId}) => { const [email, setEmail] = useState(value.email || "") const [id, setId] = useState(value.id || "") const [firstName, setFirstName] = useState(value.firstName || "") + const [firstNameAlias, setFirstNameAlias] = useState(value.firstNameAlias || "") const [lastName, setLastName] = useState(value.lastName || "") const [grade, setGrade] = useState(value.grade || "") + const [active, setActive] = useState(value.active) const [siteId, setSiteId] = useState(value.siteId ? value.siteId : defaultSiteId) const {sites} = useTracker(() => { @@ -57,9 +70,9 @@ const StudentEditor = ({value, close, defaultSiteId}) => { close() //TODO Should invert this and only close if there was success on the server. if(value._id) - Meteor.call("students.update", value._id, id, firstName, lastName, email, siteId, grade); + Meteor.call("students.update", value._id, id, firstName, firstNameAlias, lastName, email, siteId, grade, active); else - Meteor.call("students.add", id, firstName, lastName, email, siteId, grade); + Meteor.call("students.add", id, firstName, firstNameAlias, lastName, email, siteId, grade, active); } const rejectChanges = () => { close() @@ -72,7 +85,9 @@ const StudentEditor = ({value, close, defaultSiteId}) => { {setId(e.target.value)}}/> {setEmail(e.target.value)}}/> {setGrade(e.target.value)}}/> + {setActive(e.target.checked)}}/>} label="Active"/> {setFirstName(e.target.value)}}/> + {setFirstNameAlias(e.target.value)}}/> {setLastName(e.target.value)}}/> {setSiteId(e.target.value)}}> {sites.map((next, i) => { @@ -103,8 +118,23 @@ export default () => { return {sites} }); + const ACTIVE_BOTH = "both" + const ACTIVE_ONLY = "active" + const ACTIVE_OFF = "inactive" + const [active, setActive] = useState(ACTIVE_BOTH) + const [nameSearch, setNameSearch] = useState("") + const {students} = useTracker(() => { const studentQuery = site === siteAll._id ? {} : {siteId: site} + + if(active !== ACTIVE_BOTH) { + studentQuery["active"] = active === ACTIVE_ONLY + } + + if(nameSearch && nameSearch.length > 2) { + studentQuery["$or"] = [{firstName: {$regex: nameSearch, $options: 'i'}}, {firstNameAlias: {$regex: nameSearch, $options: 'i'}}, {lastName: {$regex: nameSearch, $options: 'i'}}] + } + let students = Students.find(studentQuery).fetch(); return {students} @@ -132,6 +162,10 @@ export default () => { name: "First Name", value: (row) => row.firstName, }, + { + name: "Alias", + value: (row) => row.firstNameAlias, + }, { name: "Last Name", value: (row) => row.lastName, @@ -149,6 +183,10 @@ export default () => { else return 1 } }, + { + name: "Active", + value: (row) => row.active ? "Active" : "Inactive", + }, ] const options = { @@ -162,14 +200,81 @@ export default () => { } } } + + const importData = (type) => { + let input = document.createElement('input') + input.type = 'file' + input.onchange = _ => { + let files = Array.from(input.files) + if(files.length === 1) { + let reader = new FileReader() + reader.onload = () => { + Meteor.call("students.loadCsv", reader.result, type, testImportOnly, (err, result) => { + //Note: It would be nice to have feedback about the operation, but right now I cannot figure out how to wait on the server for the result of the callback that is wrapped by a bindEnvironment call. + if(err) console.log(err) + // else if(testImportOnly) console.log(result) + }) + } + reader.readAsText(input.files[0]) + } + } + input.click() + } + + const [showImportDialog, setShowImportDialog] = useState(false) + const [testImportOnly, setTestImportOnly] = useState(false) + + const openImportDialog = () => { + setTestImportOnly(false) + setShowImportDialog(true) + } + const closeImportDialog = (cause, importType) => { + if(importType) importData(importType) + + if(showImportDialog) setShowImportDialog(false) + } + return ( <> - {setSite(e.target.value)}}> - {sites.map((next, i) => { - return {next.name} - })} - + + Import + +

Imports students for the entire district and deals with altering the "active" flag for students no longer in the district or who have graduated.

+

Middle of Year

+
LIST STU SC ID SEM FN LN GR FNA
+

Run any time during the year to capture changes in student body.

+

End of Year

+
LIST STU NS ID SEM FN LN NG FNA IF NG >= 12
+

Use this query for the import if the school year is over (before summer school). It utilizes the next school & next grade fields instead of the current grade/school.

+ setTestImportOnly(e.target.checked)}/>} label="Test Only"/> +
+ + + + + +
+ + + {setSite(e.target.value)}}> + {sites.map((next, i) => { + return {next.name} + })} + + + Active Students + {setActive(e.target.value)}} aria-label="Active Students"> + All + Active + Inactive + + + {setNameSearch(e.target.value)}}/> + + + + ) diff --git a/imports/ui/pages/Assignments.jsx b/imports/ui/pages/Assignments.jsx index 15d1bd0..80d49a9 100644 --- a/imports/ui/pages/Assignments.jsx +++ b/imports/ui/pages/Assignments.jsx @@ -24,6 +24,15 @@ export default () => { path: '/byAsset', href: 'byAsset' }, + { + title: "Report", + getElement: () => { + const AssignmentsReport = lazy(()=>import('./Assignments/Report')) + return + }, + path: '/report', + href: 'report' + }, ] return diff --git a/imports/ui/pages/Assignments/ByAsset.jsx b/imports/ui/pages/Assignments/ByAsset.jsx index 5264b99..8ac6585 100644 --- a/imports/ui/pages/Assignments/ByAsset.jsx +++ b/imports/ui/pages/Assignments/ByAsset.jsx @@ -42,6 +42,7 @@ const AssignmentsByAsset = () => { //Dialog stuff. const [openUnassignDialog, setOpenUnassignDialog] = useState(false) + const [unassignDialogEditConditionOnly, setUnassignDialogEditConditionOnly] = useState(false) const [unassignCondition, setUnassignCondition] = useState(conditions[2]) const [unassignComment, setUnassignComment] = useState("") const [unassignConditionDetails, setUnassignConditionDetails] = useState("") @@ -71,8 +72,9 @@ const AssignmentsByAsset = () => { // if(assetIdInput) assetIdInput.focus() // }) - const unassign = () => { + const unassign = (editConditionOnly) => { // Open the dialog to get condition and comment. + setUnassignDialogEditConditionOnly(editConditionOnly) setUnassignComment("") setUnassignCondition(foundAsset.condition ? foundAsset.condition : conditions[2]) setUnassignConditionDetails(foundAsset.conditionDetails || "") @@ -82,18 +84,26 @@ const AssignmentsByAsset = () => { setOpenUnassignDialog(false) if(unassign === true) { - // Call assets.unassign(assetId, comment, condition, conditionDetails, date) - Meteor.call('assets.unassign', foundAsset.assetId, unassignComment, unassignCondition, unassignConditionDetails, (err, result) => { - if(err) console.error(err) - else if(assetIdInput) assetIdInput.focus() - }) + if(unassignDialogEditConditionOnly) { + Meteor.call('assets.updateCondition', unassignAsset._id, unassignCondition, unassignConditionDetails, (err, result) => { + if(err) console.error(err) + else if(assetIdInput) assetIdInput.focus() + }) + } + else { + // Call assets.unassign(assetId, comment, condition, conditionDetails, date) + Meteor.call('assets.unassign', foundAsset.assetId, unassignComment, unassignCondition, unassignConditionDetails, (err, result) => { + if (err) console.error(err) + else if (assetIdInput) assetIdInput.focus() + }) + } } } return ( <> - Unassign Asset + {unassignDialogEditConditionOnly ? "Edit Condition" : "Unassign Asset"}
{setUnassignCondition(e.target.value)}}> @@ -102,11 +112,11 @@ const AssignmentsByAsset = () => { })}
- {setUnassignComment(e.target.value)}}/> + {!unassignDialogEditConditionOnly && {setUnassignComment(e.target.value)}}/>} {setUnassignConditionDetails(e.target.value)}}/>
- +
@@ -123,7 +133,9 @@ const AssignmentsByAsset = () => { <>
Assigned on: {foundAsset.assignmentDate.toString()}
Assigned to: {foundAsset.assignee.firstName} {foundAsset.assignee.lastName} {foundAsset.assignee.grade && foundAsset.assignee.grade} ({foundAsset.assignee.email})
- + + {" "} + )}
diff --git a/imports/ui/pages/Assignments/ByPerson.jsx b/imports/ui/pages/Assignments/ByPerson.jsx index 97a4062..0e71b07 100644 --- a/imports/ui/pages/Assignments/ByPerson.jsx +++ b/imports/ui/pages/Assignments/ByPerson.jsx @@ -6,7 +6,7 @@ import _ from 'lodash'; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import MenuItem from '@mui/material/MenuItem'; -import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material"; +import {InputLabel, List, ListItem, ListItemButton, ListItemText, Switch} from "@mui/material"; import Box from "@mui/material/Box"; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; @@ -19,7 +19,9 @@ import {Assets, conditions} from "/imports/api/assets"; import {AssetTypes} from "/imports/api/asset-types"; import {Students} from "/imports/api/students"; import {Staff} from "/imports/api/staff"; -import {Link} from "react-router-dom"; +import {Link, useLocation, useNavigate, useNavigationType} from "react-router-dom"; +import { Action as NavigationType } from "@remix-run/router"; +import FormControlLabel from "@mui/material/FormControlLabel"; const cssTwoColumnContainer = { display: 'grid', @@ -32,10 +34,15 @@ const cssEditorField = { } const AssignmentsByPerson = () => { - const theme = useTheme(); - const [searchType, setSearchType] = useState("Email") - const [search, setSearch] = useState("") - const [selectedPerson, setSelectedPerson] = useState("") + const navigate = useNavigate() + const navigateType = useNavigationType() + const location = useLocation() + const state = location.state + const theme = useTheme() + // const [searchType, setSearchType] = useState("Email") + const [search, setSearch] = useState(state && state.search ? state.search : "") + const [includeInactive, setIncludeInactive] = useState(false) + const [selectedPerson, setSelectedPerson] = useState(state && state.person ? state.person : "") const [assetId, setAssetId] = useState("") const [openAssignDialog, setOpenAssignDialog] = useState(false) const [assignCondition, setAssignCondition] = useState(conditions[2]) @@ -43,6 +50,7 @@ const AssignmentsByPerson = () => { //Dialog stuff. const [openUnassignDialog, setOpenUnassignDialog] = useState(false) + const [unassignDialogEditConditionOnly, setUnassignDialogEditConditionOnly] = useState(false) const [unassignCondition, setUnassignCondition] = useState(conditions[2]) const [unassignComment, setUnassignComment] = useState("") const [unassignConditionDetails, setUnassignConditionDetails] = useState("") @@ -56,13 +64,17 @@ const AssignmentsByPerson = () => { if(search && search.length > 1) { let query; - if(searchType === "Email") { - query = {email: {$regex: search, $options: 'i'}}; - } else if(searchType === 'First Name') { - query = {firstName: {$regex: search, $options: 'i'}} - } else { - query = {lastName: {$regex: search, $options: 'i'}} - } + query = {$or: [{email: {$regex: search, $options: 'i'}}, {firstName: {$regex: search, $options: 'i'}}, {firstNameAlias: {$regex: search, $options: 'i'}}, {lastName: {$regex: search, $options: 'i'}}]} + // if(searchType === "Email") { + // query = {email: {$regex: search, $options: 'i'}}; + // } else if(searchType === 'First Name') { + // query = {firstName: {$regex: search, $options: 'i'}} + // } else { + // query = {lastName: {$regex: search, $options: 'i'}} + // } + + // Look for students/staff that are active or whose active flag is not set. + if(!includeInactive) query = {$and: [query, {$or: [{active: true}, {active: {$exists: false}}]}]} const students = Students.find(query).fetch(); const staff = Staff.find(query).fetch(); @@ -143,8 +155,9 @@ const AssignmentsByPerson = () => { // if(assetIdInput) assetIdInput.focus() // }) - const unassign = (asset) => { + const unassign = (asset, editConditionOnly) => { // Open the dialog to get condition and comment. + setUnassignDialogEditConditionOnly(editConditionOnly) setUnassignAsset(asset); setUnassignComment("") setUnassignCondition(asset.condition ? asset.condition : conditions[2]) @@ -155,11 +168,19 @@ const AssignmentsByPerson = () => { setOpenUnassignDialog(false) if(unassign === true) { - // Call assets.unassign(assetId, comment, condition, conditionDetails, date) - Meteor.call('assets.unassign', unassignAsset.assetId, unassignComment, unassignCondition, unassignConditionDetails, (err, result) => { - if(err) console.error(err) - else if(assetIdInput) assetIdInput.focus() - }) + if(unassignDialogEditConditionOnly) { + Meteor.call('assets.updateCondition', unassignAsset._id, unassignCondition, unassignConditionDetails, (err, result) => { + if(err) console.error(err) + else if(assetIdInput) assetIdInput.focus() + }) + } + else { + // Call assets.unassign(assetId, comment, condition, conditionDetails, date) + Meteor.call('assets.unassign', unassignAsset.assetId, unassignComment, unassignCondition, unassignConditionDetails, (err, result) => { + if(err) console.error(err) + else if(assetIdInput) assetIdInput.focus() + }) + } } } @@ -171,6 +192,20 @@ const AssignmentsByPerson = () => { userSelect: 'none', // '&:nthChild(even)': {backgroundColor: '#935e5e'} } + + const changeSelectedPerson = (person) => { + setSelectedPerson(person) + navigate("/assignments", {replace: false, state: {person, search}}); + } + useEffect(() => { + if(!state) navigate("/assignments/byPerson", {replace: true, state: {search: "", person: null}}) + else { + if(navigateType === NavigationType.Pop) { + setSearch(state.search) + setSelectedPerson(state.person) + } + } + }) return ( <> @@ -193,7 +228,7 @@ const AssignmentsByPerson = () => { - Unassign Asset + {unassignDialogEditConditionOnly ? "Edit Condition" : "Unassign Asset"}
{setUnassignCondition(e.target.value)}}> @@ -202,47 +237,51 @@ const AssignmentsByPerson = () => { })}
- {setUnassignComment(e.target.value)}}/> + {!unassignDialogEditConditionOnly && {setUnassignComment(e.target.value)}}/>} {setUnassignConditionDetails(e.target.value)}}/>
- +
- - setSearchType(type)} aria-label="Search Type"> - Email - First Name - Last Name - + + {/*setSearchType(type)} aria-label="Search Type">*/} + {/* Email*/} + {/* First Name*/} + {/* Last Name*/} + {/**/} + {setIncludeInactive(e.target.checked)}}/>} label="Inactive"/> {setSearch(e.target.value)}}/> - -
+ +
{people.map((next, i) => { return ( - {setSelectedPerson(next)}}> - + {changeSelectedPerson(next)}}> + ) })}
-
+
{selectedPerson && ( -
-
setAssetIdInput(input)} style={cssEditorField} variant="standard" label="Asset ID" value={assetId} onChange={(e) => {setAssetId(e.target.value.toUpperCase())}}/>
-
{foundAsset && foundAsset.assetType.name}
-
{foundAsset && {foundAsset.assetId}}
-
{foundAsset && {foundAsset.serial}}
- {foundAsset && foundAsset.assignee && ( -
Assigned To: {foundAsset.assignee.firstName} {foundAsset.assignee.lastName}
- )} - -
+ <> +

{selectedPerson.firstName + " " + (selectedPerson.firstNameAlias ? "'" + selectedPerson.firstNameAlias + "' " : "") + selectedPerson.lastName + (selectedPerson.grade ? " (" + selectedPerson.grade + ")" : "")}

+
+
setAssetIdInput(input)} style={cssEditorField} variant="standard" label="Asset ID" value={assetId} onChange={(e) => {setAssetId(e.target.value.toUpperCase())}}/>
+
{foundAsset && foundAsset.assetType.name}
+
{foundAsset && {foundAsset.assetId}}
+
{foundAsset && {foundAsset.serial}}
+ {foundAsset && foundAsset.assignee && ( +
Assigned To: {foundAsset.assignee.firstName} {foundAsset.assignee.lastName}
+ )} + +
+ )} {assets.map((next, i) => { return ( @@ -250,7 +289,9 @@ const AssignmentsByPerson = () => {
{next.assetType.name}
{next.assetId}
{next.serial}
- + + {" "} +
) })} diff --git a/imports/ui/pages/Assignments/Report.jsx b/imports/ui/pages/Assignments/Report.jsx new file mode 100644 index 0000000..604a871 --- /dev/null +++ b/imports/ui/pages/Assignments/Report.jsx @@ -0,0 +1,168 @@ +import {Meteor} from "meteor/meteor"; +import React from "react"; +import {useTheme} from "@mui/material/styles"; +import {useTracker} from "meteor/react-meteor-data"; +import {AssetTypes} from "/imports/api/asset-types"; +import {Assets} from "/imports/api/assets"; +import {Students} from "/imports/api/students"; +import {Staff} from "/imports/api/staff"; +import SimpleTable from "/imports/ui/util/SimpleTable"; +import moment from "moment"; +import {Box, Grid} from "@mui/material"; +import TextField from "@mui/material/TextField"; +import MenuItem from "@mui/material/MenuItem"; +import Button from "@mui/material/Button"; + +const AssignmentsReport = () => { + const theme = useTheme(); + + const {people} = useTracker(() => { + let assets = Assets.find({assigneeId: {$exists: true}, assigneeType: "Student"}).fetch() + let assetTypes = AssetTypes.find().fetch() + let students = Students.find().fetch() + let people = [] + + let assetTypesById = assetTypes.reduce((map, obj) => {map[obj._id] = obj; return map}, {}) + let studentsById = students.reduce((map, obj) => {map[obj._id] = obj; return map}, {}) + // let staffById = staff.reduce((map, obj) => {map[obj._id] = obj; return map}, {}) + + for(let next of assets) { + let student = studentsById[next.assigneeId] + let assetType = assetTypesById[next.assetTypeId] + + people.push({_id: next._id, firstName: student.firstName, lastName: student.lastName, grade: student.grade, assetName: assetType.name, checkOutDate: next.assignmentDate, condition: next.condition, assetTag: next.assetId}) + } + + people.sort((a, b) => { + let firstName = a.firstName.localeCompare(b.firstName) + let lastName = a.lastName.localeCompare(b.lastName) + + return a.grade === b.grade ? lastName ? lastName : firstName : b.grade - a.grade + }) + + return {people} + }); + + const exportData = () => { + let csv + + for(let next of people) { + if(csv) csv += "\r\n" + else csv = "First Name;Last Name;Grade;Asset Name;Check Out Date;Condition;Asset Tag\r\n" + + csv += next.firstName + ";" + next.lastName + ";" + next.grade + ";" + next.assetName + ";" + next.checkOutDate + ";" + next.condition + ";" + next.assetTag + ";" + } + + let blob = new Blob([csv], {type: 'text/csv'}) + let a = document.createElement('a') + a.download = 'export.csv' + a.href = window.URL.createObjectURL(blob) + a.click() + } + + // const {checkedOutAssets} = useTracker(() => { + // let assets = Assets.find({assigneeId: {$exists: true}}).fetch() + // let assetTypes = AssetTypes.find().fetch() + // let students = Students.find().fetch() + // let staff = Staff.find().fetch() + // + // let assetTypesById = assetTypes.reduce((map, obj) => {map[obj._id] = obj; return map}, {}) + // let studentsById = students.reduce((map, obj) => {map[obj._id] = obj; return map}, {}) + // let staffById = staff.reduce((map, obj) => {map[obj._id] = obj; return map}, {}) + // + // for(let next of assets) { + // next.assetType = assetTypesById[next.assetTypeId] + // if(next.assigneeType === "Staff") + // next.person = staffById[next.assigneeId] + // else + // next.person = studentsById[next.assigneeId] + // } + // + // let checkedOutAssets = []; + // + // for(let next of assets) { + // if(next.assigneeType === "Student") { + // checkedOutAssets.push(next); + // } + // } + // + // return {checkedOutAssets} + // }); + + // people.push({firstName: student.firstName, lastName: student.lastName, grade: student.grade, assetName: assetType.name, checkOutDate: next.assignmentDate, condition: next.condition, assetTag: next.assetTag}) + const columns = [ + { + name: "First Name", + //value: (checkedOutAsset) => checkedOutAsset.person ? checkedOutAsset.person.firstName : "" + value: (next) => next.firstName + }, + { + name: "Last Name", + // value: (checkedOutAsset) => checkedOutAsset.person ? checkedOutAsset.person.lastName : "" + value: (next) => next.lastName + }, + { + name: "Grade", + value: (next) => next.grade, + }, + { + name: "Asset", + value: (next) => next.assetName, + }, + { + name: "Asset Tag", + value: (next) => next.assetTag, + }, + { + name: "Checkout Date", + value: (next) => moment(next.assignmentDate).format("YYYY-MM-DD"), + }, + { + name: "Condition", + value: (next) => next.condition, + }, + ] + + const options = { + key: (row) => row._id, + // editor: (row, close) => {return ()}, + add: false, + maxHeight: '40rem', + keyHandler: (e, selected) => { + // if(selected && selected._id && e.key === "Delete") { + // Meteor.call("assetTypes.remove", selected._id); + // } + } + } + + return ( + <> + + + + {/*

Filter

*/} + {/* */} + {/* */} + {/* {setAssetTypeId(e.target.value)}} label="Grade">*/} + {/* {assetTypes.map((assetType, i) => {*/} + {/* return {assetType.name}*/} + {/* })}*/} + {/* */} + {/* */} + {/* */} +
+ + + ) +} + +export default () => { + Meteor.subscribe('students'); + Meteor.subscribe('staff'); + Meteor.subscribe('assetTypes'); + Meteor.subscribe('assets'); + + return ( + + ) +} \ No newline at end of file diff --git a/imports/ui/pages/Home.jsx b/imports/ui/pages/Home.jsx index c47c963..d28d58e 100644 --- a/imports/ui/pages/Home.jsx +++ b/imports/ui/pages/Home.jsx @@ -15,7 +15,7 @@ import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; -import {Assets, conditions} from "/imports/api/assets"; +import {Assets, conditions, functionalConditions} from "/imports/api/assets"; import {AssetTypes} from "/imports/api/asset-types"; import {Students} from "/imports/api/students"; import {Staff} from "/imports/api/staff"; @@ -34,6 +34,21 @@ const cssEditorField = { const Statistics = () => { const [selectedMissingAsset, setSelectedMissingAsset] = useState("") + const {assetStatistics} = useTracker(() => { + let assetStatistics = [] + const assetTypes = AssetTypes.find({}, {year: 1}).fetch() + + for(let type of assetTypes) { + let count = Assets.find({assetTypeId: type._id, condition: {$in: functionalConditions}}).count() + + if(count > 0) { + assetStatistics.push({name: type.name, count}) + } + } + + return {assetStatistics} + }) + const {missingAssets} = useTracker(() => { let missingAssets = []; @@ -54,16 +69,32 @@ const Statistics = () => { return ( <> -

Missing Equipment

- - {missingAssets.map((next, i) => { - return ( - {setSelectedMissingAsset(next)}}> - - - ) - })} - +
+
+

Equipment Counts

+ + {assetStatistics.map((next, i) => { + return ( + + + + ) + })} + +
+
+

Missing Equipment

+ + {missingAssets.map((next, i) => { + return ( + {setSelectedMissingAsset(next)}}> + + + ) + })} + +
+
) } diff --git a/package.json b/package.json index ecc3b86..d0c5eb4 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "@lexical/headless": "^0.5.0", "@lexical/link": "^0.5.0", "@lexical/react": "^0.5.0", - "@mui/icons-material": "^5.10.2", - "@mui/material": "^5.10.2", + "@mui/icons-material": "^5.11.16", + "@mui/material": "^5.13.4", "bcrypt": "^5.0.1", "classnames": "^2.2.6", "csv-parse": "^5.3.0",