diff --git a/.meteor/packages b/.meteor/packages index d13d105..ec09d16 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -7,7 +7,7 @@ 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 -jquery # Wrapper package for npm-installed jquery +jquery # Wrapper package for npm-installed jquery reactive-var@1.0.11 # Reactive variable for tracker tracker@1.2.0 # Meteor's client-side reactive programming library @@ -18,6 +18,9 @@ ecmascript@0.16.2 # Enable ECMAScript2015+ syntax in app code typescript@4.5.4 # Enable TypeScript syntax in .ts and .tsx modules shell-server@0.5.0 # Server-side component of the `meteor shell` command +aldeed:collection2 # Attaches a schema to a collection +aldeed:schema-index # Allows the schema to specify fields to be indexed + svelte:compiler #static-html@1.3.2 rdb:svelte-meteor-data diff --git a/.meteor/versions b/.meteor/versions index 4c7787c..1346557 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -5,6 +5,11 @@ accounts-password@2.3.1 accounts-ui@1.4.2 accounts-ui-unstyled@1.7.0 alanning:roles@3.4.0 +aldeed:collection2@2.10.0 +aldeed:collection2-core@1.2.0 +aldeed:schema-deny@1.1.0 +aldeed:schema-index@1.1.1 +aldeed:simple-schema@1.5.4 allow-deny@1.1.1 autoupdate@1.8.0 babel-compiler@7.9.0 @@ -47,6 +52,7 @@ launch-screen@1.3.0 less@4.0.0 localstorage@1.2.0 logging@1.3.1 +mdg:validation-error@0.2.0 meteor@1.10.0 meteor-base@1.5.1 meteortesting:browser-tests@1.3.5 @@ -72,6 +78,7 @@ oauth2@1.3.1 observe-sequence@1.0.20 ordered-dict@1.1.0 promise@0.12.0 +raix:eventemitter@0.1.3 random@1.2.0 rate-limit@1.0.9 rdb:svelte-meteor-data@1.0.0 diff --git a/imports/api/asset-assignments.js b/imports/api/asset-assignments.js new file mode 100644 index 0000000..c4225cd --- /dev/null +++ b/imports/api/asset-assignments.js @@ -0,0 +1,68 @@ +import {Mongo} from "meteor/mongo"; +import {Meteor} from "meteor/meteor"; +import { check } from 'meteor/check'; +import { Roles } from 'meteor/alanning:roles'; +import SimpleSchema from "simpl-schema"; +import {AssetTypes} from "./asset-types"; + +export const AssetAssignments = new Mongo.Collection('assetAssignments'); +/* +const TYPE_STUDENT = 1; +const TYPE_STAFF = 2; + +const AssetAssignmentsSchema = new SimpleSchema({ + assetId: { + type: String, + label: "Asset ID", + optional: false, + index: 1, + unique: false + }, + assigneeId: { + type: String, + label: "Assignee ID", + optional: false, + }, + assigneeType: { + type: SimpleSchema.Integer, + label: "Assignee Type", + optional: false, + min: 1, + max: 2, + exclusiveMin: false, + exclusiveMax: false, + }, +}); + +AssetAssignments.attachSchema(AssetAssignmentsSchema); +*/ + +if (Meteor.isServer) { + // Drop any old indexes we no longer will use. Create indexes we need. + //try {AssetTypes._dropIndex("name")} catch(e) {} + //AssetTypes.createIndex({name: "text"}, {name: "name", unique: false}); + AssetTypes.createIndex({assetId: 1}, {name: "AssetID", unique: false}); + + // This code only runs on the server + Meteor.publish('assetAssignments', function() { + return AssetAssignments.find({}); + }); +} +Meteor.methods({ + 'assetAssignments.add'(assetId) { + // check(assetTypeId, String); + // check(assetId, String); + + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + // Assets.insert({assetTypeId, assetId}); + } + }, + 'assetAssignments.remove'(_id) { + check(_id, String); + + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + //TODO: Need to first verify there are no checked out assets to the staff member. + } + }, +}); + diff --git a/imports/api/asset-types.js b/imports/api/asset-types.js new file mode 100644 index 0000000..5dc2bd5 --- /dev/null +++ b/imports/api/asset-types.js @@ -0,0 +1,72 @@ +import {Mongo} from "meteor/mongo"; +import {Meteor} from "meteor/meteor"; +import { check } from 'meteor/check'; +import { Roles } from 'meteor/alanning:roles'; +import SimpleSchema from "simpl-schema"; + +// +// An asset type is a specific type of equipment. Example: Lenovo 100e Chromebook. +// +export const AssetTypes = new Mongo.Collection('assetType'); +/* + +const AssetTypesSchema = new SimpleSchema({ + name: { + type: String, + label: "Model Name", + optional: false, + trim: true, + }, + description: { + type: String, + label: "Description", + optional: true, + trim: true, + defaultValue: "" + }, + hasSerial: { + type: Boolean, + label: "Is a serial number available for all instances?", + optional: false, + defaultValue: false + }, +}); + +AssetTypes.attachSchema(AssetTypesSchema); + */ + +if (Meteor.isServer) { + // Drop any old indexes we no longer will use. Create indexes we need. + try {AssetTypes._dropIndex("name")} catch(e) {} + //AssetTypes.createIndex({name: "text"}, {name: "name", unique: false}); + AssetTypes.createIndex({id: 1}, {name: "External ID", unique: true}); + + //Debug: Show all indexes. + // AssetTypes.rawCollection().indexes((err, indexes) => { + // console.log(indexes); + // }); + + // This code only runs on the server + Meteor.publish('assetTypes', function() { + return AssetTypes.find({}, {sort: {name: 1}}); + }); +} +Meteor.methods({ + 'assetTypes.add'(name, description, hasSerial) { + check(name, String); + check(description, String); + check(hasSerial, Boolean); + + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + AssetTypes.insert({name, description, hasSerial}); + } + }, + 'assetTypes.remove'(_id) { + check(_id, String); + + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + //TODO: Need to first verify there are no checked out assets to the staff member. + } + }, +}); + diff --git a/imports/api/assets.js b/imports/api/assets.js new file mode 100644 index 0000000..2fd06fa --- /dev/null +++ b/imports/api/assets.js @@ -0,0 +1,80 @@ +import {Mongo} from "meteor/mongo"; +import {Meteor} from "meteor/meteor"; +import { check } from 'meteor/check'; +import { Roles } from 'meteor/alanning:roles'; +import SimpleSchema from "simpl-schema"; +import {AssetTypes} from "./asset-types"; + +export const Assets = new Mongo.Collection('assets'); + +/* +const AssetsSchema = new SimpleSchema({ + assetTypeId: { + type: String, + label: "Asset Type ID", + optional: false, + trim: true, + }, + assetId: { + type: String, + label: "Asset ID", + optional: false, + trim: true, + index: 1, + unique: true + }, + serial: { + type: String, + label: "Serial", + optional: true, + trim: false, + index: 1, + unique: false + }, +}); +Assets.attachSchema(AssetsSchema); + */ + +if (Meteor.isServer) { + // Drop any old indexes we no longer will use. Create indexes we need. + //try {Assets._dropIndex("serial")} catch(e) {} + Assets.createIndex({assetId: 1}, {name: "AssetID", unique: true}); + Assets.createIndex({serial: 1}, {name: "Serial", unique: false}); + + // This code only runs on the server + Meteor.publish('assets', function() { + return Assets.find({}); + }); +} +Meteor.methods({ + 'assets.add'(assetTypeId, assetId, serial) { + check(assetTypeId, String); + check(assetId, String); + check(serial, String); + + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + let assetType = AssetTypes.findOne({assetTypeId}); + + if(assetType.hasSerial && serial || !assetType.hasSerial && !serial) { + if(serial) { + Assets.insert({assetTypeId, assetId, serial}); + } + else { + Assets.insert({assetTypeId, assetId}); + } + } + else { + //Should never get here due to client side validation. + console.log("Error: Must provide a serial for asset types marked as having one, and may not provide one for asset types not marked as having one.") + } + } + }, + 'assets.remove'(_id) { + check(_id, String); + + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + //TODO: Need to first verify there are no checked out assets to the staff member. + } + }, +}); + diff --git a/imports/api/example-schema.js b/imports/api/example-schema.js new file mode 100644 index 0000000..d7f3a5b --- /dev/null +++ b/imports/api/example-schema.js @@ -0,0 +1,93 @@ +import {AssetTypes} from "./asset-types"; +import {SimpleSchema} from "simpl-schema/dist/SimpleSchema"; + +const AssetTypesSchema = new SimpleSchema({ + name: { + type: String, + label: "Name", + optional: false, + trim: true, + index: 1, + unique: true + }, + tags: { //An array of ProductTag names. Note that we are not using the ProductTag ID's because I want a looser connection (if a ProductTag is deleted, it isn't a big deal if it isn't maintained in the Product records). + type: [String], + label: "Tags", + optional: false, + defaultValue: [] + }, + measures: { //A JSON array of Measure ID's. + type: Array, + label: "Measures", + optional: false, + defaultValue: [] + }, + 'measures.$': { + type: String, + label: "Measure ID", + regEx: SimpleSchema.RegEx.Id + }, + aliases: { //A JSON array of alternate names. + type: Array, + label: "Aliases", + optional: false, + defaultValue: [] + }, + 'aliases.$': { + type: String + }, + createdAt: { + type: Date, + label: "Created On", + optional: false + }, + updatedAt: { + type: Date, + label: "Updated On", + optional: true + }, + deactivated: { //This is turned on first, if true it will hide the product in production views, but keep the product available in the sale views. It is intended to be turned on for products that are no longer produced, but for which we have remaining inventory. + type: Boolean, + label: "Deactivated", + optional: true + }, + hidden: { //Deactivated must first be true. Hides the product everywhere in the system except in historical pages. The inventory should be all sold prior to hiding a product. + type: Boolean, + label: "Hidden", + optional: true + }, + timestamp: { + type: Date, + label: "Timestamp", + optional: true + }, + weekOfYear: { + type: Number, + label: "Week Of Year", + optional: true + }, + amount: { + type: Number, + label: "Amount", + optional: false, + decimal: true + }, + price: { + type: Number, + label: "Price", + optional: false, + min: 0, + exclusiveMin: true, + }, + assigneeType: { + type: SimpleSchema.Integer, + label: "Assignee Type", + optional: false, + min: 1, + max: 2, + exclusiveMin: false, + exclusiveMax: false, + }, +}); + +AssetTypes.attachSchema(AssetTypesSchema); diff --git a/imports/api/index.js b/imports/api/index.js index a753cd4..1d8d2d1 100644 --- a/imports/api/index.js +++ b/imports/api/index.js @@ -2,5 +2,8 @@ import "./users.js"; import "./data-collection.js"; import "./admin.js"; import "./students.js"; -import "./rooms.js"; +import "./staff.js"; import "./sites.js"; +import "./asset-types.js"; +import "./assets.js"; +import "./asset-assignments.js"; diff --git a/imports/api/sites.js b/imports/api/sites.js index d3a8186..efe2f49 100644 --- a/imports/api/sites.js +++ b/imports/api/sites.js @@ -1,7 +1,8 @@ import {Mongo} from "meteor/mongo"; import {Meteor} from "meteor/meteor"; import {Students} from "./students"; -import {Rooms} from "./rooms"; +import {Staff} from "./staff"; +import { Roles } from 'meteor/alanning:roles'; export const Sites = new Mongo.Collection('sites'); @@ -29,7 +30,7 @@ Meteor.methods({ if(site) { //Clear any site references in student/room entries. Students.update({siteId: _id}, {$unset: {siteId: 1}}); - Rooms.update({siteId: _id}, {$unset: {siteId: 1}}); + Staff.update({siteId: _id}, {$unset: {siteId: 1}}); Sites.remove({_id}); } } diff --git a/imports/api/rooms.js b/imports/api/staff.js similarity index 54% rename from imports/api/rooms.js rename to imports/api/staff.js index 2b312fd..6715014 100644 --- a/imports/api/rooms.js +++ b/imports/api/staff.js @@ -1,23 +1,24 @@ import {Mongo} from "meteor/mongo"; import {Meteor} from "meteor/meteor"; +import { Roles } from 'meteor/alanning:roles'; -export const Rooms = new Mongo.Collection('rooms'); +export const Staff = new Mongo.Collection('staff'); if (Meteor.isServer) { // This code only runs on the server - Meteor.publish('rooms', function(siteId) { - return Rooms.find({siteId}); + Meteor.publish('staff', function(siteId) { + return Staff.find({siteId}); }); } Meteor.methods({ - 'rooms.add'(name, siteId) { + 'staff.add'(firstName, lastName, email, siteId) { if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { - Rooms.insert({name, siteId}); + Staff.insert({firstName, lastName, email, siteId}); } }, - 'rooms.remove'(_id) { + 'staff.remove'(_id) { if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { - //TODO: Need to first verify there are no checked out assets to the room. + //TODO: Need to first verify there are no checked out assets to the staff member. } }, }); diff --git a/imports/api/students.js b/imports/api/students.js index 885fc31..b242c95 100644 --- a/imports/api/students.js +++ b/imports/api/students.js @@ -2,10 +2,13 @@ import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; import { check } from 'meteor/check'; import {Sites} from "./sites"; +import { Roles } from 'meteor/alanning:roles'; export const Students = new Mongo.Collection('students'); if (Meteor.isServer) { + Students.createIndex({id: 1}, {name: "External ID", unique: true}); + // This code only runs on the server Meteor.publish('students', function(siteId) { return Students.find({siteId}); @@ -13,6 +16,19 @@ if (Meteor.isServer) { } Meteor.methods({ + /** + * Sets a first name alias that can be overridden by the one that is imported. + * @param _id The student's database ID. + * @param alias The alias to set for the student. + */ + 'students.setAlias'(_id, alias) { + if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) { + check(_id, String); + check(alias, String); + + Students.update({_id}, !alias || !alias.length() ? {$unset: {alias: true}} : {$set: {alias}}); + } + }, /** * 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 @@ -32,7 +48,7 @@ Meteor.methods({ check(csv, String); check(siteId, String); - let site = Sites.find({_id: siteId}); + let site = Sites.findOne({_id: siteId}); if(site) { let lines = csv.split(/\r?\n/); @@ -45,16 +61,21 @@ Meteor.methods({ skip = 2; } else { let values = line.split(/\s*,\s*/); - let email = values[0]; - let id = values[1]; - let firstName = values[2]; - let lastName = values[3]; - let grade = values[4]; - let firstNameAlias = values[5]; - let lastNameAlias = values[6]; - let student = {email, id, firstName, lastName, grade, firstNameAlias, lastNameAlias}; - console.log(student); + if(values.length === 7) { + let email = values[0]; + let id = values[1]; + let firstName = values[2]; + let lastName = values[3]; + let grade = parseInt(values[4], 10); + let firstNameAlias = values[5]; + let active = true; + let student = {siteId, email, id, firstName, lastName, grade, firstNameAlias, active}; + + // console.log(student); + // Update or insert in the db. + Students.upsert({id}, {$set: student}); + } //TODO: Find existing student. Update student, and move them to the new site. //TODO: Create student if none exists. diff --git a/imports/ui/Admin.svelte b/imports/ui/Admin.svelte index 00c9869..50dd7e4 100644 --- a/imports/ui/Admin.svelte +++ b/imports/ui/Admin.svelte @@ -6,18 +6,17 @@ import {writable} from "svelte/store"; import TextField from '@smui/textfield'; import HelperText from '@smui/textfield/helper-text'; + import {Students} from "../api/students"; + import {Staff} from "../api/staff"; + import {AssetTypes} from "../api/asset-types"; onMount(async () => { Meteor.subscribe('sites'); + Meteor.subscribe('assetTypes'); }); const fixRecords = () => {Meteor.call("admin.fixRecords");} - const submitForm = () => { - Meteor.call('students.loadCsv'); - } - let files; - const siteColumns = [ { key: "_id", @@ -66,10 +65,162 @@ const rejectSiteChanges = () => { editedSite.set(null); } + let selectedSite = null; + let students = null; + let staff = null; + $: { + if(selectedSite) { + Meteor.subscribe('students', selectedSite._id); + Meteor.subscribe('staff', selectedSite._id); + students = Students.find({siteId: selectedSite._id}); + staff = Staff.find({siteId: selectedSite._id}); + } + } + const onSiteSelection = (e) => { + selectedSite = Sites.findOne({_id: e.detail}); + } + + const uploadStudents = () => { + // console.log(files); + // console.log(selectedSite); + // console.log(selectedSite._id); + if(files && files.length) { + let file = files[0]; + let reader = new FileReader(); + reader.onload = (e) => { + // console.log("Sending Data"); + // console.log(selectedSite._id); + // console.log(reader.result); + Meteor.call('students.loadCsv', reader.result, selectedSite._id); + } + reader.readAsText(file, "UTF-8"); + } + } + let files; + + const studentColumns = [ + { + key: "_id", + title: "ID", + value: v => v._id, + minWidth: 20, + weight: 1, + cls: "id", + }, { + key: "email", + title: "Email", + value: v => v.email, + minWidth: 100, + weight: 1, + cls: "email", + }, { + key: "firstName", + title: "First Name", + value: v => v.firstName, + minWidth: 100, + weight: 1, + cls: "firstName", + }, { + key: "lastName", + title: "Last Name", + value: v => v.lastName, + minWidth: 100, + weight: 1, + cls: "lastName", + }, { + key: "grade", + title: "Grade", + value: v => v.grade, + minWidth: 100, + weight: 1, + cls: "grade", + }, + ]; + let editedStudent = writable(null); + const onStudentSelection = (e) => { + + } + + const staffColumns = [ + { + key: "_id", + title: "ID", + value: v => v._id, + minWidth: 20, + weight: 1, + cls: "id", + }, { + key: "email", + title: "Email", + value: v => v.email, + minWidth: 100, + weight: 1, + cls: "email", + }, { + key: "firstName", + title: "First Name", + value: v => v.firstName, + minWidth: 100, + weight: 1, + cls: "firstName", + }, { + key: "lastName", + title: "Last Name", + value: v => v.lastName, + minWidth: 100, + weight: 1, + cls: "lastName", + }, + ]; + let editedStaff = writable(null); + const onStaffSelection = (e) => { + + } + + const assetTypesColumns = [ + { + key: "_id", + title: "ID", + value: v => v._id, + minWidth: 20, + weight: 1, + cls: "id", + }, { + key: "name", + title: "Name", + value: v => v.name, + minWidth: 100, + weight: 1, + cls: "name", + }, { + key: "description", + title: "Description", + value: v => v.description, + minWidth: 100, + weight: 1, + cls: "description", + }, { + key: "hasSerial", + title: "Has Serial", + value: v => v.hasSerial, + minWidth: 100, + weight: 1, + cls: "hasSerial", + }, + ]; + let editedAssetType = writable(null); + const onAssetTypeSelection = (e) => { + + } + let dirtyAssetType = null; + // Copy the edited site when ever it changes, set some defaults for a new site object (to make the view happy). + editedAssetType.subscribe(v => {dirtyAssetType = Object.assign({name: ""}, v)}); + // Load the sites (reactive). + let assetTypes = AssetTypes.find({});