Initial check in; All but the history pages working.
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
private/settings.json
|
||||
package-lock.json
|
||||
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
13
.idea/Tempest.iml
generated
Normal file
13
.idea/Tempest.iml
generated
Normal 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
6
.idea/jsLibraryMappings.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
25
.idea/watcherTasks.xml
generated
Normal 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>
|
||||
19
.meteor/.finished-upgraders
Normal file
19
.meteor/.finished-upgraders
Normal 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
1
.meteor/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
local
|
||||
7
.meteor/.id
Normal file
7
.meteor/.id
Normal 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
29
.meteor/packages
Normal 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
2
.meteor/platforms
Normal file
@@ -0,0 +1,2 @@
|
||||
server
|
||||
browser
|
||||
1
.meteor/release
Normal file
1
.meteor/release
Normal file
@@ -0,0 +1 @@
|
||||
METEOR@2.7.3
|
||||
99
.meteor/versions
Normal file
99
.meteor/versions
Normal 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
8
README.md
Normal 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
133
client/_app.sass
Normal 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
|
||||
32
client/_material-icons.sass
Normal file
32
client/_material-icons.sass
Normal 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
106
client/_page.sass
Normal 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
7
client/_roboto.sass
Normal 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
186
client/_simple-grid.sass
Normal 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
540
client/main.css
Normal 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
1
client/main.css.map
Normal 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
10
client/main.html
Normal 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
10
client/main.jsx
Normal 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
16
client/main.sass
Normal 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
11
client/pages/_Users.sass
Normal 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
60
imports/api/admin.js
Normal 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.")
|
||||
20
imports/api/asset-assignment-history.js
Normal file
20
imports/api/asset-assignment-history.js
Normal 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.
|
||||
*/
|
||||
79
imports/api/asset-types.js
Normal file
79
imports/api/asset-types.js
Normal 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
302
imports/api/assets.js
Normal 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.")
|
||||
149
imports/api/data-collection.js
Normal file
149
imports/api/data-collection.js
Normal 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.")
|
||||
93
imports/api/example-schema.js
Normal file
93
imports/api/example-schema.js
Normal 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
11
imports/api/index.js
Normal 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
40
imports/api/records.js
Normal 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
38
imports/api/sites.js
Normal 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
161
imports/api/staff.js
Normal 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
150
imports/api/students.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
74
imports/startup/accounts-config.js
Normal file
74
imports/startup/accounts-config.js
Normal 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
55
imports/ui/App.jsx
Normal 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
77
imports/ui/Page.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
imports/ui/pages/Admin.jsx
Normal file
58
imports/ui/pages/Admin.jsx
Normal 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}/>
|
||||
}
|
||||
113
imports/ui/pages/Admin/AssetTypes.jsx
Normal file
113
imports/ui/pages/Admin/AssetTypes.jsx
Normal 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}/>
|
||||
)
|
||||
}
|
||||
6
imports/ui/pages/Admin/Functions.jsx
Normal file
6
imports/ui/pages/Admin/Functions.jsx
Normal 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>)}
|
||||
90
imports/ui/pages/Admin/Sites.jsx
Normal file
90
imports/ui/pages/Admin/Sites.jsx
Normal 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}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
153
imports/ui/pages/Admin/Staff.jsx
Normal file
153
imports/ui/pages/Admin/Staff.jsx
Normal 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}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
158
imports/ui/pages/Admin/Students.jsx
Normal file
158
imports/ui/pages/Admin/Students.jsx
Normal 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}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
imports/ui/pages/Assets.jsx
Normal file
30
imports/ui/pages/Assets.jsx
Normal 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}/>
|
||||
}
|
||||
134
imports/ui/pages/Assets/AddAssets.jsx
Normal file
134
imports/ui/pages/Assets/AddAssets.jsx
Normal 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}/>
|
||||
)
|
||||
}
|
||||
135
imports/ui/pages/Assets/AssetList.jsx
Normal file
135
imports/ui/pages/Assets/AssetList.jsx
Normal 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}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
imports/ui/pages/Assignments.jsx
Normal file
30
imports/ui/pages/Assignments.jsx
Normal 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}/>
|
||||
}
|
||||
139
imports/ui/pages/Assignments/ByAsset.jsx
Normal file
139
imports/ui/pages/Assignments/ByAsset.jsx
Normal 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/>
|
||||
)
|
||||
}
|
||||
282
imports/ui/pages/Assignments/ByPerson.jsx
Normal file
282
imports/ui/pages/Assignments/ByPerson.jsx
Normal 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/>
|
||||
)
|
||||
}
|
||||
30
imports/ui/pages/History.jsx
Normal file
30
imports/ui/pages/History.jsx
Normal 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}/>
|
||||
}
|
||||
17
imports/ui/pages/History/AssetAssignments.jsx
Normal file
17
imports/ui/pages/History/AssetAssignments.jsx
Normal 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';
|
||||
38
imports/ui/pages/History/ChromebookUsage.jsx
Normal file
38
imports/ui/pages/History/ChromebookUsage.jsx
Normal 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
122
imports/ui/pages/Users.jsx
Normal 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
2
imports/ui/util/JsBarcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
137
imports/ui/util/SimpleTable.jsx
Normal file
137
imports/ui/util/SimpleTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
imports/ui/util/TabNav.jsx
Normal file
74
imports/ui/util/TabNav.jsx
Normal 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
2363
imports/ui/util/qrious.js
Normal file
File diff suppressed because it is too large
Load Diff
1
imports/ui/util/qrious.js.map
Normal file
1
imports/ui/util/qrious.js.map
Normal file
File diff suppressed because one or more lines are too long
6
imports/ui/util/qrious.min.js
vendored
Normal file
6
imports/ui/util/qrious.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
imports/ui/util/qrious.min.js.map
Normal file
1
imports/ui/util/qrious.min.js.map
Normal file
File diff suppressed because one or more lines are too long
46
package.json
Normal file
46
package.json
Normal 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
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/fonts/Courgette-Regular.ttf
Normal file
BIN
public/fonts/Courgette-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/KaushanScript-Regular.ttf
Normal file
BIN
public/fonts/KaushanScript-Regular.ttf
Normal file
Binary file not shown.
2218
public/fonts/MaterialIcons-Regular.codepoints
Normal file
2218
public/fonts/MaterialIcons-Regular.codepoints
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/fonts/MaterialIcons-Regular.ttf
Normal file
BIN
public/fonts/MaterialIcons-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Merienda-Bold.ttf
Normal file
BIN
public/fonts/Merienda-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Merienda-Regular.ttf
Normal file
BIN
public/fonts/Merienda-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Monoton-Regular.ttf
Normal file
BIN
public/fonts/Monoton-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-Black.ttf
Normal file
BIN
public/fonts/Roboto-Black.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-BlackItalic.ttf
Normal file
BIN
public/fonts/Roboto-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-Bold.ttf
Normal file
BIN
public/fonts/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-BoldItalic.ttf
Normal file
BIN
public/fonts/Roboto-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-Italic.ttf
Normal file
BIN
public/fonts/Roboto-Italic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-Light.ttf
Normal file
BIN
public/fonts/Roboto-Light.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-LightItalic.ttf
Normal file
BIN
public/fonts/Roboto-LightItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-Medium.ttf
Normal file
BIN
public/fonts/Roboto-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-MediumItalic.ttf
Normal file
BIN
public/fonts/Roboto-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-Regular.ttf
Normal file
BIN
public/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-Thin.ttf
Normal file
BIN
public/fonts/Roboto-Thin.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Roboto-ThinItalic.ttf
Normal file
BIN
public/fonts/Roboto-ThinItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-Bold.ttf
Normal file
BIN
public/fonts/Spectral-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-BoldItalic.ttf
Normal file
BIN
public/fonts/Spectral-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-ExtraBold.ttf
Normal file
BIN
public/fonts/Spectral-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-ExtraBoldItalic.ttf
Normal file
BIN
public/fonts/Spectral-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-ExtraLight.ttf
Normal file
BIN
public/fonts/Spectral-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-ExtraLightItalic.ttf
Normal file
BIN
public/fonts/Spectral-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-Italic.ttf
Normal file
BIN
public/fonts/Spectral-Italic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-Light.ttf
Normal file
BIN
public/fonts/Spectral-Light.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-LightItalic.ttf
Normal file
BIN
public/fonts/Spectral-LightItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-Medium.ttf
Normal file
BIN
public/fonts/Spectral-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-MediumItalic.ttf
Normal file
BIN
public/fonts/Spectral-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-Regular.ttf
Normal file
BIN
public/fonts/Spectral-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-SemiBold.ttf
Normal file
BIN
public/fonts/Spectral-SemiBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Spectral-SemiBoldItalic.ttf
Normal file
BIN
public/fonts/Spectral-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/images/forbidden/HauntedHouseForeground.png
Normal file
BIN
public/images/forbidden/HauntedHouseForeground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
public/images/forbidden/bat-body.png
Normal file
BIN
public/images/forbidden/bat-body.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/images/forbidden/bat-wing.png
Normal file
BIN
public/images/forbidden/bat-wing.png
Normal file
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
Reference in New Issue
Block a user