Initial check in; All but the history pages working.

This commit is contained in:
2022-09-07 08:58:00 -07:00
commit d6bd620207
109 changed files with 13170 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
private/settings.json
package-lock.json

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

13
.idea/Tempest.iml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/.meteor/local" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Meteor project library" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Tempest.iml" filepath="$PROJECT_DIR$/.idea/Tempest.iml" />
</modules>
</component>
</project>

25
.idea/watcherTasks.xml generated Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="true">
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="sass" />
<option name="immediateSync" value="true" />
<option name="name" value="Sass" />
<option name="output" value="$FileNameWithoutExtension$.css:$FileNameWithoutExtension$.css.map" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="sass" />
<option name="runOnExternalChanges" value="true" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="true" />
<option name="workingDir" value="$FileDir$" />
<envs />
</TaskOptions>
</component>
</project>

View File

@@ -0,0 +1,19 @@
# This file contains information which helps Meteor properly upgrade your
# app when you run 'meteor update'. You should check it into version control
# with your project.
notices-for-0.9.0
notices-for-0.9.1
0.9.4-platform-file
notices-for-facebook-graph-api-2
1.2.0-standard-minifiers-package
1.2.0-meteor-platform-split
1.2.0-cordova-changes
1.2.0-breaking-changes
1.3.0-split-minifiers-package
1.4.0-remove-old-dev-bundle-link
1.4.1-add-shell-server-package
1.4.3-split-account-service-packages
1.5-add-dynamic-import-package
1.7-split-underscore-from-meteor-base
1.8.3-split-jquery-from-blaze

1
.meteor/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
local

7
.meteor/.id Normal file
View File

@@ -0,0 +1,7 @@
# This file contains a token that is unique to your project.
# Check it into your repository along with the rest of this directory.
# It can be used for purposes such as:
# - ensuring you don't accidentally deploy one app on top of another
# - providing package authors with aggregated statistics
icx08c5o8tue.o91vpgtmehj

29
.meteor/packages Normal file
View File

@@ -0,0 +1,29 @@
# Meteor packages used by this project, one per line.
# Check this file (and the other files in this directory) into your repository.
#
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
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
reactive-var@1.0.11 # 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
es5-shim@4.8.0 # ECMAScript 5 compatibility for older browsers
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
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-google@1.4.0
service-configuration@1.3.0
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)

2
.meteor/platforms Normal file
View File

@@ -0,0 +1,2 @@
server
browser

1
.meteor/release Normal file
View File

@@ -0,0 +1 @@
METEOR@2.7.3

99
.meteor/versions Normal file
View File

@@ -0,0 +1,99 @@
accounts-base@2.2.4
accounts-google@1.4.0
accounts-oauth@1.4.1
accounts-password@2.3.1
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-runtime@1.5.1
base64@1.0.12
binary-heap@1.0.11
blaze@2.5.0
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
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
ecmascript-runtime-client@0.12.1
ecmascript-runtime-server@0.11.0
ejson@1.1.2
email@2.2.1
es5-shim@4.8.0
fetch@0.1.1
geojson-utils@1.0.10
google-config-ui@1.0.3
google-oauth@1.4.2
hot-code-push@1.0.4
html-tools@1.1.3
htmljs@1.1.1
id-map@1.1.1
inter-process-messaging@0.1.1
jquery@3.0.0
launch-screen@1.3.0
less@3.0.2
localstorage@1.2.0
logging@1.3.1
meteor@1.10.0
meteor-base@1.5.1
meteortoys:toykit@10.0.0
minifier-css@1.6.1
minifier-js@2.7.5
minimongo@1.8.0
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
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
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
reload@1.3.1
retry@1.1.0
routepolicy@1.1.1
service-configuration@1.3.0
session@1.2.0
sha@1.0.9
shell-server@0.5.0
socket-stream-client@0.5.0
spacebars@1.2.0
spacebars-compiler@1.3.1
standard-minifier-css@1.8.2
standard-minifier-js@2.8.1
static-html@1.3.2
templating@1.4.1
templating-compiler@1.4.1
templating-runtime@1.5.0
templating-tools@1.2.2
tracker@1.2.0
typescript@4.5.4
underscore@1.0.10
url@1.3.2
webapp@1.13.1
webapp-hashing@1.1.0

8
README.md Normal file
View File

@@ -0,0 +1,8 @@
# Simple Todo List (archived)
The new React Tutorial lives here
https://github.com/meteor/react-tutorial/
Any PRs or issues should be created there now.
You can check the tutorial here https://react-tutorial.meteor.com/

133
client/_app.sass Normal file
View File

@@ -0,0 +1,133 @@
/* CSS declarations go here */
.Mongol_row, .Mongol_row_name
color: white
*
box-sizing: border-box
html
padding: 0
margin: 0
body
font-family: sans-serif
background-attachment: fixed
position: absolute
top: 0
bottom: 0
left: 0
right: 0
padding: 0
margin: 0
//.container
// max-width: 600px
// margin: 0 auto
// min-height: 100%
// background: white
h1
font-size: 1.5em
margin: 0 0 1rem 0
display: inline-block
form
margin-top: 10px
margin-bottom: 10px
position: relative
.new-task input
box-sizing: border-box
padding: 10px 0
background: transparent
border: none
width: 100%
padding-right: 80px
font-size: 1em
.new-task input:focus
outline: 0
ul
margin: 0
padding: 0
background: white
.delete
float: right
font-weight: bold
background: none
font-size: 1em
border: none
position: relative
table
min-width: 650px
table > thead > tr > th.headerCell
color: white
background-color: #333447
table > thead.sticky > tr > th.headerCell
position: sticky
top: 0
table > tbody > tr > td
background: white
user-select: none
table > tbody > tr.selected > td
background: #f8ef8d
table .tableRow
&:last-child td, &:last-child th
border: 0
.userTable
max-height: 64rem
li
position: relative
list-style: none
padding: 15px
border-bottom: #eee solid 1px
li .text
margin-left: 10px
li.checked
color: #888
li.checked .text
text-decoration: line-through
li.private
background: #eee
border-color: #ddd
header .hide-completed
float: right
.toggle-private
margin-left: 5px
html
font-size: 16px
@media (max-width: 900px)
html
font-size: 14px
@media (max-width: 600px)
li
padding: 12px 15px
.search
width: 150px
clear: both
.new-task input
padding-bottom: 5px
html
font-size: 10px

View File

@@ -0,0 +1,32 @@
// https://github.com/google/material-design-icons
@font-face
font-family: 'Material Icons'
font-style: normal
font-weight: 400
src: local('Material Icons'), local('MaterialIcons-Regular'), url(/fonts/MaterialIcons-Regular.ttf) format('truetype')
.material-icons
font-family: 'Material Icons'
font-weight: normal
font-style: normal
font-size: 24px
/* Preferred icon size */
display: inline-block
line-height: 1
text-transform: none
letter-spacing: normal
word-wrap: normal
white-space: nowrap
direction: ltr
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale
/* Support for IE. */
font-feature-settings: 'liga'
.material-symbols-outlined
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48

106
client/_page.sass Normal file
View File

@@ -0,0 +1,106 @@
@font-face
font-family: 'KaushanScript-Regular'
font-style: normal
font-weight: 400
src: url('/fonts/KaushanScript-Regular.ttf') format('truetype')
.nav-separator
height: 0.2rem
width: 40%
margin-left: 30%
background-color: white
nav
font-size: 1.4rem
nav a
display: inline-block
color: #AF8A62
text-decoration: none
nav a:hover
color: #F9F1E1
text-decoration: underline
nav a:active
color: #F9F1E1
text-decoration: none
nav a + a
margin-left: 2rem
.pageHeaderContainer
background-image: url("/images/header.svg")
background-origin: border-box
background-size: cover
background-position: center
.pageHeader
//background: #2c031c
color: white
/*background-image: linear-gradient(to bottom, #d0edf5, #e1e5f0 100%)*/
padding: 20px 15px 15px 15px
position: relative
.pageNavContainer
background: #28211A
.pageContentContainer
margin: 0 auto
width: 85%
padding-top: 2rem
.title
margin-bottom: 0
color: white
font-family: "KaushanScript-Regular", sans-serif
font-size: 4rem
display: inline-block
.logo
position: absolute
left: 0
top: 0
width: 8rem
.logoContainer
height: 0
position: relative
.login
position: absolute
right: 0
top: 0
.login button
background: #28211A
border-radius: 999px
box-shadow: 0.2rem .3rem .4rem .1rem #886f59
//box-shadow: #886f59 0 1px 2px -1px
box-sizing: border-box
color: #FFFFFF
cursor: pointer
font-family: Inter, Helvetica, "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols, -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", sans-serif
font-weight: 700
line-height: 24px
opacity: 1
outline: 0 solid transparent
padding: 8px 18px
user-select: none
-webkit-user-select: none
touch-action: manipulation
width: fit-content
word-break: break-word
border: 0
@media (max-width: 600px)
.logo
position: absolute
left: 0
right: 0
top: 0
margin: 0 auto
width: 8rem
.logoContainer
height: 6rem

7
client/_roboto.sass Normal file
View File

@@ -0,0 +1,7 @@
// https://fonts.google.com/specimen/Roboto
@font-face
font-family: 'Roboto'
font-style: normal
font-weight: 400
src: local('Roboto'), local('Roboto-Regular'), url(/fonts/Roboto-Regular.ttf) format('truetype')

186
client/_simple-grid.sass Normal file
View File

@@ -0,0 +1,186 @@
@import url(https://fonts.googleapis.com/css?family=Lato:400,300,300italic,400italic,700,700italic)
html, body
height: 100%
width: 100%
margin: 0
padding: 0
left: 0
top: 0
/* ROOT FONT STYLES
*
font-family: 'Lato', Helvetica, sans-serif
color: #333447
line-height: 1.5
/* TYPOGRAPHY
h1
font-size: 2.5rem
h2
font-size: 2rem
h3
font-size: 1.375rem
h4
font-size: 1.125rem
h5
font-size: 1rem
h6
font-size: 0.875rem
p
font-size: 1.125rem
font-weight: 200
line-height: 1.8
.font-light
font-weight: 300
.font-regular
font-weight: 400
.font-heavy
font-weight: 700
/* POSITIONING
.left
text-align: left
.right
text-align: right
.center
text-align: center
margin-left: auto
margin-right: auto
.justify
text-align: justify
/* ==== GRID SYSTEM ====
.container
//width: 90%
width: 100%
margin-left: auto
margin-right: auto
.row
position: relative
//width: 100%
[class^="col"]
float: left
margin: 0.5rem 2%
min-height: 0.125rem
.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12
width: 96%
margin: auto
.col-1-sm
width: 4.33%
.col-2-sm
width: 12.66%
.col-3-sm
width: 21%
.col-4-sm
width: 29.33%
.col-5-sm
width: 37.66%
.col-6-sm
width: 46%
.col-7-sm
width: 54.33%
.col-8-sm
width: 62.66%
.col-9-sm
width: 71%
.col-10-sm
width: 79.33%
.col-11-sm
width: 87.66%
.col-12-sm
width: 96%
.row::after
content: ""
display: table
clear: both
.hidden-sm
display: none
@media only screen and (min-width: 33.75em)
/* 540px
.container
width: 80%
@media only screen and (min-width: 45em)
/* 720px
.col-1
width: 4.33%
.col-2
width: 12.66%
.col-3
width: 21%
.col-4
width: 29.33%
.col-5
width: 37.66%
.col-6
width: 46%
.col-7
width: 54.33%
.col-8
width: 62.66%
.col-9
width: 71%
.col-10
width: 79.33%
.col-11
width: 87.66%
.col-12
width: 96%
.hidden-sm
display: block
@media only screen and (min-width: 60em)
/* 960px
.container
width: 75%
max-width: 60rem

540
client/main.css Normal file
View File

@@ -0,0 +1,540 @@
@import url(https://fonts.googleapis.com/css?family=Lato:400,300,300italic,400italic,700,700italic);
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
left: 0;
top: 0;
}
/* ROOT FONT STYLES */
* {
font-family: "Lato", Helvetica, sans-serif;
color: #333447;
line-height: 1.5;
}
/* TYPOGRAPHY */
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.375rem;
}
h4 {
font-size: 1.125rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.875rem;
}
p {
font-size: 1.125rem;
font-weight: 200;
line-height: 1.8;
}
.font-light {
font-weight: 300;
}
.font-regular {
font-weight: 400;
}
.font-heavy {
font-weight: 700;
}
/* POSITIONING */
.left {
text-align: left;
}
.right {
text-align: right;
}
.center {
text-align: center;
margin-left: auto;
margin-right: auto;
}
.justify {
text-align: justify;
}
/* ==== GRID SYSTEM ==== */
.container {
width: 100%;
margin-left: auto;
margin-right: auto;
}
.row {
position: relative;
}
.row [class^=col] {
float: left;
margin: 0.5rem 2%;
min-height: 0.125rem;
}
.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 {
width: 96%;
margin: auto;
}
.col-1-sm {
width: 4.33%;
}
.col-2-sm {
width: 12.66%;
}
.col-3-sm {
width: 21%;
}
.col-4-sm {
width: 29.33%;
}
.col-5-sm {
width: 37.66%;
}
.col-6-sm {
width: 46%;
}
.col-7-sm {
width: 54.33%;
}
.col-8-sm {
width: 62.66%;
}
.col-9-sm {
width: 71%;
}
.col-10-sm {
width: 79.33%;
}
.col-11-sm {
width: 87.66%;
}
.col-12-sm {
width: 96%;
}
.row::after {
content: "";
display: table;
clear: both;
}
.hidden-sm {
display: none;
}
@media only screen and (min-width: 33.75em) {
/* 540px */
.container {
width: 80%;
}
}
@media only screen and (min-width: 45em) {
/* 720px */
.col-1 {
width: 4.33%;
}
.col-2 {
width: 12.66%;
}
.col-3 {
width: 21%;
}
.col-4 {
width: 29.33%;
}
.col-5 {
width: 37.66%;
}
.col-6 {
width: 46%;
}
.col-7 {
width: 54.33%;
}
.col-8 {
width: 62.66%;
}
.col-9 {
width: 71%;
}
.col-10 {
width: 79.33%;
}
.col-11 {
width: 87.66%;
}
.col-12 {
width: 96%;
}
.hidden-sm {
display: block;
}
}
@media only screen and (min-width: 60em) {
/* 960px */
.container {
width: 75%;
max-width: 60rem;
}
}
/* CSS declarations go here */
.Mongol_row, .Mongol_row_name {
color: white;
}
* {
box-sizing: border-box;
}
html {
padding: 0;
margin: 0;
}
body {
font-family: sans-serif;
background-attachment: fixed;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
padding: 0;
margin: 0;
}
h1 {
font-size: 1.5em;
margin: 0 0 1rem 0;
display: inline-block;
}
form {
margin-top: 10px;
margin-bottom: 10px;
position: relative;
}
.new-task input {
box-sizing: border-box;
padding: 10px 0;
background: transparent;
border: none;
width: 100%;
padding-right: 80px;
font-size: 1em;
}
.new-task input:focus {
outline: 0;
}
ul {
margin: 0;
padding: 0;
background: white;
}
.delete {
float: right;
font-weight: bold;
background: none;
font-size: 1em;
border: none;
position: relative;
}
table {
min-width: 650px;
}
table > thead > tr > th.headerCell {
color: white;
background-color: #333447;
}
table > thead.sticky > tr > th.headerCell {
position: sticky;
top: 0;
}
table > tbody > tr > td {
background: white;
user-select: none;
}
table > tbody > tr.selected > td {
background: #f8ef8d;
}
table .tableRow:last-child td, table .tableRow:last-child th {
border: 0;
}
.userTable {
max-height: 64rem;
}
li {
position: relative;
list-style: none;
padding: 15px;
border-bottom: #eee solid 1px;
}
li .text {
margin-left: 10px;
}
li.checked {
color: #888;
}
li.checked .text {
text-decoration: line-through;
}
li.private {
background: #eee;
border-color: #ddd;
}
header .hide-completed {
float: right;
}
.toggle-private {
margin-left: 5px;
}
html {
font-size: 16px;
}
@media (max-width: 900px) {
html {
font-size: 14px;
}
}
@media (max-width: 600px) {
li {
padding: 12px 15px;
}
.search {
width: 150px;
clear: both;
}
.new-task input {
padding-bottom: 5px;
}
html {
font-size: 10px;
}
}
@font-face {
font-family: "Material Icons";
font-style: normal;
font-weight: 400;
src: local("Material Icons"), local("MaterialIcons-Regular"), url(/fonts/MaterialIcons-Regular.ttf) format("truetype");
}
.material-icons {
font-family: "Material Icons";
font-weight: normal;
font-style: normal;
font-size: 24px;
/* Preferred icon size */
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Support for IE. */
font-feature-settings: "liga";
}
.material-symbols-outlined {
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 48;
}
@font-face {
font-family: "Roboto";
font-style: normal;
font-weight: 400;
src: local("Roboto"), local("Roboto-Regular"), url(/fonts/Roboto-Regular.ttf) format("truetype");
}
@font-face {
font-family: "KaushanScript-Regular";
font-style: normal;
font-weight: 400;
src: url("/fonts/KaushanScript-Regular.ttf") format("truetype");
}
.nav-separator {
height: 0.2rem;
width: 40%;
margin-left: 30%;
background-color: white;
}
nav {
font-size: 1.4rem;
}
nav a {
display: inline-block;
color: #AF8A62;
text-decoration: none;
}
nav a:hover {
color: #F9F1E1;
text-decoration: underline;
}
nav a:active {
color: #F9F1E1;
text-decoration: none;
}
nav a + a {
margin-left: 2rem;
}
.pageHeaderContainer {
background-image: url("/images/header.svg");
background-origin: border-box;
background-size: cover;
background-position: center;
}
.pageHeader {
color: white;
/*background-image: linear-gradient(to bottom, #d0edf5, #e1e5f0 100%)*/
padding: 20px 15px 15px 15px;
position: relative;
}
.pageNavContainer {
background: #28211A;
}
.pageContentContainer {
margin: 0 auto;
width: 85%;
padding-top: 2rem;
}
.title {
margin-bottom: 0;
color: white;
font-family: "KaushanScript-Regular", sans-serif;
font-size: 4rem;
display: inline-block;
}
.logo {
position: absolute;
left: 0;
top: 0;
width: 8rem;
}
.logoContainer {
height: 0;
position: relative;
}
.login {
position: absolute;
right: 0;
top: 0;
}
.login button {
background: #28211A;
border-radius: 999px;
box-shadow: 0.2rem 0.3rem 0.4rem 0.1rem #886f59;
box-sizing: border-box;
color: #FFFFFF;
cursor: pointer;
font-family: Inter, Helvetica, "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols, -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", sans-serif;
font-weight: 700;
line-height: 24px;
opacity: 1;
outline: 0 solid transparent;
padding: 8px 18px;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
width: fit-content;
word-break: break-word;
border: 0;
}
@media (max-width: 600px) {
.logo {
position: absolute;
left: 0;
right: 0;
top: 0;
margin: 0 auto;
width: 8rem;
}
.logoContainer {
height: 6rem;
}
}
.userTable .userEditorContainer {
width: 100%;
}
.userTable .userEditorGrid {
display: grid;
grid-template-columns: auto 10rem 10rem;
width: 100%;
}
.userTable .insetPermissions {
padding-left: 2rem;
}
/*# sourceMappingURL=main.css.map */

1
client/main.css.map Normal file
View File

@@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["_simple-grid.sass","_app.sass","_material-icons.sass","_roboto.sass","_page.sass","pages/_Users.sass"],"names":[],"mappings":"AAAQ;AAER;EACC;EACA;EACA;EACA;EACA;EACA;;;AAED;AAEA;EACC;EACA;EACA;;;AAED;AAEA;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;EACA;EACA;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;AAEA;EACC;;;AAED;EACC;;;AAED;EACC;EACA;EACA;;;AAED;EACC;;;AAED;AAEA;EAEC;EACA;EACA;;;AAED;EACC;;AAGA;EACC;EACA;EACA;;;AAEF;EACC;EACA;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;EACA;EACA;;;AAED;EACC;;;AAED;AACC;EAEA;IACC;;;AAEF;AACC;EAEA;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;EAED;IACC;;;AAEF;AACC;EAEA;IACC;IACA;;;ACzLF;AACA;EACC;;;AAED;EACC;;;AAED;EACC;EACA;;;AAED;EACC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAQD;EACC;EACA;EACA;;;AAED;EACC;EACA;EACA;;;AAED;EACC;EACA;EACA;EACA;EACA;EACA;EACA;;;AAED;EACC;;;AAED;EACC;EACA;EACA;;;AAED;EACC;EACA;EACA;EACA;EACA;EACA;;;AAED;EACC;;;AAED;EACC;EACA;;;AAED;EACC;EACA;;;AAED;EACC;EACA;;;AAED;EACC;;;AAGA;EACC;;;AAEF;EACC;;;AAED;EACC;EACA;EACA;EACA;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;EACA;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;;;AAED;EACC;IACC;;;AAEF;EACC;IACC;;EAED;IACC;IACA;;EAED;IACC;;EAED;IACC;;;AClIF;EACC;EACA;EACA;EACA;;AAED;EACC;EACA;EACA;EACA;AACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;AACA;EACA;AACA;EACA;AACA;EACA;AACA;EACA;;;AAED;EACC;;;AC7BD;EACC;EACA;EACA;EACA;;ACND;EACC;EACA;EACA;EACA;;AAED;EACC;EACA;EACA;EACA;;;AAED;EACC;;;AAED;EACC;EACA;EACA;;;AAED;EACC;EACA;;;AACD;EACC;EACA;;;AAED;EACC;;;AAED;EACC;EACA;EACA;EACA;;;AAED;EAEC;AACA;EACA;EACA;;;AAED;EACC;;;AAED;EACC;EACA;EACA;;;AAED;EACC;EACA;EACA;EACA;EACA;;;AAED;EACC;EACA;EACA;EACA;;;AAED;EACC;EACA;;;AAED;EACC;EACA;EACA;;;AAED;EACC;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAED;EACC;IACC;IACA;IACA;IACA;IACA;IACA;;EAED;IACC;;;ACvGD;EACC;;AAED;EACC;EACA;EACA;;AAED;EACC","file":"main.css"}

10
client/main.html Normal file
View File

@@ -0,0 +1,10 @@
<head>
<title>K12 Tempest</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meteor-bundled-css />
<link rel="icon" type="image/x-icon" href="/favicon.png">
</head>
<body>
<div id="react-target"></div>
</body>

10
client/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import { Meteor } from 'meteor/meteor';
import { createRoot } from 'react-dom/client';
import {App} from '/imports/ui/App';
import '../imports/startup/accounts-config.js';
Meteor.startup(() => {
createRoot(document.getElementById('react-target')).render(<App/>)
//render(<App/>, document.getElementById('react-target'));
});

16
client/main.sass Normal file
View File

@@ -0,0 +1,16 @@
@import "./simple-grid.sass"
@import "./app.sass"
@import './material-icons.sass'
@import './roboto.sass'
@import './page.sass'
@import './pages/Users.sass'
//@import './smui.sass'
//.mdc-floating-label
// transform: translateY(-80%)
//.mdc-select__selected-text-container
// transform: translateY(30%)

11
client/pages/_Users.sass Normal file
View File

@@ -0,0 +1,11 @@
.userTable
.userEditorContainer
width: 100%
.userEditorGrid
display: grid
grid-template-columns: auto 10rem 10rem
width: 100%
.insetPermissions
padding-left: 2rem

60
imports/api/admin.js Normal file
View File

@@ -0,0 +1,60 @@
import {Meteor} from "meteor/meteor";
import { _ } from 'underscore';
import { Roles } from 'meteor/alanning:roles';
// console.log("Setting Up Admin...")
if (Meteor.isServer) {
Meteor.methods({
'admin.fixRecords'(input) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
console.log("In Fix Records");
console.log("Deleting invalid records...");
// Delete any records missing key fields.
Meteor.Records.remove({serial: {$exists: false}});
Meteor.Records.remove({deviceId: {$exists: false}});
Meteor.Records.remove({endTime: {$exists: false}});
console.log("Consolidating records...");
let emails = _.uniq(Meteor.Records.find({}, {
sort: {email: 1},
fields: {email: true}
}).fetch().map(function (x) {
return x.email;
}), true);
emails.forEach(email => {
// Find all records for the user sorted from oldest to newest.
let records = Meteor.Records.find({email}, {sort: {startTime: 1}}).fetch();
let newRecords = [];
let record = records[0];
for (let index = 1; index < records.length; index++) {
let nextRecord = records[index];
if (record.deviceId === nextRecord.deviceId) {
record.endTime = nextRecord.endTime;
record.count += nextRecord.count;
record.internalCount += nextRecord.internalCount;
} else {
if (!record.endTime) record.endTime = nextRecord.startTime;
newRecords.push(record);
record = nextRecord;
}
}
newRecords.push(record);
Meteor.Records.remove({email});
for (let index = 0; index < newRecords.length; index++) {
Meteor.Records.insert(newRecords[index]);
}
});
}
},
});
}
// console.log("Admin setup.")

View File

@@ -0,0 +1,20 @@
import {Mongo} from "meteor/mongo";
export const AssetAssignmentHistory = new Mongo.Collection('assetAssignmentHistory');
/*
Maintains a historical record of asset assignments.
assetKey: The MongoID of the asset. AssetID's could be reused, so this prevents that eventuality from messing up historical records.
assetId: The asset's assigned ID (not a MongoID).
serial: The asset's serial number (part of the device, or defined by the manufacturer). In some cases this may be a partial serial. It is not a unique identifier, but should be mostly unique. This might be undefined if a serial was never provided for the original asset.
assetTypeName: The name of the asset type, or "UNK" if one could not be found (shouldn't happen). This is stored because these records could be kept longer than the assets in the system.
assigneeType: One of 'Student' or 'Staff'.
assigneeId: The MongoID of the student or staff the asset was assigned to.
startDate: The date/time of the assignment.
endDate: The date/time of the unassignment.
comment: A text block detailing the reason for the unassignment of the device. Eg: 'Broke Screen' or 'End of year' or 'Not charging' or 'Needs replacement'.
startCondition: One of the condition options: [New, Like New, Good, Okay, Damaged] (see assets.unassign for details).
endCondition: One of the condition options: [New, Like New, Good, Okay, Damaged] (see assets.unassign for details).
startConditionDetails: An optional text block for details on the condition.
endConditionDetails: An optional text block for details on the condition.
*/

View File

@@ -0,0 +1,79 @@
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";
// console.log("Setting Up Asset Types...")
//
// An asset type is a specific type of equipment. Example: Lenovo 100e Chromebook.
//
export const AssetTypes = new Mongo.Collection('assetTypes');
/*
const AssetTypesSchema = new SimpleSchema({
name: {
type: String,
label: "Model Name",
optional: false,
trim: true,
},
description: {
type: String,
label: "Description",
optional: true,
trim: true,
defaultValue: "",
},
});
AssetTypes.attachSchema(AssetTypesSchema);
*/
if (Meteor.isServer) {
// Drop any old indexes we no longer will use. Create indexes we need.
try {AssetTypes._dropIndex("External ID")} 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({});
});
}
Meteor.methods({
'assetTypes.add'(name, description, year) {
check(name, String);
check(description, String);
check(year, String);
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
AssetTypes.insert({name, description, year});
}
},
'assetTypes.update'(_id, name, description, year) {
check(_id, String);
check(name, String);
check(description, String);
check(year, String);
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
AssetTypes.update({_id}, {$set: {name, description, year}});
}
},
'assetTypes.remove'(_id) {
check(_id, String);
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
//TODO: Need to either remove all assets of this type, or change their type.
}
},
});
// console.log("Asset types setup.")

302
imports/api/assets.js Normal file
View File

@@ -0,0 +1,302 @@
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";
import {AssetAssignmentHistory} from "/imports/api/asset-assignment-history";
// console.log("Setting Up Assets...")
export const Assets = new Mongo.Collection('assets');
export const conditions = ['New','Like New','Good','Okay','Damaged']
/*
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
},
assigneeId: { //Should be undefined or non-existent if not assigned.
type: String,
label: "Assignee ID",
optional: true,
},
assigneeType: { // 0: Student, 1: Staff, Should be undefined or non-existent if not assigned.
type: SimpleSchema.Integer,
label: "Assignee Type",
optional: true,
min: 0,
max: 1,
exclusiveMin: false,
exclusiveMax: false,
},
assignmentDate: {
type: Date,
label: "Assignment Date",
optional: true,
},
condition: { //One of the condition options: [New, Like New, Good, Okay, Damaged] (see assets.unassign for details).
type: String,
label: "Condition",
optional: false,
},
conditionDetails: { //An optional text block for details on the condition.
type: String,
label: "Condition Details",
optional: true,
}
});
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.publish('assetsAssignedTo', function(personId) {
return Assets.find({assigneeId: personId});
});
}
Meteor.methods({
'assets.add'(assetTypeId, assetId, serial, condition, conditionDetails) {
check(assetTypeId, String);
check(assetId, String);
check(serial, String);
check(condition, String);
if(conditionDetails) check(conditionDetails, String);
if(condition !== 'New' && condition !== 'Like New' && condition !== 'Good' && condition !== 'Okay' && condition !== 'Damaged') {
//Should never happen.
console.error("Invalid condition option in assets.add(..)");
throw new Meteor.Error("Invalid condition option.");
}
// Convert the asset ID's to uppercase for storage to make searching easier.
assetId = assetId.toUpperCase();
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
let assetType = AssetTypes.findOne({assetTypeId});
if(Assets.findOne({assetId})) {
//return {error: true, errorType: 'duplicateAssetId'}
throw new Meteor.Error("Duplicate Asset Id", "Cannot use the same asset ID twice.")
}
else if(serial) {
Assets.insert({assetTypeId, assetId, serial, condition, conditionDetails});
}
else {
Assets.insert({assetTypeId, assetId, condition, conditionDetails});
}
}
else throw new Meteor.Error("User Permission Error");
},
'assets.update'(_id, assetTypeId, assetId, serial, condition, conditionDetails) {
check(_id, String);
check(assetTypeId, String);
check(assetId, String);
if(serial) check(serial, String);
check(condition, String);
if(conditionDetails) check(conditionDetails, String);
if(condition !== 'New' && condition !== 'Like New' && condition !== 'Good' && condition !== 'Okay' && condition !== 'Damaged') {
//Should never happen.
console.error("Invalid condition option in assets.update(..)");
throw new Meteor.Error("Invalid condition option.");
}
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}});
}
else throw new Meteor.Error("User Permission Error");
},
'assets.remove'(_id) {
check(_id, String);
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
let asset = Assets.findOne({_id});
if(asset) {
// Ensure the asset is not assigned still. Must unassign then remove. That allows us to maintain historical records for assignees.
if(asset.assigneeId) {
throw new Meteor.Error("Must unassign the asset before removal.");
}
else {
Assets.remove({_id});
}
}
else {
//This should never happen.
throw new Meteor.Error("Could not find the asset: " + _id);
}
}
else throw new Meteor.Error("User Permission Error");
},
/**
* Assigns the asset to the assignee. The assignee should either be a Student or Staff member.
* @param assetId The Asset ID (eg: 'Z1Q') of the asset (asset.assetId).
* @param assigneeType One of: 'Student', 'Staff'
* @param assigneeId The Mongo ID of the Student or Staff (person._id).
* @param condition One of the condition options: [New, Like New, Good, Okay, Damaged]. 'Like New' is defined as very minor cosmetic damage. 'Good' is defined as some cosmetic damage or very minor screen damage. 'Okay' is defined as significant cosmetic damage, or screen/keyboard/trackpad damage but is still useable. 'Damaged' indicates significant damage and the device should not be reissued until it is repaired.
* @param conditionDetails A text block detailing the current condition (if it is needed).
* @param date The date/time of the action. Will be set to the current date/time if not provided.
*/
'assets.assign'(assetId, assigneeType, assigneeId, condition, conditionDetails, date) {
check(assigneeId, String);
check(assigneeType, String);
check(assetId, String);
check(condition, String);
if(conditionDetails) check(conditionDetails, String);
if(date) check(date, Date);
if(!date) date = new Date();
if(condition !== 'New' && condition !== 'Like New' && condition !== 'Good' && condition !== 'Okay' && condition !== 'Damaged') {
//Should never happen.
console.error("Invalid condition option in assets.unassign(..)");
throw new Meteor.Error("Invalid condition option.");
}
if(assigneeType !== 'Student' && assigneeType !== 'Staff') {
// Should never happen.
console.error("Error: Received incorrect assignee type in adding an assignment.");
console.error(assigneeType);
throw new Meteor.Error("Error: Received incorrect assignee type in adding an assignment.");
}
else if(Roles.userIsInRole(Meteor.userId(), "laptop-management", {anyScope:true})) {
let asset = Assets.findOne({assetId});
if(asset) {
if(asset.assigneeId) {
//TODO: Should we unassign and re-assign????
console.error("Asset is already assigned! " + assetId);
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}});
}
}
else {
console.error("Could not find the asset: " + assetId)
}
}
else throw new Meteor.Error("User Permission Error");
},
/**
* Removes an assignment for the asset.
* TODO: Should create a historical record.
* @param assetId The Asset ID (eg: 'Z1Q') of the asset (asset.assetId).
* @param comment A textual comment on the reason for unassigning the asset. Should not contain condition information.
* @param condition One of the condition options: [New, Like New, Good, Okay, Damaged]. 'Like New' is defined as very minor cosmetic damage. 'Good' is defined as some cosmetic damage or very minor screen damage. 'Okay' is defined as significant cosmetic damage, or screen/keyboard/trackpad damage but is still useable. 'Damaged' indicates significant damage and the device should not be reissued until it is repaired.
* @param conditionDetails A text block detailing the current condition (if it is needed).
* @param date The date/time of the action. Will be set to the current date/time if not provided.
*/
'assets.unassign'(assetId, comment, condition, conditionDetails, date) {
check(assetId, String);
if(date) check(date, Date);
if(comment) check(comment, String);
check(condition, String);
if(conditionDetails) check(conditionDetails, String);
if(!date) date = new Date();
if(condition !== 'New' && condition !== 'Like New' && condition !== 'Good' && condition !== 'Okay' && condition !== 'Damaged') {
//Should never happen.
console.error("Invalid condition option in assets.unassign(..)");
throw new Meteor.Error("Invalid condition option.");
}
if(Roles.userIsInRole(Meteor.userId(), "laptop-management", {anyScope:true})) {
let asset = Assets.findOne({assetId});
if(asset) {
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});
} catch (e) {
console.error(e);
}
Assets.update({assetId}, {$unset: {assigneeType: "", assigneeId: "", assignmentDate: ""}, $set: {condition, conditionDetails}});
}
else {
console.error("Could not find the asset: " + assetId);
throw new Meteor.Error("Could not find the asset: " + assetId);
}
}
else throw new Meteor.Error("User Permission Error");
},
/**
* A fix to remove the AssetAssignment collection and merge it with the Asset collection.
*/
'assets.fixAssetAssignments'() {
// Removed this since it should no longer be relevant.
// let assignmentDate = new Date();
// //This function just removes the need for the asset-assignments collection and merges it with assets.
// if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
// let assets = Assets.find({}).fetch();
// let assetAssignments = AssetAssignments.find({}).fetch();
//
// let assetMap = assets.reduce((map, obj) => {
// map[obj.assetId] = obj;
// return map;
// }, {});
//
// console.log(assetMap);
// console.log("");
//
// for(let next of assetAssignments) {
// console.log(next);
// let asset = assetMap[next.assetId];
// console.log("Updating " + asset.assetId + " to be assigned to " + next.assigneeType + ": " + next.assigneeId);
// let c = Assets.update({assetId: asset.assetId}, {$set: {assigneeType: next.assigneeType, assigneeId: next.assigneeId, assignmentDate}});
// console.log("Updated " + c + " Assets");
// console.log(Assets.findOne({assetId: asset.assetId}));
// }
// }
},
/**
* A fix to remove the AssetAssignment collection and merge it with the Asset collection.
*/
'assets.fixAssetCondition'() {
// Removed this since it should no longer be relevant.
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
Assets.update({assetTypeId: 'xPu8YK39pmQW93Fuz', condition: {$exists: false}}, {$set: {condition: 'Okay', conditionDetails: 'Automated Condition'}}, {multi: true}); //Lenovo E100 CB
Assets.update({assetTypeId: 'casMp4pJ9t8FtpyuR', condition: {$exists: false}}, {$set: {condition: 'Good', conditionDetails: 'Automated Condition'}}, {multi: true}); //Lenovo E100 Charger
Assets.update({assetTypeId: 'ZD9XiHqGr6TcKH9Nv', condition: {$exists: false}}, {$set: {condition: 'New'}}, {multi: true}); //Acer CB315 CB
Assets.update({assetTypeId: 'mfE9NtiFBotb8kp4v', condition: {$exists: false}}, {$set: {condition: 'New'}}, {multi: true}); //Acer CB315 Charger
Assets.update({assetTypeId: 'btEsKYxW4Sgf7T8nA', condition: {$exists: false}}, {$set: {condition: 'Good',conditionDetails: 'Automated Condition'}}, {multi: true}); //Dell 3100 Charger
Assets.update({assetTypeId: '9bszeFJNPteMDbye5', condition: {$exists: false}}, {$set: {condition: 'Like New',conditionDetails: 'Automated Condition'}}, {multi: true}); //HP 11A CB
Assets.update({assetTypeId: 'tCj7s5T2YcFXZEaqE', condition: {$exists: false}}, {$set: {condition: 'Like New',conditionDetails: 'Automated Condition'}}, {multi: true}); //HP 11A Charger
}
}
});
// console.log("Assets setup.")

View File

@@ -0,0 +1,149 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
import { MongoClient } from 'mongodb';
import {Assets} from "/imports/api/assets";
import {Students} from "/imports/api/students";
import {Staff} from "/imports/api/staff";
import {AssetTypes} from "/imports/api/asset-types";
//import {Roles} from 'alanning/roles';
// console.log("Setting Up Data Collection...")
//export const Records = new Mongo.Collection('records');
let client;
let database;
let dataCollection;
if (Meteor.isServer) {
// let uri = process.env.MONGO_URL2;
// uri = "mongodb://localhost:27017";
//
// //client = new MongoClient(uri);
// MongoClient.connect(uri, (err, c) => {
// client = c;
// database = client.db("avusd-data-collection");
// dataCollection = database.collection("records");
// dataCollection.find({deviceId: "1e3e99ef-adf4-4aa2-8784-205bc60f0ce3"}).toArray((e, a) => {
// if(e) console.log(e);
// else console.log("Found " + a.length + " records.");
// })
// });
// let results = Meteor.Records.find({deviceId: "1e3e99ef-adf4-4aa2-8784-205bc60f0ce3"}).fetch();
// console.log(results);
}
if (Meteor.isServer) {
Meteor.methods({
/**
* Collects Chromebook history given one of the possible parameters.
* @param params An object with a single attribute. The attribute must be one of: deviceId, serial, email. It will find all Chromebook data that starts with the given attribute value.
* @returns {any} Array of Chromebook data objects.
*/
'DataCollection.chromebookData'(params) {
if(Roles.userIsInRole(Meteor.userId(), "laptop-management", {anyScope:true})) {
let query = {};
// For asset ID's, we need to get the serial from the asset collection first.
if(params.assetId) {
let asset = Assets.findOne({assetId : params.assetId});
if(asset) {
params.serial = asset.serial;
params.regex = false;
}
}
if (params.deviceId) query.deviceId = params.regex ? {
$regex: params.deviceId,
$options: "i"
} : params.deviceId;
else if (params.serial) query.serial = params.regex ? {
$regex: params.serial,
$options: "i"
} : params.serial;
// else if (params.assetId) {
// let asset = Assets.findOne({assetId: params.assetId});
//
// if(asset.serial) {
// // An exact search.
// query.serial = asset.serial;
// }
// }
else if (params.email) query.email = params.regex ? {
$regex: params.email,
$options: "i"
} : params.email;
else if (params.date) { //Assume that date is a number. Finds all Chromebook Data with the last check in time greater than or equal to the given date.
query.endTime = {'$gte': params.date}
}
else {
query = undefined;
}
if(query) {
// console.log("Collecting Chromebook Data: ");
// console.log(query);
//Sort by the last time the record was updated from most to least recent.
let result = Meteor.Records.find(query, {sort: {endTime: -1}}).fetch();
// console.log("Found: ");
// console.log(result);
//Add some additional data to the records.
for (let next of result) {
if (next.serial) {
next.asset = Assets.findOne({serial: next.serial});
}
if (next.email) {
next.person = Students.findOne({email: next.email});
if (!next.person) next.person = Staff.findOne({email: next.email});
}
if (next.asset) {
next.assetType = AssetTypes.findOne({_id: next.asset.assetType})
if (next.asset.assigneeId) {
next.assignedTo = next.asset.assigneeType === "Student" ? Students.findOne({_id: next.asset.assigneeId}) : Staff.findOne({_id: next.asset.assigneeId})
}
}
}
return result;
} else return null;
}
else return null;
}
// 'tasks.setChecked'(taskId, setChecked) {
// check(taskId, String);
// check(setChecked, Boolean);
//
// const task = Tasks.findOne(taskId);
// if (task.private && task.owner !== this.userId) {
// // If the task is private, make sure only the owner can check it off
// throw new Meteor.Error('not-authorized');
// }
//
// Tasks.update(taskId, { $set: { checked: setChecked } });
// },
// 'tasks.setPrivate'(taskId, setToPrivate) {
// check(taskId, String);
// check(setToPrivate, Boolean);
//
// const task = Tasks.findOne(taskId);
//
// // Make sure only the task owner can make a task private
// if (task.owner !== this.userId) {
// throw new Meteor.Error('not-authorized');
// }
//
// Tasks.update(taskId, { $set: { private: setToPrivate } });
// },
});
}
// console.log("Data Collection setup.")

View File

@@ -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);

11
imports/api/index.js Normal file
View File

@@ -0,0 +1,11 @@
import "./users.js";
import "./data-collection.js";
import "./admin.js";
import "./students.js";
import "./staff.js";
import "./sites.js";
import "./asset-types.js";
import "./assets.js";
import "./asset-assignment-history.js";
// console.log("Finished setting up server side models.");

40
imports/api/records.js Normal file
View File

@@ -0,0 +1,40 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
export const Records = new Mongo.Collection('records');
if (Meteor.isServer) {
// This code only runs on the server
Meteor.publish('records', function() {
return Records.find({});
});
}
Meteor.methods({
// 'tasks.setChecked'(taskId, setChecked) {
// check(taskId, String);
// check(setChecked, Boolean);
//
// const task = Tasks.findOne(taskId);
// if (task.private && task.owner !== this.userId) {
// // If the task is private, make sure only the owner can check it off
// throw new Meteor.Error('not-authorized');
// }
//
// Tasks.update(taskId, { $set: { checked: setChecked } });
// },
// 'tasks.setPrivate'(taskId, setToPrivate) {
// check(taskId, String);
// check(setToPrivate, Boolean);
//
// const task = Tasks.findOne(taskId);
//
// // Make sure only the task owner can make a task private
// if (task.owner !== this.userId) {
// throw new Meteor.Error('not-authorized');
// }
//
// Tasks.update(taskId, { $set: { private: setToPrivate } });
// },
});

38
imports/api/sites.js Normal file
View File

@@ -0,0 +1,38 @@
import {Mongo} from "meteor/mongo";
import {Meteor} from "meteor/meteor";
import {Students} from "./students";
import {Staff} from "./staff";
import { Roles } from 'meteor/alanning:roles';
export const Sites = new Mongo.Collection('sites');
if (Meteor.isServer) {
// This code only runs on the server
Meteor.publish('sites', function() {
return Sites.find({});
});
}
Meteor.methods({
'sites.update'(_id, name) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
Sites.update({_id}, {$set: {name}});
}
},
'sites.add'(name) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
Sites.insert({name});
}
},
'sites.remove'(_id) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
let site = Sites.find({_id});
if(site) {
//Clear any site references in student/room entries.
Students.update({siteId: _id}, {$unset: {siteId: 1}});
Staff.update({siteId: _id}, {$unset: {siteId: 1}});
Sites.remove({_id});
}
}
},
});

161
imports/api/staff.js Normal file
View File

@@ -0,0 +1,161 @@
import {Mongo} from "meteor/mongo";
import {Meteor} from "meteor/meteor";
import { Roles } from 'meteor/alanning:roles';
import {check} from "meteor/check";
import {Sites} from "/imports/api/sites";
import {parse} from "csv-parse";
// console.log("Setting Up Staff...")
export const Staff = new Mongo.Collection('staff');
if (Meteor.isServer) {
// This code only runs on the server
Meteor.publish('staff', function(siteId) {
if(siteId) check(siteId, String);
return siteId ? Staff.find({siteId}) : Staff.find({});
});
}
Meteor.methods({
'staff.add'(id, firstName, lastName, email, siteId) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
Staff.insert({id, firstName, lastName, email, siteId});
}
},
'staff.update'(_id, id, firstName, lastName, email, siteId) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
Staff.update({_id}, {$set: {id, firstName, lastName, email, siteId}});
}
},
'staff.remove'(_id) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
//TODO: Need to first verify there are no checked out assets to the staff member.
}
},
/**
* Assumes that the ID field is a unique ID that never changes for staff.
* This must be true in order for duplicate staff to be avoided.
* Will automatically update staff data, including the site he/she is associated with.
*
* Expects the CSV string to contain comma delimited data in the form:
* ID, email, first name, last name
*
* The query in Aeries is: `LIST STF ID FN LN EM PSC`.
* A more complete Aeries query: `LIST STF ID FN LN EM BY FN IF PSC = 5`
* Note that you will want to run this query for each school, and the district. The example above sets the school to #5 (PSC).
* Run the query in Aeries as a `Report`, select TXT, and upload here.
*
* 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
* StuEmail,Student ID,First Name,Last Name,Grade,First Name Alias,Last Name Alias
* @type: Currently only supports 'CSV' or 'Aeries Text Report'
*/
'staff.loadCsv'(csv, type, siteId) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
check(csv, String);
check(siteId, String);
let site = Sites.findOne({_id: siteId});
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).
// Remove headers from the CSV.
for(const line of lines) {
if (skip > 0) skip--;
else if (pageHeader && line === pageHeader) {
skip = 2;
} else {
if(!cleanCsv) cleanCsv = "";
else cleanCsv += '\r\n';
cleanCsv += line;
}
}
//console.log(cleanCsv);
// Note: This doesn't work because some values are quoted and contain commas as a value character.
// Parse the CSV (now without any headers).
// lines = cleanCsv.split(/\r\n/);
// for(const line of lines) {
// let values = line.split(/\s*,\s*/);
//
// if(values.length >= 5) {
// 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};
//
// // console.log(student);
// // Update or insert in the db.
// console.log("Upserting: " + student);
// Students.upsert({id}, {$set: student});
// }
// }
const bound = Meteor.bindEnvironment((callback) => {callback();});
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 staff = {siteId, email, id, firstName, lastName, active};
// Track the staff ID's and record duplicates. This is used to ensure our counts are accurate later.
if(foundIds.has(staff.id)) {
duplicates.push(staff.id);
}
else {
foundIds.add(staff.id);
}
//console.log(staff);
try {
Staff.upsert({id: staff.id}, {$set: staff});
}
catch(err) {
console.error(err);
}
}
console.log(duplicates.length + " records were duplicates:");
console.log(duplicates);
}
});
})
}
else {
console.log("Failed to find the site with the ID: " + siteId);
}
}
}
});
// console.log("Staff setup.")

150
imports/api/students.js Normal file
View File

@@ -0,0 +1,150 @@
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';
import {parse} from 'csv-parse';
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) {
if(siteId) check(siteId, String);
return siteId ? Students.find({siteId}) : Students.find({});
});
Meteor.methods({
'students.add'(id, firstName, lastName, email, siteId, grade) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
Students.insert({id, firstName, lastName, email, siteId, grade});
}
},
'students.update'(_id, id, firstName, lastName, email, siteId, grade) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
Students.update({_id}, {$set: {id, firstName, lastName, email, siteId, grade}});
}
},
'students.remove'(_id) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
//TODO: Need to first verify there are no checked out assets to the staff member.
}
},
'students.getPossibleGrades'() {
return Students.rawCollection().distinct('grade', {});
},
/**
* 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}});
}
},
/**
* Assumes that the ID field is a unique ID that never changes for a student.
* This must be true in order for duplicate students to be avoided.
* Will automatically update a student's data, including the site he/she is associated with.
*
* 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`
* 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];`.
* Run the query in Aeries as a `Report`, select TXT, and upload here.
*
* 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'
*/
'students.loadCsv'(csv, type, siteId) {
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
check(csv, String);
check(siteId, String);
let site = Sites.findOne({_id: siteId});
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).
// Remove headers from the CSV.
for(const line of lines) {
if (skip > 0) skip--;
else if (pageHeader && line === pageHeader) {
skip = 2;
} else {
if(!cleanCsv) cleanCsv = "";
else cleanCsv += '\r\n';
cleanCsv += line;
}
}
const bound = Meteor.bindEnvironment((callback) => {callback();});
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);
}
});
})
}
else {
console.log("Failed to find the site with the ID: " + siteId);
}
}
}
});
}

View File

@@ -0,0 +1,74 @@
import { Accounts } from 'meteor/accounts-base'
import { Roles } from 'meteor/alanning:roles'
import {Meteor} from "meteor/meteor";
console.log("Setting up accounts-config...")
if(Meteor.isClient) {
Accounts.ui.config({
passwordSignupFields: 'USERNAME_ONLY'
});
}
Accounts.config({
// Allow only certain email domains.
restrictCreationByEmailDomain: function(address) {
let pattern = process.env.EMAIL_REGEX;
return new RegExp(pattern, 'i').test(address)
}
});
if(Meteor.isServer) {
let adminEmail = process.env.ADMIN_EMAIL;
let watchForAdmin = false;
//Setup the roles.
Roles.createRole('admin', {unlessExists: true});
Roles.createRole('laptop-management', {unlessExists: true});
Roles.addRolesToParent('laptop-management', 'admin', {unlessExists: true});
//Roles.addUsersToRoles("zwbMiaSKHix4bWQ8d", 'admin', 'global', {unlessExists: true});
// If we are passed an email address that should be admin by default, then ensure that user is admin, or mark it as needing to be admin if the user ever logs in.
// Given that this app requires Google OAuth2, and we expect logins to be restricted to district email addresses, this should be very secure.
if(adminEmail) {
let user = Meteor.users.findOne({"services.google.email": adminEmail});
if(user) {
let assignment = Meteor.roleAssignment.findOne({'user._id': user._id, "role._id": "admin"});
// console.log("Admin Role Assignment: " + JSON.stringify(assignment));
if(!assignment) {
Roles.addUsersToRoles(user._id, ['admin']);
}
}
else {
watchForAdmin = true;
}
}
// Listen for users logging in so we can setup the admin user automatically once they log in the first time.
if(watchForAdmin) {
// TODO: It would be nice to remove this handler after the admin user is found, but the docs are pretty ambiguous about how to do that. Not a big deal, just annoying.
Accounts.onLogin(function (data) {
// console.log("User logged in:");
// console.log(data.user.services.google.email);
// data.user == Meteor.user()
//console.log(JSON.stringify(Meteor.user()));
if (watchForAdmin) {
try {
if (data.user.services.google.email === adminEmail) {
Roles.addUsersToRoles(data.user._id, ['admin']);
watchForAdmin = false;
}
} catch (err) {
console.log(err);
}
}
});
}
}
console.log("Finished setting up accounts-config.")

55
imports/ui/App.jsx Normal file
View File

@@ -0,0 +1,55 @@
import { Meteor } from 'meteor/meteor';
import {Roles} from 'meteor/alanning:roles';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import {BrowserRouter, Routes, Route} from 'react-router-dom';
import {Page} from './Page'
import Assignments from './pages/Assignments'
import Assets from './pages/Assets'
import History from './pages/History'
import Users from './pages/Users'
import Admin from './pages/Admin'
export const App = () => {
const {user, canManageLaptops, isAdmin} = useTracker(() => {
const user = Meteor.user();
const canManageLaptops = user && Roles.userIsInRole(user._id, 'laptop-management', 'global');
const isAdmin = user && Roles.userIsInRole(user._id, 'admin', 'global');
return {
user,
canManageLaptops,
isAdmin
}
})
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Page>
<div className="container">
<div className="row">
TODO: Some statistics and such.
</div>
</div>
</Page>}/>
<Route path="/assignments/*" element={<Page>
{canManageLaptops && <Assignments/>}
</Page>}/>
<Route path="/assets/*" element={<Page>
{isAdmin && <Assets/>}
</Page>}/>
<Route path="/admin/*" element={<Page>
{isAdmin && <Admin/>}
</Page>}/>
<Route path="/history/*" element={<Page>
{canManageLaptops && <History/>}
</Page>}/>
<Route path="/users/*" element={<Page>
{isAdmin && <Users/>}
</Page>}/>
</Routes>
</BrowserRouter>
)
}

77
imports/ui/Page.jsx Normal file
View File

@@ -0,0 +1,77 @@
import { Meteor } from 'meteor/meteor';
import {Roles} from 'meteor/alanning:roles';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import {Link} from 'react-router-dom';
import _ from 'lodash';
export const Page = (props) => {
const {user, canManageLaptops, isAdmin} = useTracker(() => {
const user = Meteor.user();
const canManageLaptops = user && Roles.userIsInRole(user._id, 'laptop-management', 'global');
const isAdmin = user && Roles.userIsInRole(user._id, 'admin', 'global');
return {
user,
canManageLaptops,
isAdmin
}
})
function performLogin() {
//Login style can be "popup" or "redirect". I am not sure we need to request and offline token.
Meteor.loginWithGoogle({loginStyle: "popup", requestOfflineToken: true}, (err) => {
if (err) {
console.log(err);
} else {
//console.log("Logged in");
}
})
}
function performLogout() {
Meteor.logout();
}
return (
<div>
<div className='pageHeaderContainer'>
<div className="container">
<header className="row pageHeader">
<div className="col-12 logoContainer">
<img className="logo" src="/images/logo.svg" alt="Logo"/>
<div className="login">
{!user ?
<button type="button" role="button" onClick={performLogin}>Login</button>
:
<button type="button" role="button" onClick={performLogout}>Logout</button>
}
</div>
</div>
<div className="col-12 center title">K12 Tempest</div>
<div className="col-12 center">
<div className="nav-separator"/>
</div>
</header>
</div>
</div>
<div className='pageNavContainer'>
<div className='container'>
<header className='row pageNavHeader'>
<nav className="col-12 center">
<Link to='/'>Home</Link>
{canManageLaptops && <Link to="/history">History</Link>}
{canManageLaptops && <Link to="/assignments">Assignments</Link>}
{isAdmin && <Link to="/assets">Assets</Link>}
{isAdmin && <Link to="/users">Users</Link>}
{isAdmin && <Link to="/admin">Admin</Link>}
</nav>
</header>
</div>
</div>
<div className='pageContentContainer'>
{props.children}
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import React, {lazy, useState} from 'react';
import TabNav from '../util/TabNav';
// This is needed because there is some problem with the lazy loading of the pages when they import this file.
// Importing it here pre-loads it and avoids the issue.
// It has something to do with the students.js file and its import of the csv parsing library.
import {Students} from "/imports/api/students";
export default () => {
let tabs = [
{
title: "Sites",
getElement: () => {
const Sites = lazy(()=>import('./Admin/Sites'))
return <Sites/>
},
path: '/sites',
href: 'sites'
},
{
title: "Students",
getElement: () => {
const Students = lazy(()=>import('./Admin/Students'))
return <Students/>
},
path: '/students',
href: 'students'
},
{
title: "Staff",
getElement: () => {
const Staff = lazy(()=>import('./Admin/Staff'))
return <Staff/>
},
path: '/staff',
href: 'staff'
},
{
title: "Asset Types",
getElement: () => {
const AssetTypes = lazy(()=>import('./Admin/AssetTypes'))
return <AssetTypes/>
},
path: '/assetTypes',
href: 'assetTypes'
},
{
title: "Functions",
getElement: () => {
const Functions = lazy(()=>import('./Admin/Functions'))
return <Functions/>
},
path: '/functions',
href: 'functions'
},
]
return <TabNav tabs={tabs}/>
}

View File

@@ -0,0 +1,113 @@
import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import {AssetTypes} from "/imports/api/asset-types";
const cssFieldColumnContainer = {
display: 'flex',
flexDirection: 'column',
backgroundColor: '#DDD',
padding: '0.5rem',
border: '1px solid #999',
borderRadius: '0.2rem'
}
const cssEditorField = {
marginTop: '0.6rem',
}
const cssGridFieldContainer = {
display: 'grid',
gridTemplateColumns: "1fr 1fr",
columnGap: '1rem',
rowGap: '0.4rem',
marginBottom: '1.5rem'
}
const cssButtonContainer = {
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end'
}
const AssetTypeEditor = ({value, close}) => {
const [year, setYear] = useState(value.year || "")
const [name, setName] = useState(value.name || "")
const [description, setDescription] = useState(value.description || "")
const applyChanges = () => {
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);
else
Meteor.call("assetType.add", name, description, year);
}
const rejectChanges = () => {
close()
}
const change = (e) => {
console.log(e);
setYear(e.target.value);
}
return (
<div style={cssFieldColumnContainer}>
<h1>Asset Type Editor</h1>
<div style={cssGridFieldContainer}>
<TextField variant="standard" style={cssEditorField} label="Year" value={year} onChange={(e) => change}/>
<TextField variant="standard" style={cssEditorField} label="Name" value={name} onChange={(e) => {setName(e.target.value)}}/>
<TextField variant="outlined" style={{gridColumn: '1 / span 2',...cssEditorField}} multiline rows={4} label="Description" value={description} onChange={(e) => {setDescription(e.target.value)}}/>
</div>
<div style={cssButtonContainer}>
<Button variant="contained" className="button accept-button" onClick={applyChanges}>Accept</Button>
<Button variant="outlined" className="button reject-button" onClick={rejectChanges}>Reject</Button>
</div>
</div>
)
}
export default () => {
Meteor.subscribe('assetTypes');
const {assetTypes} = useTracker(() => {
let assetTypes = AssetTypes.find().fetch();
return {assetTypes}
});
const columns = [
{
name: "Year",
value: (row) => row.year,
},
{
name: "Name",
value: (row) => row.name,
},
{
name: "Description",
value: (row) => row.description,
},
]
const options = {
key: (row) => row._id,
editor: (row, close) => {return (<AssetTypeEditor value={row} close={close}/>)},
add: true,
maxHeight: '40rem',
keyHandler: (e, selected) => {
if(selected && selected._id && e.key === "Delete") {
Meteor.call("assetType.remove", selected._id);
}
}
}
return (
<SimpleTable rows={assetTypes} columns={columns} options={options}/>
)
}

View File

@@ -0,0 +1,6 @@
import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
export default () => {return (<div>None</div>)}

View File

@@ -0,0 +1,90 @@
import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import {Sites} from "/imports/api/sites";
const cssEditorField = {
margin: '0.6rem 0',
}
const cssFieldContainer = {
display: 'flex',
flexDirection: 'column',
backgroundColor: '#DDD',
padding: '0.5rem',
border: '1px solid #999',
borderRadius: '0.2rem',
}
const cssButtonContainer = {
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end'
}
const SiteEditor = ({value, close}) => {
const [name, setName] = useState(value.name || "")
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);
else
Meteor.call("sites.add", name);
}
const rejectChanges = () => {
close()
}
return (
<div style={cssFieldContainer}>
<h1>Site Editor</h1>
<TextField style={cssEditorField} variant="standard" label="Name" value={name} onChange={(e) => {setName(e.target.value)}}/>
<div style={cssButtonContainer}>
<Button variant="contained" className="button accept-button" onClick={applyChanges}>Accept</Button>
<Button variant="outlined" className="button reject-button" onClick={rejectChanges}>Reject</Button>
</div>
</div>
)
}
export default () => {
Meteor.subscribe('sites');
const {sites} = useTracker(() => {
const sites = Sites.find({}).fetch();
return {
sites
}
});
const columns = [
{
name: "Name",
value: (row) => row.name,
},
]
const options = {
key: (row) => row._id,
editor: (row, close) => {return (<SiteEditor value={row} close={close}/>)},
add: true,
maxHeight: '40rem',
keyHandler: (e, selected) => {
if(selected && selected._id && e.key === "Delete") {
Meteor.call("sites.remove", selected._id);
}
}
}
return (
<>
<SimpleTable rows={sites} columns={columns} options={options}/>
</>
)
}

View File

@@ -0,0 +1,153 @@
import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import {Staff} from "/imports/api/staff";
import {Sites} from "/imports/api/sites";
const cssSitesSelect = {
margin: '0.6rem 0',
minWidth: '20rem',
}
const cssFieldColumnContainer = {
display: 'flex',
flexDirection: 'column',
backgroundColor: '#DDD',
padding: '0.5rem',
border: '1px solid #999',
borderRadius: '0.2rem'
}
const cssGridFieldContainer = {
display: 'grid',
gridTemplateColumns: "1fr 1fr 1fr",
columnGap: '1rem',
rowGap: '0.4rem',
marginBottom: '1.5rem'
}
const cssButtonContainer = {
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end'
}
const StaffEditor = ({value, close, defaultSiteId}) => {
const [email, setEmail] = useState(value.email || "")
const [id, setId] = useState(value.id || "")
const [firstName, setFirstName] = useState(value.firstName || "")
const [lastName, setLastName] = useState(value.lastName || "")
const [siteId, setSiteId] = useState(value.siteId ? value.siteId : defaultSiteId)
const {sites} = useTracker(() => {
let sites = Sites.find({}).fetch();
return {sites}
});
if(!siteId && sites && sites.length > 0) {
setSiteId(sites[0]._id)
}
const applyChanges = () => {
close()
//TODO Should invert this and only close if there was success on the server.
if(value._id)
Meteor.call("staff.update", value._id, id, firstName, lastName, email, siteId);
else
Meteor.call("staff.add", id, firstName, lastName, email, siteId);
}
const rejectChanges = () => {
close()
}
return (
<div style={cssFieldColumnContainer}>
<h1>Staff Editor</h1>
<div style={cssGridFieldContainer}>
<TextField variant="standard" label="ID" value={id} onChange={(e) => {setId(e.target.value)}}/>
<TextField variant="standard" label="Email" value={email} onChange={(e) => {setEmail(e.target.value)}}/>
<div/>
<TextField variant="standard" label="First Name" value={firstName} onChange={(e) => {setFirstName(e.target.value)}}/>
<TextField variant="standard" label="Last Name" value={lastName} onChange={(e) => {setLastName(e.target.value)}}/>
<TextField select variant="standard" label="Site" value={siteId} onChange={(e) => {setSiteId(e.target.value)}}>
{sites.map((next, i) => {
return <MenuItem key={next._id} value={next._id}>{next.name}</MenuItem>
})}
</TextField>
</div>
<div style={cssButtonContainer}>
<Button variant="contained" className="button accept-button" onClick={applyChanges}>Accept</Button>
<Button variant="outlined" className="button reject-button" onClick={rejectChanges}>Reject</Button>
</div>
</div>
)
}
export default () => {
const siteAll = {_id: 0, name: "All"}
const [site, setSite] = useState(siteAll._id)
Meteor.subscribe('sites');
Meteor.subscribe('staff');
const {sites} = useTracker(() => {
const sites = Sites.find({}).fetch();
sites.push(siteAll);
return {sites}
});
const {staff} = useTracker(() => {
const staffQuery = site === siteAll._id ? {} : {siteId: site}
let staff = Staff.find(staffQuery).fetch();
return {staff}
});
const columns = [
{
name: "ID",
value: (row) => row.id,
},
{
name: "Email",
value: (row) => row.email,
},
{
name: "First Name",
value: (row) => row.firstName,
},
{
name: "Last Name",
value: (row) => row.lastName,
},
]
const options = {
key: (row) => row._id,
editor: (row, close) => {return (<StaffEditor value={row} close={close} defaultSiteId={site}/>)},
add: true,
maxHeight: '40rem',
keyHandler: (e, selected) => {
if(selected && selected._id && e.key === "Delete") {
Meteor.call("staff.remove", selected._id);
}
}
}
return (
<>
<TextField label="Site" style={cssSitesSelect} select variant="standard" value={site} onChange={(e)=>{setSite(e.target.value)}}>
{sites.map((next, i) => {
return <MenuItem key={next._id} value={next._id}>{next.name}</MenuItem>
})}
</TextField>
<SimpleTable rows={staff} columns={columns} options={options}/>
</>
)
}

View File

@@ -0,0 +1,158 @@
import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import {Students} from "/imports/api/students";
import {Sites} from "/imports/api/sites";
const cssSitesSelect = {
margin: '0.6rem 0',
minWidth: '20rem',
}
const cssFieldColumnContainer = {
display: 'flex',
flexDirection: 'column',
backgroundColor: '#DDD',
padding: '0.5rem',
border: '1px solid #999',
borderRadius: '0.2rem'
}
const cssGridFieldContainer = {
display: 'grid',
gridTemplateColumns: "1fr 1fr 1fr",
columnGap: '1rem',
rowGap: '0.4rem',
marginBottom: '1.5rem'
}
const cssButtonContainer = {
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end'
}
const StudentEditor = ({value, close, defaultSiteId}) => {
const [email, setEmail] = useState(value.email || "")
const [id, setId] = useState(value.id || "")
const [firstName, setFirstName] = useState(value.firstName || "")
const [lastName, setLastName] = useState(value.lastName || "")
const [grade, setGrade] = useState(value.grade || "")
const [siteId, setSiteId] = useState(value.siteId ? value.siteId : defaultSiteId)
const {sites} = useTracker(() => {
let sites = Sites.find({}).fetch();
return {sites}
});
if(!siteId && sites && sites.length > 0) {
setSiteId(sites[0]._id)
}
const applyChanges = () => {
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);
else
Meteor.call("students.add", id, firstName, lastName, email, siteId, grade);
}
const rejectChanges = () => {
close()
}
return (
<div style={cssFieldColumnContainer}>
<h1>Student Editor</h1>
<div style={cssGridFieldContainer}>
<TextField variant="standard" label="ID" value={id} onChange={(e) => {setId(e.target.value)}}/>
<TextField variant="standard" label="Email" value={email} onChange={(e) => {setEmail(e.target.value)}}/>
<TextField variant="standard" label="Grade" value={grade} onChange={(e) => {setGrade(e.target.value)}}/>
<TextField variant="standard" label="First Name" value={firstName} onChange={(e) => {setFirstName(e.target.value)}}/>
<TextField variant="standard" label="Last Name" value={lastName} onChange={(e) => {setLastName(e.target.value)}}/>
<TextField select variant="standard" label="Site" value={siteId} onChange={(e) => {setSiteId(e.target.value)}}>
{sites.map((next, i) => {
return <MenuItem key={next._id} value={next._id}>{next.name}</MenuItem>
})}
</TextField>
</div>
<div style={cssButtonContainer}>
<Button variant="contained" className="button accept-button" onClick={applyChanges}>Accept</Button>
<Button variant="outlined" className="button reject-button" onClick={rejectChanges}>Reject</Button>
</div>
</div>
)
}
export default () => {
const siteAll = {_id: 0, name: "All"}
const [site, setSite] = useState(siteAll._id)
Meteor.subscribe('sites');
Meteor.subscribe('students');
const {sites} = useTracker(() => {
const sites = Sites.find({}).fetch();
sites.push(siteAll);
return {sites}
});
const {students} = useTracker(() => {
const studentQuery = site === siteAll._id ? {} : {siteId: site}
let students = Students.find(studentQuery).fetch();
return {students}
});
const columns = [
{
name: "ID",
value: (row) => row.id,
},
{
name: "Email",
value: (row) => row.email,
},
{
name: "First Name",
value: (row) => row.firstName,
},
{
name: "Last Name",
value: (row) => row.lastName,
},
{
name: "GRD",
value: (row) => row.grade,
},
]
const options = {
key: (row) => row._id,
editor: (row, close) => {return (<StudentEditor value={row} close={close} defaultSiteId={site}/>)},
add: true,
maxHeight: '40rem',
keyHandler: (e, selected) => {
if(selected && selected._id && e.key === "Delete") {
Meteor.call("students.remove", selected._id);
}
}
}
return (
<>
<TextField label="Site" style={cssSitesSelect} select variant="standard" value={site} onChange={(e)=>{setSite(e.target.value)}}>
{sites.map((next, i) => {
return <MenuItem key={next._id} value={next._id}>{next.name}</MenuItem>
})}
</TextField>
<SimpleTable rows={students} columns={columns} options={options}/>
</>
)
}

View File

@@ -0,0 +1,30 @@
import { Meteor } from 'meteor/meteor';
import React, {lazy, Suspense, useState} from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import TabNav from '../util/TabNav';
export default () => {
let tabs = [
{
title: "Asset List",
getElement: () => {
const AssetList = lazy(()=>import('./Assets/AssetList'))
return <AssetList/>
},
path: '/assetList',
href: 'assetList'
},
{
title: "Add Assets",
getElement: () => {
const AddAssets = lazy(()=>import('./Assets/AddAssets'))
return <AddAssets/>
},
path: '/addAssets',
href: 'addAssets'
},
]
return <TabNav tabs={tabs}/>
}

View File

@@ -0,0 +1,134 @@
import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import { useTheme } from '@mui/material/styles';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import Chip from '@mui/material/Chip';
import MenuItem from '@mui/material/MenuItem';
import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material";
import {Assets, conditions} from "/imports/api/assets";
import {AssetTypes} from "/imports/api/asset-types";
import Box from "@mui/material/Box";
import OutlinedInput from '@mui/material/OutlinedInput';
import FormControl from '@mui/material/FormControl';
const cssContainer = {
display: 'flex',
flexDirection: 'row',
marginTop: '2rem',
backgroundColor: '#DDD',
padding: '0.5rem',
border: '1px solid #999',
borderRadius: '0.2rem'
}
const cssComponent = {
width: '100%',
marginTop: '1rem',
}
const cssEditorField = {
margin: '0.6rem 1rem',
minWidth: '10rem'
}
const AddAssets = ({assetTypes}) => {
const theme = useTheme();
const [selectedAssetTypes, setSelectedAssetTypes] = useState([])
const [selectedAssetType, setSelectedAssetType] = useState("")
const [assetId, setAssetId] = useState("")
const [serial, setSerial] = useState("")
const [condition, setCondition] = useState("New")
const [conditionDetails, setConditionDetails] = useState("")
const getSelectItemStyles = (value) => {
return {
fontWeight: selectedAssetTypes.indexOf(value) === -1 ? theme.typography.fontWeightRegular : theme.typography.fontWeightBold
}
}
const getAssetTypeListItemStyle = (assetType) => {
return {
backgroundColor: selectedAssetType === assetType ? '#EECFA6' : 'white'
}
}
const addAsset = () => {
//TODO: Check the inputs.
Meteor.call("assets.add", selectedAssetType._id, assetId, serial, condition, conditionDetails);
setAssetId("")
setSerial("")
}
return (
<>
<Box style={cssContainer}>
<FormControl style={cssComponent}>
<InputLabel id="selectAssetTypesLabel">Available Asset Types</InputLabel>
<Select labelId='selectAssetTypesLabel' multiple variant="standard"
value={selectedAssetTypes} onChange={(e)=>{setSelectedAssetTypes(e.target.value)}}
renderValue={(selected) => (
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 0.5}}>
{selected.map((value) => (
<Chip key={value.name} label={value.name}/>
))}
</Box>
)}
>
{assetTypes.map((assetType, i) => {
return <MenuItem key={i} value={assetType} style={getSelectItemStyles(assetType)}>{assetType.name}</MenuItem>
})}
</Select>
</FormControl>
</Box>
<Box style={cssContainer}>
<div style={{maxHeight: '26rem', overflowY:'auto', minWidth: '10rem', minHeight: '10rem'}}>
<List>
{selectedAssetTypes.map((next, i) => {
return (
<ListItemButton key={next._id} style={getAssetTypeListItemStyle(next)} selected={selectedAssetType === next} onClick={(e) => {setSelectedAssetType(next)}}>
<ListItemText primary={next.name} secondary={next.description}/>
</ListItemButton>
)
})}
</List>
</div>
<div style={{marginLeft: '1rem', display: 'flex', flexDirection: 'column'}}>
<div style={{display: 'flex', flexDirection: 'row'}}>
<TextField style={cssEditorField} variant="standard" label="Asset ID" value={assetId} onChange={(e) => {setAssetId(e.target.value)}}/>
<TextField style={cssEditorField} variant="standard" label="Serial" value={serial} onChange={(e) => {setSerial(e.target.value)}}/>
</div>
<div style={{display: 'flex', flexDirection: 'row'}}>
<TextField style={cssEditorField} select variant="standard" label="Condition" value={condition} onChange={(e)=>{setCondition(e.target.value)}}>
{conditions.map((condition, i) => {
return <MenuItem key={i} value={condition}>{condition}</MenuItem>
})}
</TextField>
</div>
<div style={{display: 'flex', flexDirection: 'row'}}>
<TextField style={{width: '100%', margin: '1rem'}} multiline variant="outlined" rows={4} label="Condition Details" value={conditionDetails} onChange={(e) => {setConditionDetails(e.target.value)}}/>
</div>
</div>
</Box>
<div style={{display: 'flex', flexDirection: 'row-reverse'}}>
<Button variant="contained" className="button" onClick={addAsset}>Add</Button>
</div>
</>
)
}
export default () => {
Meteor.subscribe('assetTypes');
const {assetTypes} = useTracker(() => {
const assetTypes = AssetTypes.find({}, {sort: {year: -1}}).fetch();
return {
assetTypes
}
});
return (
<AddAssets assetTypes={assetTypes}/>
)
}

View File

@@ -0,0 +1,135 @@
import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import {Assets, conditions} from "/imports/api/assets";
import {AssetTypes} from "/imports/api/asset-types";
const cssEditorField = {
margin: '0.6rem 0',
}
const cssGridFieldContainer = {
display: 'grid',
gridTemplateColumns: "1fr 1fr",
columnGap: '1rem',
rowGap: '0.4rem',
marginBottom: '1.5rem'
}
const cssFieldContainer = {
display: 'flex',
flexDirection: 'column',
backgroundColor: '#DDD',
padding: '0.5rem',
border: '1px solid #999',
borderRadius: '0.2rem',
}
const cssButtonContainer = {
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end'
}
const AssetEditor = ({value, close}) => {
const [assetId, setAssetId] = useState(value.assetId || "")
const [serial, setSerial] = useState(value.serial || "")
const [condition, setCondition] = useState(value.condition || "")
const [conditionDetails, setConditionDetails] = useState(value.conditionDetails || "")
const [assetTypeId, setAssetTypeId] = useState(value.assetTypeId || "")
const assetTypes = AssetTypes.find({}, {sort: {year: -1}});
const applyChanges = () => {
close()
//TODO Should invert this and only close if there was success on the server.
if(value._id)
Meteor.call("assets.update", value._id, assetTypeId, assetId, serial, condition, conditionDetails);
else
Meteor.call("assets.add", assetTypeId, assetId, serial, condition, conditionDetails);
}
const rejectChanges = () => {
close()
}
return (
<div style={cssFieldContainer}>
<h1>Asset Editor</h1>
<div style={cssGridFieldContainer}>
<TextField style={cssEditorField} select variant="standard" value={assetTypeId} onChange={(e)=>{setAssetTypeId(e.target.value)}} label="Asset Type">
{assetTypes.map((assetType, i) => {
return <MenuItem key={i} value={assetType._id}>{assetType.name}</MenuItem>
})}
</TextField>
<TextField style={cssEditorField} variant="standard" label="Asset ID" value={assetId} onChange={(e) => {setAssetId(e.target.value)}}/>
<TextField style={cssEditorField} select variant="standard" label="Condition" value={condition} onChange={(e)=>{setCondition(e.target.value)}}>
{conditions.map((condition, i) => {
return <MenuItem key={i} value={condition}>{condition}</MenuItem>
})}
</TextField>
<TextField style={cssEditorField} variant="standard" label="Serial" value={serial} onChange={(e) => {setSerial(e.target.value)}}/>
<TextField style={{gridColumn: '1 / span 2',...cssEditorField}} multiline variant="outlined" rows={4} label="Condition Details" value={conditionDetails} onChange={(e) => {setConditionDetails(e.target.value)}}/>
</div>
<div style={cssButtonContainer}>
<Button variant="contained" style={{gridColumn: '2/2'}} className="button accept-button" onClick={applyChanges}>Accept</Button>
<Button type="outlined" style={{gridColumn: '3/3'}} className="button reject-button" onClick={rejectChanges}>Reject</Button>
</div>
</div>
)
}
export default () => {
Meteor.subscribe('assetTypes');
Meteor.subscribe('assets');
const {assets} = useTracker(() => {
const assets = Assets.find({}).fetch();
const assetTypes = AssetTypes.find({}, {sort: {year: -1}}).fetch();
const assetTypeNameMap = assetTypes.reduce((map, obj) => {
map[obj._id] = obj;
return map;
}, {})
for(let asset of assets) {
asset.assetType = assetTypeNameMap[asset.assetTypeId]
}
return {
assets
}
});
const columns = [
{
name: "Asset ID",
value: (row) => row.assetId,
},
{
name: "Serial",
value: (row) => row.serial,
},
{
name: "Condition",
value: (row) => row.condition,
},
{
name: "AssetType",
value: (row) => row.assetType.name,
},
]
const options = {
key: (row) => row._id,
editor: (row, close) => {return (<AssetEditor value={row} close={close}/>)},
add: true,
maxHeight: '40rem'
}
return (
<>
<SimpleTable rows={assets} columns={columns} options={options}/>
</>
)
}

View File

@@ -0,0 +1,30 @@
import { Meteor } from 'meteor/meteor';
import React, {lazy, Suspense, useState} from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import TabNav from '../util/TabNav';
export default () => {
let tabs = [
{
title: "By Person",
getElement: () => {
const ByPerson = lazy(()=>import('./Assignments/ByPerson'))
return <ByPerson/>
},
path: '/byPerson',
href: 'byPerson'
},
{
title: "By Asset",
getElement: () => {
const ByAsset = lazy(()=>import('./Assignments/ByAsset'))
return <ByAsset/>
},
path: '/byAsset',
href: 'byAsset'
},
]
return <TabNav tabs={tabs}/>
}

View File

@@ -0,0 +1,139 @@
import { Meteor } from 'meteor/meteor';
import React, { useState, useEffect } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import { useTheme } from '@mui/material/styles';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import Chip from '@mui/material/Chip';
import MenuItem from '@mui/material/MenuItem';
import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material";
import Box from "@mui/material/Box";
import OutlinedInput from '@mui/material/OutlinedInput';
import FormControl from '@mui/material/FormControl';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Dialog from '@mui/material/Dialog';
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 {AssetTypes} from "/imports/api/asset-types";
import {Students} from "/imports/api/students";
import {Staff} from "/imports/api/staff";
const cssTwoColumnContainer = {
display: 'grid',
gridTemplateColumns: "1fr 1fr",
columnGap: '1rem',
rowGap: '0.4rem',
}
const cssEditorField = {
minWidth: '10rem'
}
const AssignmentsByAsset = () => {
const theme = useTheme();
const [assetId, setAssetId] = useState("")
//Dialog stuff.
const [openUnassignDialog, setOpenUnassignDialog] = 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;
if(assetId) {
foundAsset = Assets.findOne({assetId: assetId});
if(foundAsset) {
foundAsset.assetType = AssetTypes.findOne({_id: foundAsset.assetTypeId})
if(foundAsset.assigneeId)
foundAsset.assignee = foundAsset.assigneeType === "Student" ? Students.findOne({_id: foundAsset.assigneeId}) : Staff.findOne({_id: foundAsset.assigneeId})
}
}
return {foundAsset}
});
useEffect(() => {
if(assetIdInput) assetIdInput.focus()
})
const unassign = () => {
// Open the dialog to get condition and comment.
setUnassignComment("")
setUnassignCondition(foundAsset.condition ? foundAsset.condition : conditions[2])
setUnassignConditionDetails(foundAsset.conditionDetails || "")
setOpenUnassignDialog(true);
}
const unassignDialogClosed = (unassign) => {
setOpenUnassignDialog(false)
if(unassign === true) {
// Call assets.unassign(assetId, comment, condition, conditionDetails, date)
Meteor.call('assets.unassign', foundAsset.assetId, unassignComment, unassignCondition, unassignConditionDetails)
}
}
return (
<>
<Dialog open={openUnassignDialog} onClose={unassignDialogClosed}>
<DialogTitle>Unassign Asset</DialogTitle>
<DialogContent style={{display: 'flex', flexDirection: 'column'}}>
<div>
<TextField style={cssEditorField} select variant="standard" label="Condition" value={unassignCondition} onChange={(e)=>{setUnassignCondition(e.target.value)}}>
{conditions.map((condition, i) => {
return <MenuItem key={i} value={condition}>{condition}</MenuItem>
})}
</TextField>
</div>
<TextField style={{marginTop: '1rem',minWidth: '30rem'}} variant="standard" label="Comment" value={unassignComment} onChange={(e) => {setUnassignComment(e.target.value)}}/>
<TextField style={{marginTop: '1rem',minWidth: '30rem'}} multiline rows={4} variant="outlined" label="Condition Details" value={unassignConditionDetails} onChange={(e) => {setUnassignConditionDetails(e.target.value)}}/>
</DialogContent>
<DialogActions>
<Button onClick={() => unassignDialogClosed(true)}>Unassign</Button>
<Button onClick={() => unassignDialogClosed(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<Box style={{marginTop: '1rem',...cssTwoColumnContainer}}>
<TextField style={cssEditorField} variant="standard" label="Asset ID" inputRef={input=>setAssetIdInput(input)} value={assetId} onChange={(e) => {setAssetId(e.target.value)}}/>
</Box>
{foundAsset && (
<div>
<div>Serial: {foundAsset.serial}</div>
<div>Condition: {foundAsset.condition}</div>
<div>Condition Details: {foundAsset.conditionDetails}</div>
{foundAsset.assignee && (
<>
<div>Assigned on: {foundAsset.assignmentDate.toString()}</div>
<div>Assigned to: {foundAsset.assignee.firstName} {foundAsset.assignee.lastName} {foundAsset.assignee.grade && foundAsset.assignee.grade} ({foundAsset.assignee.email})</div>
<Button variant="contained" color='secondary' className="button" onClick={()=>unassign()}>Unassign</Button>
</>
)}
</div>
)}
</>
)
}
export default () => {
Meteor.subscribe('students');
Meteor.subscribe('staff');
Meteor.subscribe('assetTypes');
Meteor.subscribe('assets');
return (
<AssignmentsByAsset/>
)
}

View File

@@ -0,0 +1,282 @@
import { Meteor } from 'meteor/meteor';
import React, { useState, useEffect } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import { useTheme } from '@mui/material/styles';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import Chip from '@mui/material/Chip';
import MenuItem from '@mui/material/MenuItem';
import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material";
import Box from "@mui/material/Box";
import OutlinedInput from '@mui/material/OutlinedInput';
import FormControl from '@mui/material/FormControl';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Dialog from '@mui/material/Dialog';
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 {AssetTypes} from "/imports/api/asset-types";
import {Students} from "/imports/api/students";
import {Staff} from "/imports/api/staff";
const cssTwoColumnContainer = {
display: 'grid',
gridTemplateColumns: "1fr 1fr",
columnGap: '1rem',
rowGap: '0.4rem',
}
const cssEditorField = {
minWidth: '10rem'
}
const AssignmentsByPerson = () => {
const theme = useTheme();
const [searchType, setSearchType] = useState("Email")
const [search, setSearch] = useState("")
const [selectedPerson, setSelectedPerson] = useState("")
const [assetId, setAssetId] = useState("")
const [openAssignDialog, setOpenAssignDialog] = useState(false)
const [assignCondition, setAssignCondition] = useState(conditions[2])
const [assignConditionDetails, setAssignConditionDetails] = useState("")
//Dialog stuff.
const [openUnassignDialog, setOpenUnassignDialog] = 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 {people} = useTracker(() => {
let people = [];
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'}}
}
const students = Students.find(query).fetch();
const staff = Staff.find(query).fetch();
for(let next of students) next.type = "Student"
for(let next of staff) next.type = "Staff"
people = [...staff, ...students]
}
return {people}
});
const {assets} = useTracker(() => {
let assets = [];
if(selectedPerson) {
assets = Assets.find({assigneeId: selectedPerson._id}).fetch();
for(let next of assets) {
next.assetType = AssetTypes.findOne({_id: next.assetTypeId})
}
}
return {assets}
});
const {foundAsset} = useTracker(() => {
let foundAsset = null;
if(assetId) {
foundAsset = Assets.findOne({assetId: assetId});
if(foundAsset) {
foundAsset.assetType = AssetTypes.findOne({_id: foundAsset.assetTypeId})
if(foundAsset.assigneeId)
foundAsset.assignee = foundAsset.assigneeType === "Student" ? Students.findOne({_id: foundAsset.assigneeId}) : Staff.findOne({_id: foundAsset.assigneeId})
}
}
return {foundAsset}
});
const getListItemStyle = (item) => {
return {
backgroundColor: selectedPerson === item ? '#EECFA6' : 'white'
}
}
const assign = () => {
if(foundAsset) {
//Open the dialog to get condition.
setUnassignCondition(foundAsset.condition ? foundAsset.condition : conditions[2])
setUnassignConditionDetails(foundAsset.conditionDetails || "")
setOpenAssignDialog(true)
}
}
const assignDialogClosed = (assign) => {
setOpenAssignDialog(false)
if(assign === true) {
// Call assets.assign
Meteor.call('assets.assign', foundAsset.assetId, selectedPerson.type, selectedPerson._id, assignCondition, assignConditionDetails)
setAssetId("")
// Set the focus back to the asset id text field
// document.getElementById('assetIdInput').focus()
// useEffect(() => {
// if(assetIdInput) assetIdInput.focus()
// })
}
}
useEffect(() => {
if(assetIdInput) assetIdInput.focus()
})
const unassign = (asset) => {
// Open the dialog to get condition and comment.
setUnassignAsset(asset);
setUnassignComment("")
setUnassignCondition(asset.condition ? asset.condition : conditions[2])
setUnassignConditionDetails(asset.conditionDetails || "")
setOpenUnassignDialog(true);
}
const unassignDialogClosed = (unassign) => {
setOpenUnassignDialog(false)
if(unassign === true) {
// Call assets.unassign(assetId, comment, condition, conditionDetails, date)
Meteor.call('assets.unassign', unassignAsset.assetId, unassignComment, unassignCondition, unassignConditionDetails)
}
}
const getAssetTileStyles = (index) => {
return index % 2 ? {backgroundColor: '#FFF'} : {backgroundColor: '#d2d2d2'}
}
const cssAssetTile = {
padding: '.8rem',
userSelect: 'none',
// '&:nthChild(even)': {backgroundColor: '#935e5e'}
}
return (
<>
<Dialog open={openAssignDialog} onClose={assignDialogClosed}>
<DialogTitle>Assign Asset</DialogTitle>
<DialogContent style={{display: 'flex', flexDirection: 'column'}}>
<div>
<TextField style={cssEditorField} select variant="standard" label="Condition" value={assignCondition} onChange={(e)=>{setAssignCondition(e.target.value)}}>
{conditions.map((condition, i) => {
return <MenuItem key={i} value={condition}>{condition}</MenuItem>
})}
</TextField>
</div>
<TextField style={{marginTop: '1rem',minWidth: '30rem'}} multiline rows={4} variant="outlined" label="Condition Details" value={assignConditionDetails} onChange={(e) => {setAssignConditionDetails(e.target.value)}}/>
</DialogContent>
<DialogActions>
<Button onClick={() => assignDialogClosed(true)}>Assign</Button>
<Button onClick={() => assignDialogClosed(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<Dialog open={openUnassignDialog} onClose={unassignDialogClosed}>
<DialogTitle>Unassign Asset</DialogTitle>
<DialogContent style={{display: 'flex', flexDirection: 'column'}}>
<div>
<TextField style={cssEditorField} select variant="standard" label="Condition" value={unassignCondition} onChange={(e)=>{setUnassignCondition(e.target.value)}}>
{conditions.map((condition, i) => {
return <MenuItem key={i} value={condition}>{condition}</MenuItem>
})}
</TextField>
</div>
<TextField style={{marginTop: '1rem',minWidth: '30rem'}} variant="standard" label="Comment" value={unassignComment} onChange={(e) => {setUnassignComment(e.target.value)}}/>
<TextField style={{marginTop: '1rem',minWidth: '30rem'}} multiline rows={4} variant="outlined" label="Condition Details" value={unassignConditionDetails} onChange={(e) => {setUnassignConditionDetails(e.target.value)}}/>
</DialogContent>
<DialogActions>
<Button onClick={() => unassignDialogClosed(true)}>Unassign</Button>
<Button onClick={() => unassignDialogClosed(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<Box style={{marginTop: '1rem',...cssTwoColumnContainer}}>
<ToggleButtonGroup color="primary" value={searchType} exclusive onChange={(e, type)=>setSearchType(type)} aria-label="Search Type">
<ToggleButton value="Email">Email</ToggleButton>
<ToggleButton value="First Name">First Name</ToggleButton>
<ToggleButton value="Last Name">Last Name</ToggleButton>
</ToggleButtonGroup>
<TextField style={cssEditorField} variant="standard" label="Search" value={search} onChange={(e) => {setSearch(e.target.value)}}/>
</Box>
<Box style={cssTwoColumnContainer}>
<div style={{maxHeight: '26rem', overflowY:'auto', minWidth: '10rem', minHeight: '10rem'}}>
<List>
{people.map((next, i) => {
return (
<ListItemButton key={next._id} style={getListItemStyle(next)} selected={selectedPerson === next} onClick={(e) => {setSelectedPerson(next)}}>
<ListItemText primary={next.firstName + " " + next.lastName} secondary={next.email} tertiary={"Hello World"}/>
</ListItemButton>
)
})}
</List>
</div>
<div style={{display: 'flex', flexDirection: 'column', margin: '1rem 0 0 .5rem'}}>
{selectedPerson && (
<div style={cssAssetTile}>
<div style={{marginBottom: '1rem'}}><TextField id='assetIdInput' inputRef={input=>setAssetIdInput(input)} style={cssEditorField} variant="standard" label="Asset ID" value={assetId} onChange={(e) => {setAssetId(e.target.value)}}/></div>
<div>{foundAsset && foundAsset.assetType.name}</div>
<div>{foundAsset && foundAsset.serial}</div>
{foundAsset && foundAsset.assignee && (
<div>Assigned To: {foundAsset.assignee.firstName} {foundAsset.assignee.lastName}</div>
)}
<Button variant="contained" color='primary' className="button" disabled={!foundAsset || foundAsset.assignee !== undefined} onClick={()=>assign()}>Assign</Button>
</div>
)}
{assets.map((next, i) => {
return (
<div key={next._id} style={{...getAssetTileStyles(i), ...cssAssetTile}}>
<div>{next.assetType.name}</div>
<div>{next.assetId}</div>
<div>{next.serial}</div>
<Button variant="contained" color='secondary' className="button" onClick={()=>unassign(next)}>Unassign</Button>
</div>
)
})}
{/*<div style={{display: 'flex', flexDirection: 'row'}}>*/}
{/* <TextField style={cssEditorField} variant="standard" label="Asset ID" value={assetId} onChange={(e) => {setAssetId(e.target.value)}}/>*/}
{/* <TextField style={cssEditorField} variant="standard" label="Serial" value={serial} onChange={(e) => {setSerial(e.target.value)}}/>*/}
{/*</div>*/}
{/*<div style={{display: 'flex', flexDirection: 'row'}}>*/}
{/* <TextField style={cssEditorField} select variant="standard" label="Condition" value={condition} onChange={(e)=>{setCondition(e.target.value)}}>*/}
{/* {conditions.map((condition, i) => {*/}
{/* return <MenuItem key={i} value={condition}>{condition}</MenuItem>*/}
{/* })}*/}
{/* </TextField>*/}
{/*</div>*/}
{/*<div style={{display: 'flex', flexDirection: 'row'}}>*/}
{/* <TextField style={{width: '100%', margin: '1rem'}} multiline variant="outlined" rows={4} label="Condition Details" value={conditionDetails} onChange={(e) => {setConditionDetails(e.target.value)}}/>*/}
{/*</div>*/}
</div>
</Box>
</>
)
}
export default () => {
Meteor.subscribe('students');
Meteor.subscribe('staff');
Meteor.subscribe('assetTypes');
Meteor.subscribe('assets');
return (
<AssignmentsByPerson/>
)
}

View File

@@ -0,0 +1,30 @@
import { Meteor } from 'meteor/meteor';
import React, {lazy, Suspense, useState} from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import TabNav from '../util/TabNav';
export default () => {
let tabs = [
{
title: "Chromebook Usage",
getElement: () => {
const ChromebookUsage = lazy(()=>import('./History/ChromebookUsage'))
return <ChromebookUsage/>
},
path: '/chromebookUsage',
href: 'chromebookUsage'
},
{
title: "Asset Assignments",
getElement: () => {
const AssetAssignments = lazy(()=>import('./History/AssetAssignments'))
return <AssetAssignments/>
},
path: '/assetAssignments',
href: 'assetAssignments'
},
]
return <TabNav tabs={tabs}/>
}

View File

@@ -0,0 +1,17 @@
import { Meteor } from 'meteor/meteor';
import React, { useState, useEffect } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import { useTheme } from '@mui/material/styles';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import Chip from '@mui/material/Chip';
import MenuItem from '@mui/material/MenuItem';
import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material";
import Box from "@mui/material/Box";
import OutlinedInput from '@mui/material/OutlinedInput';
import FormControl from '@mui/material/FormControl';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';

View File

@@ -0,0 +1,38 @@
import { Meteor } from 'meteor/meteor';
import React, { useState, useEffect } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import { useTheme } from '@mui/material/styles';
import _ from 'lodash';
import SimpleTable from "/imports/ui/util/SimpleTable";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Select from '@mui/material/Select';
import Chip from '@mui/material/Chip';
import MenuItem from '@mui/material/MenuItem';
import {InputLabel, List, ListItem, ListItemButton, ListItemText} from "@mui/material";
import Paper from '@mui/material/Paper';
import InputBase from '@mui/material/InputBase';
import IconButton from '@mui/material/IconButton';
import SearchIcon from '@mui/icons-material/Search';
import Box from "@mui/material/Box";
import OutlinedInput from '@mui/material/OutlinedInput';
import FormControl from '@mui/material/FormControl';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
export default () => {
return (
<div style={{display: "flex", flexDirection: "column"}} sx={{ p: '2px 4px', display: 'flex', alignItems: 'center', width: 400 }}>
<Paper componet='form'>
<InputBase
sx={{ ml: 1, flex: 1 }}
placeholder="Email"
inputProps={{ 'aria-label': 'Search Email' }}
/>
<IconButton type="button" sx={{ p: '10px' }} aria-label="search">
<SearchIcon />
</IconButton>
</Paper>
</div>
)
}

122
imports/ui/pages/Users.jsx Normal file
View File

@@ -0,0 +1,122 @@
import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import Button from '@mui/material/Button';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import classNames from 'classnames';
Meteor.subscribe('allUsers');
Meteor.subscribe('allRoleAssignments');
let UsersTable = ({rows}) => {
const [selected, setSelected] = useState(undefined);
let selectRow = (e, row) => {
setSelected(row);
}
const [edited, setEdited] = useState(undefined);
const [permissions, setPermissions] = useState(undefined);
let editRow = (e, row) => {
if(row) {
setPermissions({
isAdmin: Roles.userIsInRole(row, "admin", {anyScope: true}),
laptopManagement: Roles.userIsInRole(row, "laptop-management", {anyScope: true}),
})
} else setPermissions(undefined)
setEdited(row);
}
let togglePermission = (permission) => {
permissions[permission] = !permissions[permission]
setPermissions({...permissions})
}
let applyChanges = (e) => {
let roles = [];
if(permissions.isAdmin) {
roles.push('admin');
}
else {
if(permissions.laptopManagement) {
roles.push('laptop-management');
}
}
Meteor.call("users.setUserRoles", edited._id, roles);
setEdited(undefined);
}
let rejectChanges = (e) => {
setEdited(undefined)
}
return (
<TableContainer className="userTable" component={Paper}>
<Table size="small" aria-label="User Table">
<TableHead className="sticky">
<TableRow>
<TableCell className="headerCell">Name</TableCell>
<TableCell className="headerCell">Email</TableCell>
<TableCell className="headerCell">Roles</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row)=>(
<TableRow key={row._id} className={classNames({tableRow: true, selected: (!edited || edited._id !== row._id) && selected && selected._id === row._id})} onDoubleClick={(e) => {editRow(e, row)}} onClick={(e) => selectRow(e, row)}>
{edited && edited._id === row._id ?
<TableCell className="userEditorContainer" colSpan="3">
<div className="userEditorGrid">
<label style={{gridColumn: "1/4", fontWeight: "800", borderBottom: "2px solid #888", marginBottom: "0.5rem"}}>{edited.profile.name}</label>
<FormControlLabel style={{gridColumn: "1/4"}} control={<Checkbox checked={permissions && permissions.isAdmin} onChange={() => togglePermission('isAdmin')}/>} label="Administrator"/>
<div className="insetPermissions" style={{gridColumn: "1/4"}}>
<FormControlLabel control={<Checkbox disabled={permissions && permissions.isAdmin} checked={permissions && permissions.laptopManagement} onChange={() => togglePermission('laptopManagement')}/>} label="Laptop Management"/>
</div>
<Button variant="contained" style={{gridColumn: '2/2'}} className="button accept-button" onClick={applyChanges}>Accept</Button>
<Button type="outlined" style={{gridColumn: '3/3'}} className="button reject-button" onClick={rejectChanges}>Reject</Button>
</div>
</TableCell>
: <>
<TableCell align="left">{row.profile.name}</TableCell>
<TableCell align="left">{row.services && row.services.google ? row.services.google.email : ""}</TableCell>
<TableCell align="left">{row.roles}</TableCell>
</>
}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
/*
* Separate the Meteor calls as much as possible to avoid them being run repeatedly unnecessarily (were being run on every selection in the table).
*/
export default () => {
const {rows} = useTracker(() => {
const rows = Meteor.users.find({}).fetch();
for(let row of rows) {
row.roles = Roles.getRolesForUser(row, {anyScope: true})
}
return {
rows
}
});
return <UsersTable rows={rows}/>
}

2
imports/ui/util/JsBarcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import classNames from 'classnames';
import Button from "@mui/material/Button";
import _ from 'lodash';
// let columns = [
// {
// name: "ID",
// value: (row) => row._id
// }
// ]
//
// let rows = [
// {
// _id: 1234,
// name: "abc",
// value: 123
// }
// ]
//
// let options = {
// key: (row) => row._id,
// editor: (row) => {return (<MyRowEditor value={row}/>)}
// }
const cssTopControls = {
display: 'flex',
flexDirection: 'row-reverse',
}
/*
* Separate the Meteor calls as much as possible to avoid them being run repeatedly unnecessarily (were being run on every selection in the table).
*/
export default ({columns, rows, options}) => {
const [selected, setSelected] = useState(undefined);
let selectRow = (e, row) => {
setSelected(row);
}
const [edited, setEdited] = useState(undefined);
let editRow = (e, row) => {
setEdited(row);
}
const closeEditor = () => {
setEdited(undefined)
}
const addRow = () => {
setEdited({});
}
let containerStyle = options.maxHeight ? {maxHeight: options.maxHeight} : {}
let keyHandler = (e) => {
!edited && options.keyHandler && options.keyHandler(e, selected)
// Close the editor if the user hits escape.
if(edited && e.key === 'Escape') {
setEdited(undefined)
e.stopPropagation()
}
if(!edited && e.key === 'Insert') {
setEdited({})
e.stopPropagation()
}
}
return (
<div className='simpleTableContainer'>
{options.add && <div style={cssTopControls}><Button variant="text" className="button" onClick={addRow}>Add</Button></div>}
<TableContainer className="simpleTable" component={Paper} style={containerStyle}>
<Table size="small" aria-label="Table" tabIndex="0" onKeyDown={keyHandler}>
<TableHead className="sticky">
<TableRow>
{columns.map((column, i) => {return (
<TableCell key={i} className="headerCell">{column.name}</TableCell>
)})}
{/*<TableCell className="headerCell">Name</TableCell>*/}
{/*<TableCell className="headerCell">Email</TableCell>*/}
{/*<TableCell className="headerCell">Roles</TableCell>*/}
</TableRow>
</TableHead>
<TableBody>
{edited && !options.key(edited) && (
<TableRow key="NewEditor" className="tableRow">
<TableCell className="editorContainer" colSpan={columns.length}>
{options.editor(edited, closeEditor)}
</TableCell>
</TableRow>
)}
{rows.map((row, i)=>{
// console.log("Rendering Row " + i)
// console.log(row);
return (
<TableRow key={options.key(row)} className={classNames({tableRow: true, selected: (!edited || options.key(edited) !== options.key(row)) && selected && options.key(selected) === options.key(row)})} onDoubleClick={(e) => {editRow(e, row)}} onClick={(e) => selectRow(e, row)}>
{edited && options.key(edited) === options.key(row) ?
<TableCell className="editorContainer" colSpan={columns.length}>
{options.editor(edited, closeEditor)}
</TableCell>
:
<>
{columns.map((column, ci) => {
// console.log("Rendering Cell")
// console.log(column);
// console.log(column.value(row));
let value = column.value(row);
if(_.isObject(value)) {
console.error("Cannot have an object returned as the value in a table.")
value = JSON.stringify(value)
}
return (
<TableCell key={ci} align="left">{value}</TableCell>
)
})}
</>
}
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { Meteor } from 'meteor/meteor';
import React, { useState, useEffect, lazy, Suspense } from 'react';
import { useTracker } from 'meteor/react-meteor-data';
import _ from 'lodash';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
import {Route, Routes, useNavigate} from "react-router-dom";
//Example Tabs:
// let tabs = [
// {
// title: "Asset List",
// getElement: () => {
// const AssetList = lazy(()=>import('./Assets/AssetList'))
// return <AssetList/>
// },
// path: '/assetList',
// href: 'assetList'
// },
// {
// title: "Add Assets",
// getElement: () => {
// const AddAssets = lazy(()=>import('./Assets/AddAssets'))
// return <AddAssets/>
// },
// path: '/addAssets',
// href: 'addAssets'
// },
// ]
const LinkTab = (props) => {
let nav = useNavigate()
return <Tab component='a' onClick={(e) => {
e.preventDefault()
nav(props.href)
}} {...props}/>
}
export default ({tabs}) => {
let pathName = location.pathname;
let initialTab = tabs.findIndex(tab => {return pathName.endsWith(tab.path)})
let defaultTabPath = tabs[0].path.slice(0, tabs[0].path.lastIndexOf('/'))
if(initialTab === -1) {
initialTab = 0
}
const [value, setValue] = useState(initialTab)
const valueChanged = (e, newValue) => {
setValue(newValue)
}
// console.log(defaultTabPath)
return (
<>
<Box sx={{width: '100%'}}>
<Tabs value={value} onChange={valueChanged} aria-label='nav tabs'>
{tabs.map((tab, i) => {return (
<LinkTab key={i} label={tab.title} href={tab.href}/>
)})}
</Tabs>
</Box>
<Suspense fallback={<div/>}>
<Routes>
<Route path={defaultTabPath} element={tabs[0].getElement()}/>
{tabs.map((tab, i) => {return (
<Route key={i} path={tab.path} element={tab.getElement()}/>
)})}
</Routes>
</Suspense>
</>
)
}

2363
imports/ui/util/qrious.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

6
imports/ui/util/qrious.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "simple-todos-react",
"private": true,
"scripts": {
"start": "meteor run",
"build": "npm install --omit=dev && meteor build --architecture os.linux.x86_64 --server-only ../",
"test": "meteor test --once --driver-package meteortesting:mocha",
"test-app": "TEST_WATCH=1 meteor test --full-app --driver-package meteortesting:mocha",
"visualize": "meteor --production --extra-packages bundle-visualizer"
},
"dependencies": {
"@babel/runtime": "^7.16.7",
"@emotion/react": "^11.10.0",
"@emotion/styled": "^11.10.0",
"@mui/icons-material": "^5.10.2",
"@mui/material": "^5.10.2",
"bcrypt": "^5.0.1",
"classnames": "^2.2.6",
"csv-parse": "^5.3.0",
"dayjs": "^1.11.3",
"html5-qrcode": "^2.2.0",
"jquery": "^3.6.0",
"lodash": "^4.17.15",
"meteor-node-stubs": "^1.0.0",
"moment": "^2.29.2",
"mongodb": "^4.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
"umbrellajs": "^3.3.1",
"underscore": "^1.13.2",
"winston": "^3.7.2",
"winston-daily-rotate-file": "^4.6.1",
"ws": "^8.4.2"
},
"meteor": {
"mainModule": {
"client": "client/main.jsx",
"server": "server/main.js"
},
"testModule": "tests/main.js"
},
"devDependencies": {
"chai": "^4.2.0"
}
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Some files were not shown because too many files have changed in this diff Show More