diff --git a/.meteor/packages b/.meteor/packages index 8990821..6e283e7 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -6,24 +6,25 @@ 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.15.0 # 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.11 # Reactive variable for tracker +reactive-var@1.0.12 # Reactive variable for tracker -standard-minifier-css@1.8.1 # CSS minifier run for production mode -standard-minifier-js@2.8.0 # JS 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.2 # Enable ECMAScript2015+ syntax in app code -typescript # Enable TypeScript syntax in .ts and .tsx modules +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 static-html@1.3.2 # Define static page content in .html files react-meteor-data # React higher-order component for reactively tracking Meteor data accounts-ui@1.4.2 -accounts-password@2.3.1 +accounts-password@2.3.4 accounts-google@1.4.0 -service-configuration@1.3.0 +service-configuration@1.3.1 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 66dd7b6..e8cfc7e 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@2.7.3 +METEOR@2.12 diff --git a/.meteor/versions b/.meteor/versions index 0f7c141..45a310f 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,41 +1,41 @@ -accounts-base@2.2.4 +accounts-base@2.2.8 accounts-google@1.4.0 -accounts-oauth@1.4.1 -accounts-password@2.3.1 +accounts-oauth@1.4.2 +accounts-password@2.3.4 accounts-ui@1.4.2 accounts-ui-unstyled@1.7.0 alanning:roles@3.4.0 allow-deny@1.1.1 autoupdate@1.8.0 -babel-compiler@7.9.2 +babel-compiler@7.10.4 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 -blaze@2.5.0 +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.4.0 -check@1.3.1 -ddp@1.4.0 -ddp-client@2.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.0 -ddp-server@2.5.0 -diff-sequence@1.1.1 -dynamic-import@0.7.2 -ecmascript@0.16.2 -ecmascript-runtime@0.8.0 +ddp-rate-limiter@1.2.0 +ddp-server@2.6.1 +diff-sequence@1.1.2 +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.2 -email@2.2.1 +ejson@1.1.3 +email@2.2.5 es5-shim@4.8.0 -fetch@0.1.1 -geojson-utils@1.0.10 +fetch@0.1.3 +geojson-utils@1.0.11 google-config-ui@1.0.3 -google-oauth@1.4.2 +google-oauth@1.4.3 hot-code-push@1.0.4 html-tools@1.1.3 htmljs@1.1.1 @@ -43,57 +43,57 @@ id-map@1.1.1 inter-process-messaging@0.1.1 jquery@3.0.0 launch-screen@1.3.0 -less@3.0.2 +less@4.0.0 localstorage@1.2.0 -logging@1.3.1 -meteor@1.10.0 +logging@1.3.2 +meteor@1.11.2 meteor-base@1.5.1 meteortoys:toykit@10.0.0 -minifier-css@1.6.1 +minifier-css@1.6.4 minifier-js@2.7.5 -minimongo@1.8.0 +minimongo@1.9.3 mobile-experience@1.1.0 mobile-status-bar@1.1.0 -modern-browsers@0.1.8 -modules@0.18.0 -modules-runtime@0.13.0 -mongo@1.15.0 +modern-browsers@0.1.9 +modules@0.19.0 +modules-runtime@0.13.1 +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.3.1 -oauth@2.1.2 -oauth2@1.3.1 -observe-sequence@1.0.20 +npm-mongo@4.16.0 +oauth@2.2.0 +oauth2@1.3.2 +observe-sequence@1.0.21 ordered-dict@1.1.0 -promise@0.12.0 -random@1.2.0 -rate-limit@1.0.9 -react-fast-refresh@0.2.3 -react-meteor-data@2.5.1 -reactive-dict@1.3.0 -reactive-var@1.0.11 +promise@0.12.2 +random@1.2.1 +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 reload@1.3.1 retry@1.1.0 routepolicy@1.1.1 -service-configuration@1.3.0 -session@1.2.0 +service-configuration@1.3.1 +session@1.2.1 sha@1.0.9 shell-server@0.5.0 -socket-stream-client@0.5.0 -spacebars@1.2.0 +socket-stream-client@0.5.1 +spacebars@1.3.0 spacebars-compiler@1.3.1 -standard-minifier-css@1.8.2 +standard-minifier-css@1.9.2 standard-minifier-js@2.8.1 static-html@1.3.2 -templating@1.4.1 +templating@1.4.2 templating-compiler@1.4.1 -templating-runtime@1.5.0 +templating-runtime@1.6.3 templating-tools@1.2.2 -tracker@1.2.0 -typescript@4.5.4 -underscore@1.0.10 +tracker@1.3.2 +typescript@4.9.4 +underscore@1.0.13 url@1.3.2 -webapp@1.13.1 -webapp-hashing@1.1.0 +webapp@1.13.5 +webapp-hashing@1.1.1 diff --git a/imports/api/asset-assignment-history.js b/imports/api/asset-assignment-history.js index 3a7af45..1c6f5ef 100644 --- a/imports/api/asset-assignment-history.js +++ b/imports/api/asset-assignment-history.js @@ -34,8 +34,16 @@ if (Meteor.isServer) { * @returns {any} Array of Asset Assignment History objects. */ 'AssetAssignmentHistory.get'(params) { + let result + if(Roles.userIsInRole(Meteor.userId(), "laptop-management", {anyScope:true})) { - let query = {}; + let query = {} + let person + let asset + let assetType + + // console.log("AssetAssignmentHistory: ") + // console.log(params) if(params.studentId) check(params.studentId, String) if(params.staffId) check(params.staffId, String) @@ -56,81 +64,52 @@ if (Meteor.isServer) { } } - if(params.serial) query.serial = params.serial; - else if(params.assetId) query.assetId = params.assetId; - else if(params.deviceId) query.deviceId = params.deviceId; - else if(params.studentId) { - query.assigneeId = params.studentId - query.assigneeType = "Student" - } - else if(params.staffId) { - query.assigneeId = params.staffId - query.assigneeType = "Staff" + if(params.serial || params.assetId || params.deviceId) { + if(params.serial) query.serial = params.serial; + else if(params.assetId) query.assetId = params.assetId; + else if(params.deviceId) query.deviceId = params.deviceId; + + asset = Assets.findOne(query) + if(asset) assetType = AssetTypes.findOne({_id: asset.assetTypeId}) } 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(); - let assets = []; - - // Get the current assignment for the device or person. - if(query.assetId || query.deviceId || query.serial) { - let asset = Assets.findOne(query) - - if(asset) assets = [asset] + if(params.studentId) { + query.assigneeId = params.studentId + query.assigneeType = "Student" + } + else if(params.staffId) { + query.assigneeId = params.staffId + query.assigneeType = "Staff" } else { - // Find the assets assigned to the person. - assets = Assets.find({assigneeId: params.studentId ? params.studentId : params.staffId}).fetch() + query = undefined; } + + person = query.assigneeType === "Student" ? Students.findOne({id: query.assigneeId}) : Staff.findOne({id: query.assigneeId}) + } + + if(query) { + //Sort by the last time the record was updated from most to least recent. + result = AssetAssignmentHistory.find(query, {sort: {endDate: -1}}).fetch(); - // Prepend a partial assignment history record to the list. We want to show active assignments in the results. - for(let asset of assets) { - if(asset && asset.assigneeId) { - let assetType = AssetTypes.findOne(asset.assetTypeId) - let current = { - _id: 0, - assetKey: asset._id, - assetId: asset.assetId, - serial: asset.serial, - assetTypeName: assetType.name, - assigneeType: asset.assigneeType, - assigneeId: asset.assigneeId, - startDate: asset.assignmentDate, - startCondition: asset.condition, - startConditionDetails: asset.conditionDetails - } - - result = [current, ...result] - } - } - - //Add some additional data to the records. + //Expand the assignee, asset, and asset type data. for(let next of result) { - // console.log(next) - if(next.assetKey) { - next.asset = Assets.findOne({_id: next.assetKey}) + if(person) next.assignee = person + else next.assignee = next.assigneeType === "Student" ? Students.findOne({_id: next.assigneeId}) : Staff.findOne({_id: next.assigneeId}) + + if(asset) { + next.asset = asset + next.assetType = assetType } - else if(next.assetId) { - next.asset = Assets.findOne({assetId: next.assetId}); - } - - if(next.asset) { - next.assetType = AssetTypes.findOne({_id: next.asset.assetTypeId}) - } - - if(next.assigneeId) { - next.assignee = next.asset.assigneeType === "Student" ? Students.findOne({_id: next.assigneeId}) : Staff.findOne({_id: next.assigneeId}) + else { + next.asset = Assets.findOne({assetId: next.assetId}) + if(next.asset) next.assetType = AssetTypes.findOne({_id: next.asset.assetTypeId}) } } - - return result; - } else return null; + } } - else return null; + + return result } }); } \ No newline at end of file diff --git a/imports/api/assets.js b/imports/api/assets.js index 5e1a4fa..35f7e1e 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({ @@ -122,6 +123,7 @@ Meteor.methods({ if(serial) check(serial, String); check(condition, String); if(conditionDetails) check(conditionDetails, String); + const existing = Assets.findOne({_id}) if(!conditions.includes(condition)) { //Should never happen. @@ -132,6 +134,30 @@ Meteor.methods({ if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { //TODO: Need to first verify there are no checked out assets to the staff member. Assets.update({_id}, {$set: {assetTypeId, assetId, serial, condition, conditionDetails}}); + + if(assetId !== existing.assetId) { + //When changing the asset id we also need to update the other locations in the data where that ID exists. + // assetAssignmentHistory.assetId + AssetAssignmentHistory.updateMany({assetId: existing.assetId}, {$set: {assetId}}) + } + } + 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"); }, @@ -198,7 +224,7 @@ Meteor.methods({ throw new Meteor.Error("Asset is already assigned.", "Cannot assign an asset that has already been assigned."); } else { - Assets.update({assetId}, {$set: {assigneeType, assigneeId, assignmentDate: date, condition, conditionDetails}}); + Assets.update({assetId}, {$set: {assigneeType, assigneeId, assignmentDate: date, condition, conditionDetails, assignedBy: Meteor.userId()}}); } } else { @@ -238,11 +264,11 @@ Meteor.methods({ let assetType = AssetTypes.findOne({_id: asset.assetTypeId}); try { - AssetAssignmentHistory.insert({assetKey: asset._id, assetId, serial: asset.serial, assetTypeName: (assetType ? assetType.name : "UNK"), assigneeType: asset.assigneeType, assigneeId: asset.assigneeId, startDate: asset.assignmentDate, endDate: date, startCondition: asset.condition, endCondition: condition, startConditionDetails: asset.conditionDetails, endConditionDetails: conditionDetails, comment}); + AssetAssignmentHistory.insert({assetKey: asset._id, assetId, serial: asset.serial, assetTypeName: (assetType ? assetType.name : "UNK"), assigneeType: asset.assigneeType, assigneeId: asset.assigneeId, startDate: asset.assignmentDate, endDate: date, startCondition: asset.condition, endCondition: condition, startConditionDetails: asset.conditionDetails, endConditionDetails: conditionDetails, comment, unassignedBy: Meteor.userId(), assignedBy: asset.assignedBy}); } catch (e) { console.error(e); } - Assets.update({assetId}, {$unset: {assigneeType: "", assigneeId: "", assignmentDate: ""}, $set: {condition, conditionDetails}}); + Assets.update({assetId}, {$unset: {assigneeType: "", assigneeId: "", assignmentDate: "", assignedBy: ""}, $set: {condition, conditionDetails}}); } else { console.error("Could not find the asset: " + assetId); diff --git a/imports/api/data-collection.js b/imports/api/data-collection.js index 04031ce..bb536b7 100644 --- a/imports/api/data-collection.js +++ b/imports/api/data-collection.js @@ -72,7 +72,7 @@ if (Meteor.isServer) { else if(params.studentId) { const student = Students.findOne({_id: params.studentId}) - console.log(student) + // console.log(student) if(student) query.email = student.email; else query = undefined } 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/AssetTypes.jsx b/imports/ui/pages/Admin/AssetTypes.jsx index 267e980..6d34e7a 100644 --- a/imports/ui/pages/Admin/AssetTypes.jsx +++ b/imports/ui/pages/Admin/AssetTypes.jsx @@ -42,9 +42,9 @@ const AssetTypeEditor = ({value, close}) => { close() //TODO Should invert this and only close if there was success on the server. if(value._id) - Meteor.call("assetType.update", value._id, name, description, year); + Meteor.call("assetTypes.update", value._id, name, description, year); else - Meteor.call("assetType.add", name, description, year); + Meteor.call("assetTypes.add", name, description, year); } const rejectChanges = () => { close() @@ -97,7 +97,7 @@ export default () => { maxHeight: '40rem', keyHandler: (e, selected) => { if(selected && selected._id && e.key === "Delete") { - Meteor.call("assetType.remove", selected._id); + Meteor.call("assetTypes.remove", selected._id); } } } 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/Assets/AddAssets.jsx b/imports/ui/pages/Assets/AddAssets.jsx index 8acd69f..6ce7ab9 100644 --- a/imports/ui/pages/Assets/AddAssets.jsx +++ b/imports/ui/pages/Assets/AddAssets.jsx @@ -62,6 +62,7 @@ const AddAssets = ({assetTypes}) => { return ( <> +
1: Select Asset Types To Add
Available Asset Types @@ -81,6 +82,7 @@ const AddAssets = ({assetTypes}) => { +
2: Select Asset Type and Enter Asset Data
diff --git a/imports/ui/pages/Assets/AssetList.jsx b/imports/ui/pages/Assets/AssetList.jsx index 559f1c4..a012a9a 100644 --- a/imports/ui/pages/Assets/AssetList.jsx +++ b/imports/ui/pages/Assets/AssetList.jsx @@ -65,13 +65,13 @@ const AssetEditor = ({value, close}) => { return {assetType.name} })} - {setAssetId(e.target.value)}}/> + {setAssetId(e.target.value.toUpperCase())}}/> + {setSerial(e.target.value)}}/> {setCondition(e.target.value)}}> {conditions.map((condition, i) => { return {condition} })} - {setSerial(e.target.value)}}/> {setConditionDetails(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..e186051 100644 --- a/imports/ui/pages/Assignments/ByAsset.jsx +++ b/imports/ui/pages/Assignments/ByAsset.jsx @@ -24,7 +24,12 @@ 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/history"; +import Tab from '@mui/material/Tab'; +import TabContext from '@mui/lab/TabContext'; +import TabList from '@mui/lab/TabList'; +import TabPanel from '@mui/lab/TabPanel'; const cssTwoColumnContainer = { display: 'grid', @@ -37,18 +42,22 @@ const cssEditorField = { } const AssignmentsByAsset = () => { + const navigate = useNavigate() + const navigateType = useNavigationType() + const location = useLocation() + const state = location.state const theme = useTheme(); const [assetId, setAssetId] = useState("") //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("") const [assetIdInput, setAssetIdInput] = useState(undefined) - - + const {foundAsset} = useTracker(() => { let foundAsset = null; @@ -64,15 +73,76 @@ const AssignmentsByAsset = () => { } return {foundAsset} - }); + }, [assetId]); + + // Set a timer function to create history for the browser if the user pauses on an asset long enough. + useEffect(() => { + let clearTimer; + + // Only setup the timer to update navigation if we have found an asset for the current text input, and that asset is not the same as the one already current in the browser history. + if(foundAsset && (!state || state.assetId !== foundAsset.assetId)) { + const prevFoundAssetId = foundAsset.assetId + + // If the asset id doesn't change in 3 seconds then add this asset to the browser history so the back functionality works. + const timer = setTimeout(() => { + if(foundAsset && foundAsset.assetId === prevFoundAssetId) navigate("/assignments/byAsset", {replace: false, state: {assetId: foundAsset.assetId}}); + }, 3000) + + clearTimer = () => clearTimeout(timer) + } + + return clearTimer + }, [foundAsset]) + + const [usageData, setUsageData] = useState([]) + const [assignmentData, setAssignmentData] = useState([]) - //This works too well. The field always gets focus anytime anything is typed anywhere. - // useEffect(() => { - // if(assetIdInput) assetIdInput.focus() - // }) + // Collect the usage and assignment data when the selected person changes. + useEffect(() => { + try { + if(foundAsset) { + let query = {assetId: foundAsset.assetId} + + console.log("Requesting asset historical data") + console.log(query) - const unassign = () => { + Meteor.call('DataCollection.chromebookData', query, (err, result) => { + if (err) console.error(err) + else setUsageData(result) + }) + Meteor.call('AssetAssignmentHistory.get', query, (err, result) => { + if (err) console.error(err) + else setAssignmentData(result) + }) + } + else setUsageData({}) + } catch(e) {console.log("Found error in collecting chromebook history & usage in ByAsset.jsx: " + e)} + }, [foundAsset]) + + // Restore the state if the forward/back/refresh functionality of the browser was utilized. + useEffect(() => { + console.log("useEffect - navigation") + if(!state) { + console.log("no state") + navigate("/assignments/byAsset", {replace: true, state: {asset: null}}) + } + else { + console.log(navigateType) + console.log(state) + if(navigateType === "POP" || navigateType === 'REPLACE' || navigateType === "PUSH") { + setAssetId(state.assetId ? state.assetId : "") + } + } + }, [state]) + + //Set focus on initial rendering. + useEffect(() => { + if(assetIdInput) assetIdInput.focus() + }, [assetIdInput]) + + 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 +152,102 @@ 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', foundAsset._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() + }) + } } } + const [tab, setTab] = useState('assignments') + + 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})
    + + )} + {!next.person && ( + <> + User: N/A
    + + )} + {/*Device ID: {next.deviceId}
    */} + {/*{next.asset && (*/} + {/* <>Asset ID: {next.asset.assetId}
    */} + {/*)}*/} + {/*<>Asset Type: {next.assetType ? next.assetType.name : "Unknown"}
    */} + {/*Serial: {next.serial}
    */} + {new Date(next.startTime).toLocaleDateString("en-US") + "-" + new Date(next.endTime).toLocaleDateString("en-US") + " @ " + new Date(next.endTime).toLocaleTimeString("en-US")} ({Math.ceil(((next.endTime ? next.endTime : new Date().getTime()) - next.startTime) / (1000*60*60*24))} days)
    + {/*{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})
    + + )} + {/*{next.asset && (*/} + {/* <>*/} + {/* Asset ID: {next.asset.assetId}
    */} + {/* */} + {/*)}*/} + {/*{next.assetType && (*/} + {/* <>*/} + {/* Asset Type: {next.assetType.name}
    */} + {/* */} + {/*)}*/} + {/*Serial: {next.serial}
    */} + {new Date(next.startDate).toLocaleDateString("en-US") + (next.endDate ? "-" + new Date(next.endDate).toLocaleDateString("en-US") : " - Still Assigned")} ({Math.ceil(((next.endDate ? next.endDate : new Date().getTime()) - next.startDate) / (1000*60*60*24))} days)
    + {next.comment && ( + <>Comment: {next.comment}
    + )} + Start Condition: {next.startCondition}
    + {next.startConditionDetails && <>Details: {next.startConditionDetails}
    } + {next.endDate && ( + <> + End Condition: {next.endCondition}
    + {next.endConditionDetails && <>Details: {next.endConditionDetails}
    } + + )} +
  • + ))} +
+ + ) + } + return ( <> - Unassign Asset + {unassignDialogEditConditionOnly ? "Edit Condition" : "Unassign Asset"}
{setUnassignCondition(e.target.value)}}> @@ -102,32 +256,54 @@ const AssignmentsByAsset = () => { })}
- {setUnassignComment(e.target.value)}}/> + {!unassignDialogEditConditionOnly && {setUnassignComment(e.target.value)}}/>} {setUnassignConditionDetails(e.target.value)}}/>
- +
- setAssetIdInput(input)} value={assetId} onChange={(e) => {setAssetId(e.target.value.toUpperCase())}}/> + setAssetIdInput(input)} value={assetId} onChange={(e) => {setAssetId(e.target.value.toUpperCase())}}/> + {foundAsset && ( +
+

Asset ID: {foundAsset.assetId}

+

Serial: {foundAsset.serial}

+

Current Condition: {foundAsset.condition}

+ + + setTab(v)}> + + + + + + +
+
Condition Details: {foundAsset.conditionDetails}
+ {foundAsset.assignee && ( + <> +
Assigned on: {new Date(foundAsset.assignmentDate).toLocaleDateString("en-US")} ({Math.ceil((new Date().getTime() - foundAsset.assignmentDate) / (1000*60*60*24))} days)
+
Assigned to: {foundAsset.assignee.firstName} {foundAsset.assignee.lastName} {foundAsset.assignee.grade && foundAsset.assignee.grade} ({foundAsset.assignee.email})
+ + {" "} + + + )} +
+
+ + + + + + +
+
+ )}
- {foundAsset && ( -
-
Serial: {foundAsset.serial}
-
Condition: {foundAsset.condition}
-
Condition Details: {foundAsset.conditionDetails}
- {foundAsset.assignee && ( - <> -
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 7309a2f..af038b0 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,13 @@ 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"; +import Tab from '@mui/material/Tab'; +import TabContext from '@mui/lab/TabContext'; +import TabList from '@mui/lab/TabList'; +import TabPanel from '@mui/lab/TabPanel'; const cssTwoColumnContainer = { display: 'grid', @@ -32,10 +38,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,12 +54,13 @@ 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("") const [unassignAsset, setUnassignAsset] = useState(undefined) - const [assetIdInput, setAssetIdInput] = useState(undefined) + const [searchInput, setSearchInput] = useState(undefined) const {people} = useTracker(() => { let people = []; @@ -56,13 +68,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(); @@ -74,7 +90,7 @@ const AssignmentsByPerson = () => { } return {people} - }); + }, [search]); const {assets} = useTracker(() => { let assets = []; @@ -88,7 +104,7 @@ const AssignmentsByPerson = () => { } return {assets} - }); + }, [selectedPerson]); const {foundAsset} = useTracker(() => { let foundAsset = null; @@ -105,7 +121,32 @@ const AssignmentsByPerson = () => { } return {foundAsset} - }); + }, [assetId]); + + const [usageData, setUsageData] = useState([]) + const [assignmentData, setAssignmentData] = useState([]) + + // Collect the usage and assignment data when the selected person changes. + useEffect(() => { + try { + if(selectedPerson) { + let query = selectedPerson.type === "Student" ? {studentId: selectedPerson._id} : {staffId: selectedPerson._id} + + console.log("Collecting person history") + console.log(query) + + Meteor.call('DataCollection.chromebookData', query, (err, result) => { + if (err) console.error(err) + else setUsageData(result) + }) + Meteor.call('AssetAssignmentHistory.get', query, (err, result) => { + if (err) console.error(err) + else setAssignmentData(result) + }) + } + else setUsageData({}) + } catch(e) {console.log("Found error in collecting chromebook history & usage in ByPerson.jsx: " + e)} + }, [selectedPerson]) const getListItemStyle = (item) => { return { @@ -115,8 +156,8 @@ const AssignmentsByPerson = () => { const assign = () => { if(foundAsset) { //Open the dialog to get condition. - setUnassignCondition(foundAsset.condition ? foundAsset.condition : conditions[2]) - setUnassignConditionDetails(foundAsset.conditionDetails || "") + setAssignCondition(foundAsset.condition ? foundAsset.condition : conditions[2]) + setAssignConditionDetails(foundAsset.conditionDetails || "") setOpenAssignDialog(true) } } @@ -138,13 +179,14 @@ const AssignmentsByPerson = () => { } } - //This works too well. The field always gets focus anytime anything is typed anywhere. - // useEffect(() => { - // if(assetIdInput) assetIdInput.focus() - // }) + //Force focus to the search input field when initially rendering. + useEffect(() => { + if(searchInput) searchInput.focus() + }, [searchInput]) - 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 +197,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 +221,94 @@ const AssignmentsByPerson = () => { userSelect: 'none', // '&:nthChild(even)': {backgroundColor: '#935e5e'} } + + // Changes the selected person and updates the browser history. + const changeSelectedPerson = (person) => { + setSelectedPerson(person) + navigate("/assignments/byPerson", {replace: false, state: {person, search}}); + } + + // Restore the state if the forward/back/refresh functionality of the browser was utilized. + useEffect(() => { + if(!state) navigate("/assignments/byPerson", {replace: true, state: {search: "", person: null}}) + else { + if(navigateType === "POP" || navigateType === 'REPLACE' || navigateType === "PUSH") { + setSearch(state.search) + setSelectedPerson(state.person) + } + } + }, [state, navigateType]) + + const [tab, setTab] = useState('assignments') + + 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}
    */} + {next.asset && ( + <>Asset ID: {next.asset.assetId}
    + )} + <>Asset Type: {next.assetType ? next.assetType.name : "Unknown"}
    + Serial: {next.serial}
    + {new Date(next.startTime).toLocaleDateString("en-US") + "-" + new Date(next.endTime).toLocaleDateString("en-US") + " @ " + new Date(next.endTime).toLocaleTimeString("en-US")} ({Math.ceil(((next.endTime ? next.endTime : new Date().getTime()) - next.startTime) / (1000*60*60*24))} days)
    + {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})
    */} + {/* */} + {/*)}*/} + {next.asset && ( + <> + Asset ID: {next.asset.assetId}
    + + )} + {next.assetType && ( + <> + Asset Type: {next.assetType.name}
    + + )} + Serial: {next.serial}
    + {new Date(next.startDate).toLocaleDateString("en-US") + (next.endDate ? "-" + new Date(next.endDate).toLocaleDateString("en-US") : " - Still Assigned")} ({Math.ceil(((next.endDate ? next.endDate : new Date().getTime()) - next.startDate) / (1000*60*60*24))} days)
    + {next.comment && ( + <>Comment: {next.comment}
    + )} + Start Condition: {next.startCondition}
    + {next.startConditionDetails && <>Details: {next.startConditionDetails}
    } + {next.endDate && ( + <> + End Condition: {next.endCondition}
    + {next.endConditionDetails && <>Details: {next.endConditionDetails}
    } + + )} +
  • + ))} +
+ + ) + } return ( <> @@ -193,7 +331,7 @@ const AssignmentsByPerson = () => { - Unassign Asset + {unassignDialogEditConditionOnly ? "Edit Condition" : "Unassign Asset"}
{setUnassignCondition(e.target.value)}}> @@ -202,72 +340,89 @@ 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 - - {setSearch(e.target.value)}}/> + + {/*setSearchType(type)} aria-label="Search Type">*/} + {/* Email*/} + {/* First Name*/} + {/* Last Name*/} + {/**/} + {setIncludeInactive(e.target.checked)}}/>} label="Inactive"/> + setSearchInput(input)} value={search} onChange={(e) => {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 + ")" : "")}

+ + + setTab(v)}> + + + + + + +
+
{setAssetId(e.target.value.toUpperCase())}}/>
+ {foundAsset && ( + <> +
{foundAsset && foundAsset.assetType.name}
+
Asset ID: {foundAsset.assetId}
+
Serial: {foundAsset.serial}
+ + )} + {foundAsset && foundAsset.assignee && ( + <> +
Assigned To: {foundAsset.assignee.firstName} {foundAsset.assignee.lastName}
+
Assigned: {new Date(foundAsset.assignmentDate).toLocaleDateString("en-US")} ({Math.ceil((new Date().getTime() - foundAsset.assignmentDate) / (1000*60*60*24))} days)
+ + )} + +
+ {assets.map((next, i) => { + return ( +
+
{next.assetType.name}
+
Asset ID: {next.assetId}
+
Serial: {next.serial}
+
Assigned: {new Date(next.assignmentDate).toLocaleDateString("en-US")} ({Math.ceil((new Date().getTime() - next.assignmentDate) / (1000*60*60*24))} days)
+ + {" "} + +
+ ) + })} +
+ + + + + + +
+ )} - {assets.map((next, i) => { - return ( -
-
{next.assetType.name}
-
{next.assetId}
-
{next.serial}
- -
- ) - })} - {/*
*/} - {/* {setAssetId(e.target.value)}}/>*/} - {/* {setSerial(e.target.value)}}/>*/} - {/*
*/} - {/*
*/} - {/* {setCondition(e.target.value)}}>*/} - {/* {conditions.map((condition, i) => {*/} - {/* return {condition}*/} - {/* })}*/} - {/* */} - {/*
*/} - {/*
*/} - {/* {setConditionDetails(e.target.value)}}/>*/} - {/*
*/}
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 1778fec..aa09f68 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "@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/lab": "^5.0.0-alpha.134", + "@mui/material": "^5.13.4", + "@remix-run/router": "^1.6.3", "bcrypt": "^5.0.1", "classnames": "^2.2.6", "csv-parse": "^5.3.0", @@ -32,7 +34,7 @@ "typescript": "^4.8.4", "umbrellajs": "^3.3.1", "underscore": "^1.13.2", - "verbum": "^0.4.0", + "verbum": "^0.5.0", "winston": "^3.7.2", "winston-daily-rotate-file": "^4.6.1", "ws": "^8.4.2" diff --git a/server/main.js b/server/main.js index 6d7214e..2a6fd0b 100644 --- a/server/main.js +++ b/server/main.js @@ -4,7 +4,8 @@ import '../imports/api/'; import './google-oauth.js'; import '/imports/startup/accounts-config.js'; import './logging'; - +//Currently all the websocket based reporting is done via the data collection system. +//import './websocket' diff --git a/server/websocket.js b/server/websocket.js new file mode 100644 index 0000000..68dd001 --- /dev/null +++ b/server/websocket.js @@ -0,0 +1,36 @@ +import WebSocket, {WebSocketServer} from 'ws' + +try { + const server = new WebSocketServer({ + port: 3001, + perMessageDeflate: { + zlibDeflateOptions: { + // See zlib defaults. + chunkSize: 1024, + memLevel: 7, + level: 3 + }, + zlibInflateOptions: { + chunkSize: 10 * 1024 + }, + // Other options settable: + clientNoContextTakeover: true, // Defaults to negotiated value. + serverNoContextTakeover: true, // Defaults to negotiated value. + serverMaxWindowBits: 10, // Defaults to negotiated value. + // Below options specified as default values. + concurrencyLimit: 10, // Limits zlib concurrency for perf. + threshold: 1024 // Size (in bytes) below which messages + // should not be compressed if context takeover is disabled. + } + }) + + server.on('connection', function connection(socket) { + socket.on('message', function message(data) { + console.log("received: %s", data) + socket.send("pong") + }) + }) +} +catch(e) { + console.log(e); +} \ No newline at end of file