diff --git a/.babelrc b/.babelrc index 7b46c78d..6fe3ea6d 100644 --- a/.babelrc +++ b/.babelrc @@ -1,25 +1,20 @@ { - "presets": [ - [ - "env", - { - "modules": false, - "targets": { - "browsers": "> 1%", - "uglify": true - }, - "useBuiltIns": true - } + "presets": [ + [ + "@babel/env", + { + "modules": false, + "targets": { + "browsers": "> 1%" + } + } + ], + "@babel/react" ], - "react" - ], - "plugins": [ - "syntax-dynamic-import", - [ - "transform-class-properties", - { - "spec": true - } + "plugins": [ + "@babel/plugin-transform-runtime", + "@babel/plugin-syntax-dynamic-import", + "@babel/plugin-proposal-class-properties", + "@babel/plugin-proposal-object-rest-spread" ] - ] } diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..f1d7f908 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +tabWidth: 4 diff --git a/Dockerfile b/Dockerfile index 43314706..4ed01c05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM ruby:2.4.1-alpine +FROM madnight/docker-alpine-wkhtmltopdf as wkhtmltopdf_image +FROM ruby:2.6.2-alpine3.8 ARG RAILS_ENV ENV RAILS_ENV=${RAILS_ENV:-development} @@ -10,6 +11,15 @@ RUN apk --update --upgrade add curl-dev libcurl build-base openssh \ tzdata libxml2 libxml2-dev libxslt libxslt-dev postgresql-dev \ nodejs +# For wkhtmltopdf +RUN apk add --update --no-cache \ + libgcc libstdc++ libx11 glib libxrender libxext libintl \ + libcrypto1.0 libssl1.0 \ + ttf-dejavu ttf-droid ttf-freefont ttf-liberation ttf-ubuntu-font-family + +COPY --from=wkhtmltopdf_image /bin/wkhtmltopdf /bin/ + + # Add Yarn to the mix # hideous hack by matz. methinks yarn lastest tarball changed. # to something dumb that will change each version @@ -42,7 +52,7 @@ RUN if [ ${RAILS_ENV} = 'production' ]; then \ bundle exec rake webpacker:compile; \ fi -EXPOSE 3000 +EXPOSE 3022 #TODO apparently cannot use variable in CMD instruction, but i hate the 5000 here! -CMD ["rails", "server", "-b", "0.0.0.0", "-p", "3000"] +CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0", "-p", "3022"] diff --git a/Gemfile b/Gemfile index 3834c55c..8a82437c 100644 --- a/Gemfile +++ b/Gemfile @@ -56,3 +56,6 @@ end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] +gem 'wicked_pdf' +gem 'liquid' +gem 'combine_pdf' diff --git a/Gemfile.lock b/Gemfile.lock index 603daf59..5f0c3a8a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -58,6 +58,8 @@ GEM coffee-script-source execjs coffee-script-source (1.12.2) + combine_pdf (1.0.16) + ruby-rc4 (>= 0.1.5) concurrent-ruby (1.0.5) crass (1.0.4) diff-lcs (1.3) @@ -87,6 +89,7 @@ GEM jbuilder (2.7.0) activesupport (>= 4.2.0) multi_json (>= 1.2) + liquid (4.0.3) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) @@ -147,7 +150,7 @@ GEM method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - rake (12.3.1) + rake (12.3.2) rb-fsevent (0.10.3) rb-inotify (0.9.10) ffi (>= 0.5.0, < 2) @@ -172,6 +175,7 @@ GEM rspec-mocks (~> 3.7.0) rspec-support (~> 3.7.0) rspec-support (3.7.1) + ruby-rc4 (0.1.5) ruby_dep (1.5.0) rubyzip (1.2.1) sass (3.5.6) @@ -218,6 +222,8 @@ GEM websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) + wicked_pdf (1.2.1) + activesupport PLATFORMS ruby @@ -227,9 +233,11 @@ DEPENDENCIES axlsx_rails byebug coffee-rails (~> 4.2) + combine_pdf guard guard-rspec jbuilder (~> 2.5) + liquid listen (>= 3.0.5, < 3.2) pg (~> 0.20) prawn (~> 2.2) @@ -245,6 +253,7 @@ DEPENDENCIES uglifier (>= 1.3.0) web-console (>= 3.3.0) webpacker + wicked_pdf BUNDLED WITH - 1.15.4 + 1.17.2 diff --git a/README.md b/README.md index a8403c76..cb47a1b9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ directory, and run docker-compose up ``` -In a new tab, open http://localhost:3000 to see the Rails welcome page! +In a new tab, open http://localhost:3022 to see the Rails welcome page! `docker-compose up` has launched two containers: `rails-app` and `webpack-dev-server`. The former runs the Rails app, while the latter @@ -42,7 +42,7 @@ You have full control over Rails code, apply the usual methods. Check the next section for details on running commands like `rake …` and `rails …`. To get you started with React quicker, this app comes preloaded with a simple -React app. Visiting http://localhost:3000/hello_react will load JavaScript code +React app. Visiting http://localhost:3022/hello_react will load JavaScript code located in `app/javascript/packs/hello_react.jsx`. ## Running commands @@ -91,10 +91,12 @@ To add a system dependency, modify the Dockerfile. ## In case of container trouble -If you are okay with losing all the data in the database, you can try `docker-compose down -v`, then `docker-compose up`. This should +Try `docker-compose down -v` then `docker-compose up`. This should delete existing data for this project. -`down -v` deletes all the volumes declared of the compose file. At time of writing, this blows away the files containing the postgress database in the postgress service, but has no effect on the rails service. The fact that it deletes ALL the volumes makes this a dangerous command, potentially disasterous in production. +`down -v` deletes all the volumes declared of the compose file. The fact that it deletes ALL the volumes makes this a dangerous command, potentially disastrous in production. + +Database information is stored in a named docker `volume` so it stays persistent. To see all docker volumes, run `docker volume ls`. You can run `docker volume rm ` to remove a volume. The volume storing the database data will be automatically recreated next time `docker-compose up` is run. To recreate the images the containers boot from, give `docker-compose up` the `--force-recreate` command line option like so: @@ -113,7 +115,7 @@ To absolutely nuke all the docker images and networks: ### daemon.json -Some of the security offered by docker containers is that docker sets up a private "bridge" network that the containers use to communicate. For instance, in the docker-compose.yml file a `link` stanza allows rails to connect to postgress over this private network. An intruder that penetrates the host cannot see the postgress server even though the rails container can! +Some of the security offered by docker containers is that docker sets up a private "bridge" network that the containers use to communicate. For instance, in the docker-compose.yml file a `link` stanza allows rails to connect to postgres over this private network. An intruder that penetrates the host cannot see the postgress server even though the rails container can! The bad news is that to do this Docker has to guess some parameters of this private network, for instance what IP addresses to use. These are set in a file called `daemon.json` @@ -133,7 +135,34 @@ NB. subnet for docker networks that are created at docker-compose up time are co ### apache (reverse proxy) -Lloyd to type here. +In a typical setup, the production server runs on port `3022`. An apache reverse proxy proxies the appropriate +url (on the standard http or https ports) to the production server. Instructions can be found [here](https://www.digitalocean.com/community/tutorials/how-to-use-apache-http-server-as-reverse-proxy-using-mod_proxy-extension#configuring-apache-to-proxy-connections) +The gist of it is: + +1. Make sure the relevant proxy modules (`proxy proxy_ajp proxy_http rewrite deflate headers proxy_balancer proxy_connect proxy_html`) +are installed and enabled for apache. On a debian-based system you can run `a2enmod ` to enable them. +2. Modify the virtual host configuration file (e.g., `/etc/apache2/sites-enabled/000-default.conf`) to include: + +``` + + ProxyPreserveHost On + + # Servers to proxy the connection, or; + # List of application servers: + # Usage: + # ProxyPass / http://[IP Addr.]:[port]/ + # ProxyPassReverse / http://[IP Addr.]:[port]/ + # Example: + ProxyPass / http://0.0.0.0:3022/ + ProxyPassReverse / http://0.0.0.0:3022/ + + ServerName localhost + +``` + +For SSL, the process is similar but a `SSLEngine On` and `SSLCertificateFile /path/to/cert.pem` needs to be added to the configuration. + +3. Restart Apache. ### Initial deployment @@ -149,6 +178,16 @@ If you don't specify the environment variable that the docker-compose file shoul up with an error from postgres ("role "tapp" does not exist"). In that case stop/remove the containers and its volumes, `docker-compose down -v`, and restart deployment from step 2. +If you are switching between development and production environments, you might get an error about a missing database table. Run `docker-compose run rails-app rake db:create` to create any missing database. + +### Setting up contract templates + +Templates for TA contracts and TA/Office short-form contracts are located in `app/views/contracts/default`. To create +cusomized contracts for your department, copy the `default` folder to `` folder and change the `offer-template.html` +and `offer-template-office.html` templates. They are regular HTML files that get rendered using the [Liquid](https://github.com/chamnap/liquid-rails) +templating engine. After you've made changes, set the `CONTRACT_SUBDIR` in `.env` to `` so that contracts will +be loaded from the correct folder. + ### Recipe for updating a functioning deployment Update the app after a hotfix or other improvement: @@ -311,6 +350,7 @@ While the application is running, ``` docker exec -t tappcp_postgres_1 pg_dumpall -U postgres > filename ``` + Here, `postgres` should be replaced with whatever you have set as your `POSTGRES_USER` 2. Stop & remove all running containers and erase their volumes: ``` docker-compose down -v @@ -333,7 +373,7 @@ While the application is running, #### peeking at backups -Hourly postgress sql dumps are stored in a safe place off the production machine, but remain in: +Hourly postgres sql dumps are stored in a safe place off the production machine, but remain in: `tapp.cs.toronto.edu:/var/data/tapp` diff --git a/app/assets/stylesheets/cp.css b/app/assets/stylesheets/cp.css index 9aa7fb6b..9571e86d 100644 --- a/app/assets/stylesheets/cp.css +++ b/app/assets/stylesheets/cp.css @@ -119,7 +119,6 @@ footer div p { #offers-grid .table-container td, #offers-grid .table-container th { - word-break: break-all; } #offer-note-popover .popover-content { @@ -504,7 +503,6 @@ footer div p { #ddahs-grid .table-container td, #ddahs-grid .table-container th { - word-break: break-all; } #ddahs-grid #table-total { @@ -523,3 +521,30 @@ footer div p { text-decoration: none; color: #555; } + +.show-on-hover-wrapper { + min-height: 2.5ex; +} + +.show-on-hover-wrapper:hover .show-on-hover { + opacity: 1; +} + +.show-on-hover { + transition: all .3s; + opacity: 0; +} + +.edit-glyph { + cursor: pointer; +} + +.field-dialog-formatted-name { + white-space: pre; + font-family: monospace; + color: blue; +} +.field-dialog-formatted-name::before, .field-dialog-formatted-name::after { + content: "'"; + color: initial; +} diff --git a/app/assets/stylesheets/cq.css b/app/assets/stylesheets/cq.css new file mode 100644 index 00000000..a29113e4 --- /dev/null +++ b/app/assets/stylesheets/cq.css @@ -0,0 +1,45 @@ +/* global vars */ +:root { + --footer-height: 4vh; /* includes margins */ + --navbar-height: 71px; /* includes margins */ + --table-menu-height: calc(34px + 1vh); + --main-div-height: calc(100vh - var(--navbar-height) - var(--footer-height)); + --min-row-height: 52px; /* assumes that header is two rows high */ + --sessions-height: calc(55px + 1.5vh); +} + +html, body, #root { + width: 100%; + height: 100%; + margin: 0px; + padding: 0px; + position: relative; + box-sizing: border-box; +} + +.show-on-hover-wrapper { + min-height: 2.5ex; +} + +.show-on-hover-wrapper:hover .show-on-hover { + opacity: 1; +} + +.show-on-hover { + transition: all .3s; + opacity: 0; +} + +.edit-glyph { + cursor: pointer; +} + +.field-dialog-formatted-name { + white-space: pre; + font-family: monospace; + color: blue; +} +.field-dialog-formatted-name::before, .field-dialog-formatted-name::after { + content: "'"; + color: initial; +} diff --git a/app/controllers/applicants_controller.rb b/app/controllers/applicants_controller.rb index 3c884c9d..cc3a4983 100644 --- a/app/controllers/applicants_controller.rb +++ b/app/controllers/applicants_controller.rb @@ -1,4 +1,5 @@ class ApplicantsController < ApplicationController + include Importer protect_from_forgery with: :null_session include Authorizer before_action :tapp_admin @@ -36,10 +37,61 @@ def update render json: applicant.errors.to_hash(true), status: :unprocessable_entity end end + + def add_or_update + exceptions = [] + applicants = params[:applicants] + begin + applicants.each do |applicant_entry| + utorid = applicant_entry["utorid"] + ident = {utorid: utorid} + exists = "applicant #{utorid} already exists" + data = { + utorid: utorid, + app_id: applicant_entry["app_id"].to_i, + student_number: applicant_entry["student_no"], + first_name:applicant_entry["first_name"], + last_name: applicant_entry["last_name"], + email:applicant_entry["email"], + phone: applicant_entry["phone"], + dept: applicant_entry["dept"], + program_id: applicant_entry["program_id"], + yip: applicant_entry["yip"], + address:applicant_entry["address"], + commentary: "", + full_time: applicant_entry["full_time"], + } + insertion_helper(Applicant, data, ident, exists) + end + rescue + exceptions.push("Error: error encountered while adding applicants") + end + + if exceptions.length > 0 + status = {success: true, errors: true, message: exceptions} + else + status = {success: true, errors: false, message: ["Applicants import was successful."]} + end + + if status[:success] + render json: {errors: status[:errors], message: status[:message]} + else + render status: 404, json: {message: status[:message], errors: status[:errors]} + end + end private def applicant_params - params.permit(:commentary) + params.permit(:commentary, + :utorid, + :student_number, + :first_name, + :last_name, + :email, + :phone, + :address, + :dept, + :program_id) end end diff --git a/app/controllers/authorizer.rb b/app/controllers/authorizer.rb index e5e197a5..e5e82b79 100644 --- a/app/controllers/authorizer.rb +++ b/app/controllers/authorizer.rb @@ -1,5 +1,12 @@ module Authorizer + require 'base64' + SECURITY = { + verify_user: ENV['RAILS_ENV'] == 'production' || (ENV['AUTHENTICATE_IN_DEV_MODE'] || "").downcase == 'true', + allow_basic_auth: (ENV['ALLOW_BASIC_AUTH'] || "").downcase == 'true', + basic_auth_ta_id: ENV['BASIC_AUTH_TA_ID'] + } + def tapp_access expected_roles = ["tapp_admin", "tapp_assistant", "instructor"] access(expected_roles) @@ -38,11 +45,11 @@ def either_admin_instructor(hr_assistant = false) end access(expected_roles) else - if ENV['RAILS_ENV'] == 'production' + if SECURITY[:verify_user] expected_roles = ["instructor"] if has_access(expected_roles) if session[:utorid] == params[:utorid] && !has_access(["cp_admin"]) - render status: 403, file: 'public/403.html' + render status: 403, file: 'public/403.html' end else render status: 403, file: 'public/403.html' @@ -60,7 +67,7 @@ def either_cp_admin_instructor(hr_assistant = false) end access(expected_roles) else - if ENV['RAILS_ENV'] == 'production' + if SECURITY[:verify_user] expected_roles = ["instructor"] if has_access(expected_roles) if session[:utorid] == params[:utorid] && !has_access(["cp_admin"]) @@ -75,7 +82,7 @@ def either_cp_admin_instructor(hr_assistant = false) def both_cp_admin_instructor(model, attr_name = :id, array = false) - if ENV['RAILS_ENV'] == 'production' + if SECURITY[:verify_user] expected_roles = ["cp_admin", "instructor"] if has_access(expected_roles) if !has_access(["cp_admin"]) @@ -93,8 +100,16 @@ def both_cp_admin_instructor(model, attr_name = :id, array = false) the utorid of the applicant the offer was made to. ''' def correct_applicant - if ENV['RAILS_ENV'] == 'production' + if SECURITY[:verify_user] utorid = get_utorid + if session[:roles].nil? + set_roles + end + # When using basic auth, we allow anyone logged in with basic_auth_ta_id + # to access any TA information. + if SECURITY[:allow_basic_auth] and session[:roles].include? "ta" + return + end if utorid != utorid_of_applicant_corresponding_to_student_facing_route(params) render status: 403, file: 'public/403.html' end @@ -103,7 +118,7 @@ def correct_applicant private def logged_in - if ENV['RAILS_ENV'] == 'production' + if SECURITY[:verify_user] set_roles if request.env['PATH_INFO'] != '/reenter-session' && !session[:logged_in] render file: 'public/logout.html' @@ -113,7 +128,7 @@ def logged_in def access(expected_roles) set_roles - if ENV['RAILS_ENV'] == 'production' + if SECURITY[:verify_user] if !has_role(expected_roles) render status: 403, file: 'public/403.html' end @@ -136,17 +151,18 @@ def has_role(expected_roles) return false end - def listed_as(users) + def listed_as(users, utorid) users = users.split(',') - if ENV['RAILS_ENV'] == 'production' - return users.include?(get_utorid) + if SECURITY[:verify_user] + return users.include?(utorid) end end def is_instructor - if ENV['RAILS_ENV'] == 'production' - if get_utorid - instructor = Instructor.find_by(utorid: get_utorid) + if SECURITY[:verify_user] + utorid = get_utorid + if utorid + instructor = Instructor.find_by(utorid: utorid) return instructor else return nil @@ -165,6 +181,24 @@ def utorid_of_applicant_corresponding_to_student_facing_route(params) stuffing in request headers when it forwards. ''' def get_utorid + if SECURITY[:allow_basic_auth] and request.env['HTTP_AUTHORIZATION'] + begin + # Apache Basic auth will pass an the HTTP_AUTHORIZATION flag in + # formatted as `Basic `. When decoded the string + # will be `user:password` separated by a colon. + method, encoded_credential = request.env['HTTP_AUTHORIZATION'].split(" ") + if method.downcase == "basic" + user, password = Base64.decode64(encoded_credential).split(":") + end + rescue + end + # Apache will only pass the HTTP_AUTHORIZATION if we have successfully + # logged in, according to apache. So we will trust this flag + session[:utorid] = user + if session[:logged_in].nil? + session[:logged_in] = true + end + end if request.env['HTTP_X_FORWARDED_USER'] session[:utorid] = request.env['HTTP_X_FORWARDED_USER'] if session[:logged_in].nil? @@ -182,31 +216,36 @@ def get_utorid The data format is CSV. ''' def set_roles + utorid = get_utorid session[:roles] = [] roles = [ { - access: listed_as(ENV['TAPP_ADMINS']), role: "tapp_admin", + access: listed_as(ENV['TAPP_ADMINS'], utorid), }, { - access: listed_as(ENV['CP_ADMINS']), role: "cp_admin", + access: listed_as(ENV['CP_ADMINS'], utorid), }, { - access: listed_as(ENV['TAPP_ASSISTANTS']), role: "tapp_assistant", + access: listed_as(ENV['TAPP_ASSISTANTS'], utorid), }, { - access: listed_as(ENV['HR_ASSISTANTS']), role: "hr_assistant", + access: listed_as(ENV['HR_ASSISTANTS'], utorid), + }, + { + role: "ta", + access: listed_as("#{ENV['TAPP_ADMINS']},#{ENV['CP_ADMINS']},#{SECURITY[:basic_auth_ta_id]}", utorid) }, { - access: is_instructor, role: "instructor", + access: is_instructor, } ] roles.each do |role| - if ENV['RAILS_ENV'] == 'production' + if SECURITY[:verify_user] if role[:access] session[:roles].push(role[:role]) end diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index f1f8b0ba..5b44a6d5 100644 --- a/app/controllers/offers_controller.rb +++ b/app/controllers/offers_controller.rb @@ -1,10 +1,11 @@ class OffersController < ApplicationController + include Importer protect_from_forgery with: :null_session before_action :set_domain include Authorizer - before_action :cp_admin, except: [:index, :show, :get_contract_student, :set_status_student, :can_print, :combine_contracts_print] - before_action :correct_applicant, only: [:get_contract_student, :set_status_student] - before_action only: [:get_contract_pdf, :get_contract, :can_print, :combine_contracts_print] do + before_action :cp_admin, except: [:index, :show, :get_contract_student, :get_contract_student_html, :set_status_student, :can_print, :combine_contracts_print] + before_action :correct_applicant, only: [:get_contract_student, :get_contract_student_html, :set_status_student] + before_action only: [:get_contract_html, :get_contract, :can_print, :combine_contracts_print] do cp_admin(true) end before_action only: [:index, :show] do @@ -71,6 +72,62 @@ def can_clear_hris_status check_offers_status(params[:offers], :hr_status, [nil, "Processed", "Printed"]) end + def update_batch_offers_hours + params["_json"].each do |offer_json| + puts "looping" + puts offer_json + offer = Offer.find(offer_json[:offer_id]) + puts offer + offer.update_attributes!(hours: offer_json[:hours]) + end + end + + def add_or_update + exceptions = [] + offers = params[:offers] + + offers.each do |offer| + position = Position.find_by(position: offer["course_id"]) + applicant = Applicant.find_by(utorid: offer["utorid"]) + if position && applicant + ident = {position_id: position[:id], applicant_id: applicant[:id]} + exists = "offer with position #{offer[:position]} for applicant #{offer[:utorid]} already exists" + data = { + position_id: position[:id], + applicant_id: applicant[:id], + hours: offer["hours"], + session: offer["session"], + year: offer["year"], + } + begin + insertion_helper(Offer, data, ident, exists) + rescue + exceptions.push("Error: Unknown error when creating Position '#{offer["course_id"]}' for '#{offer["utorid"]}' for '#{data[:hours]}' hours.") + end + else + if position + exceptions.push("Error: Applicant #{offer["utorid"]} is invalid.") + else + exceptions.push("Error: either Position #{offer["course_id"]} or Applicant #{offer["utorid"]} is invalid.") + end + end + end + + if exceptions.length == offers.length + status = {success: false, errors: true, message: exceptions} + elsif exceptions.length > 0 + status = {success: true, errors: true, message: exceptions} + else + status = {success: true, errors: false, message: ["Offers import was successful."]} + end + + if status[:success] + render json: {errors: status[:errors], message: status[:message]} + else + render status: 404, json: {message: status[:message], errors: status[:errors]} + end + end + def update if params[:id] == "batch-update" if params[:offers] @@ -162,18 +219,30 @@ def combine_contracts_print update_print_status(offer_id) end offer = Offer.find(offer_id) - offers.push(offer.format) + offers.push(offer) + end + # create a pdf concatenation of all the offers using CombinePDF + out_pdf = CombinePDF.new + offers.each do |offer| + rendered = render_to_string pdf: "contract", inline: get_contract_html(offer, true), encoding: "UTF-8" + out_pdf << CombinePDF.parse(rendered) end - generator = ContractGenerator.new(offers, true) - send_data generator.render, filename: "contracts.pdf", disposition: "inline" + send_data out_pdf.to_pdf, filename: "contracts.pdf", disposition: "inline" end def get_contract - get_contract_pdf(params) + rendered = get_contract_html(Offer.find(params[:offer_id])) + render pdf: "contract", inline: rendered end def get_contract_student - get_contract_pdf(params) + rendered = get_contract_html(Offer.find(params[:offer_id])) + render pdf: "contract", inline: rendered + end + + def get_contract_student_html + rendered = get_contract_html(Offer.find(params[:offer_id])) + render inline: rendered end def set_status_student @@ -219,14 +288,24 @@ def reset_offer end private - def get_contract_pdf(params) - offer = Offer.find(params[:offer_id]) - generator = ContractGenerator.new([offer.format]) - send_data generator.render, filename: "contract.pdf", disposition: "inline" + def get_contract_html(offer, office_template=false) + contract_dir = "#{Rails.root}/app/views/contracts/#{ENV["CONTRACT_SUBDIR"]}" + # load the offer as a Liquid template + if office_template + template = Liquid::Template.parse(File.read("#{contract_dir}/offer-template-office.html")) + else + template = Liquid::Template.parse(File.read("#{contract_dir}/offer-template.html")) + end + # font.css and header.css contain base64-encoded data since we need all + # data to be embedded in the HTML document + styles = { "style_font" => File.read("#{contract_dir}/font.css"), + "style_header" => File.read("#{contract_dir}/header.css")} + subs = offer.format.merge(styles) + rendered = template.render(subs) end def offer_params - params.permit(:hr_status, :ddah_status, :commentary) + params.permit(:hr_status, :ddah_status, :commentary, :hours) end def get_all_offers(offers) diff --git a/app/controllers/positions_controller.rb b/app/controllers/positions_controller.rb index 7ee57826..00aa151e 100644 --- a/app/controllers/positions_controller.rb +++ b/app/controllers/positions_controller.rb @@ -1,4 +1,5 @@ class PositionsController < ApplicationController + include Importer protect_from_forgery with: :null_session include Authorizer include Model @@ -47,6 +48,59 @@ def update update_date(params[:start_date], position, :start_date) update_date(params[:end_date], position, :end_date) end + + def add_or_update + exceptions = [] + session = params[:session_id] + positions = params[:positions] + begin + positions.each do |course_entry| + posting_id = course_entry["course_id"] + course_id = posting_id + round_id = course_entry["round_id"] + dates = get_dates(course_entry["dates"]) + if !dates + dates = [nil, nil] + exceptions.push("Error: The dates for Position #{course_entry["course_id"]} is malformed.") + end + exists = "Position #{posting_id} already exists" + ident = {position: posting_id, round_id: round_id} + campus_code = 1 + data = { + position: posting_id, + round_id: round_id, + open: true, + campus_code: campus_code, + course_name: (course_entry["course_name"] || "").strip, + current_enrolment: course_entry["enrolment"], + duties: course_entry["duties"], + qualifications: course_entry["qualifications"], + hours: course_entry["n_hours"] || 0, + estimated_count: course_entry["n_positions"] || 0, + estimated_total_hours: course_entry["total_hours"], + session_id: session, + start_date: dates[0], + end_date: dates[1], + } + position = insertion_helper(Position, data, ident, exists) + end + rescue + exceptions.push("Error: Unknown error when creating Position") + end + + + if exceptions.length > 0 + status = {success: true, errors: true, message: exceptions} + else + status = {success: true, errors: false, message: ["Positions import was successful."]} + end + + if status[:success] + render json: {errors: status[:errors], message: status[:message]} + else + render status: 404, json: {message: status[:message], errors: status[:errors]} + end + end private def position_params @@ -85,5 +139,18 @@ def get_all_positions_for_utorid(utorid, session = nil) return positions end + def get_dates(dates) + if dates + dates = dates.split(" to ") + if dates.size == 2 + return dates + else + dates = dates[0].split(" - ") + if dates.size == 2 + return dates + end + end + end + end end diff --git a/app/javascript/cp-styles.sass b/app/javascript/cp-styles.sass index 9e074cfe..a5f1eea8 100644 --- a/app/javascript/cp-styles.sass +++ b/app/javascript/cp-styles.sass @@ -1,3 +1 @@ -@import '../assets/stylesheets/cp' - -@import '~bootstrap/dist/css/bootstrap' +@import '../assets/stylesheets/cp' \ No newline at end of file diff --git a/app/javascript/cp/appState.js b/app/javascript/cp/appState.js index b134ecb4..fff3ac06 100644 --- a/app/javascript/cp/appState.js +++ b/app/javascript/cp/appState.js @@ -1,7 +1,7 @@ -import React from 'react'; -import { fromJS } from 'immutable'; +import React from "react"; +import { fromJS } from "immutable"; -import * as fetch from './fetch.js'; +import * as fetch from "./fetch.js"; const initialState = { // navbar component @@ -13,7 +13,7 @@ const initialState = { selectedTab: null, // list of unread notifications (string can contain HTML, but be careful because it is not sanitized!) - notifications: [], + notifications: [] }, // list of UI alerts (string can contain HTML, but be careful because it is not sanitized!) @@ -30,11 +30,13 @@ const initialState = { supervisorId: null, optional: null, requiresTraining: false, - allocations: [{ id: null, units: null, duty: null, type: null, time: null }], + allocations: [ + { id: null, units: null, duty: null, type: null, time: null } + ], trainings: [], categories: [], - changed: false, // "dirty" bit + changed: false // "dirty" bit }, selectedDdahData: { type: null, id: null }, @@ -49,7 +51,7 @@ const initialState = { templates: { fetching: 0, list: null }, trainings: { fetching: 0, list: null }, - importing: 0, + importing: 0 }; class AppState { @@ -60,7 +62,12 @@ class AppState { // list of change listeners this._listeners = []; // notify listeners of change - var notifyListeners = () => this._listeners.forEach(listener => listener()); + const notifyListeners = () => { + for (let listener of this._listeners) { + listener(); + } + //this._listeners.forEach(listener => listener()); + }; // parses a property path into a list, as expected by Immutable var parsePath = path => @@ -87,7 +94,8 @@ class AppState { if (arguments.length == 1) { _data = _data.withMutations(map => { Object.entries(property).reduce( - (result, [prop, val]) => result.setIn(parsePath(prop), val), + (result, [prop, val]) => + result.setIn(parsePath(prop), val), map ); }); @@ -111,18 +119,24 @@ class AppState { // add a row to the ddah worksheet addAllocation() { - let allocations = this.get('ddahWorksheet.allocations'); + let allocations = this.get("ddahWorksheet.allocations"); // max. 24 rows are supported (this number comes from counting the number of rows generated // in the DDAH form PDF) if (allocations.size == 24) { - this.alert('No more rows can be added.'); + this.alert("No more rows can be added."); } else { this.set({ - 'ddahWorksheet.allocations': allocations.push( - fromJS({ id: null, units: null, duty: null, type: null, time: null }) + "ddahWorksheet.allocations": allocations.push( + fromJS({ + id: null, + units: null, + duty: null, + type: null, + time: null + }) ), - 'ddahWorksheet.changed': true, + "ddahWorksheet.changed": true }); } } @@ -130,25 +144,27 @@ class AppState { // apply a sort to the offers table // note that we do not allow multiple sorts on the same field (incl. in different directions) addSort(field) { - let sorts = this.get('selectedSortFields'); + let sorts = this.get("selectedSortFields"); if (!sorts.some(val => val.get(0) == field)) { - this.set('selectedSortFields', sorts.push(fromJS([field, 1]))); + this.set("selectedSortFields", sorts.push(fromJS([field, 1]))); } else { - this.alert('Applicant Table Cannot apply the same sort more than once.'); + this.alert( + "Applicant Table Cannot apply the same sort more than once." + ); } } // add an alert to the list of active alerts alert(text) { - let alerts = this.get('alerts'); + let alerts = this.get("alerts"); // give it an id that is 1 larger than the largest id in the array, or 0 if the array is empty this.set( - 'alerts', + "alerts", alerts.unshift( fromJS({ - id: alerts.size > 0 ? alerts.first().get('id') + 1 : 0, - text: text, + id: alerts.size > 0 ? alerts.first().get("id") + 1 : 0, + text: text }) ) ); @@ -156,141 +172,159 @@ class AppState { // check whether the user has made any changes to the current ddah worksheet anyDdahWorksheetChanges() { - return this.get('ddahWorksheet.changed'); + return this.get("ddahWorksheet.changed"); } // check whether any of the given filters in the category are selected on the offers table anyFilterSelected(field) { - return this.get('selectedFilters').has(field); + return this.get("selectedFilters").has(field); } applyTemplate(templateId) { - let template = this.get('templates.list.' + templateId); - template.get('allocations').forEach(function(allocation, key){ - let allocations = template.get("allocations").set(key, allocation.remove("id")); - template = template.set("allocations", allocations); + let template = this.get("templates.list." + templateId); + template.get("allocations").forEach(function(allocation, key) { + let allocations = template + .get("allocations") + .set(key, allocation.remove("id")); + template = template.set("allocations", allocations); }); // replace the values in the current ddah with the values in the template this.set( - 'ddahWorksheet', - this.get('ddahWorksheet') + "ddahWorksheet", + this.get("ddahWorksheet") .merge(this.createDdahWorksheet(template)) - .set('changed', true) + .set("changed", true) ); } clearDdah() { - if (window.confirm('Are you sure that you want to clear the current form?')) { - this.set('ddahWorksheet', fromJS(initialState.ddahWorksheet).set('changed', true)); + if ( + window.confirm( + "Are you sure that you want to clear the current form?" + ) + ) { + this.set( + "ddahWorksheet", + fromJS(initialState.ddahWorksheet).set("changed", true) + ); } } // remove all selected filters on the offers table clearFilters() { - this.set('selectedFilters', fromJS({})); + this.set("selectedFilters", fromJS({})); } // return ddahData in the ddah worksheet format createDdahWorksheet(ddahData) { let worksheet = { - supervisorId: ddahData.get('supervisorId'), - supervisor: ddahData.get('supervisor'), - tutCategory: ddahData.get('tutCategory'), - optional: ddahData.get('optional'), - requiresTraining: ddahData.get('requiresTraining'), - allocations: ddahData.get('allocations'), - trainings: ddahData.get('trainings'), - categories: ddahData.get('categories'), + supervisorId: ddahData.get("supervisorId"), + supervisor: ddahData.get("supervisor"), + tutCategory: ddahData.get("tutCategory"), + optional: ddahData.get("optional"), + requiresTraining: ddahData.get("requiresTraining"), + allocations: ddahData.get("allocations"), + trainings: ddahData.get("trainings"), + categories: ddahData.get("categories") }; return fromJS(worksheet); } dismissAlert(id) { - let alerts = this.get('alerts'); - let i = alerts.findIndex(alert => alert.get('id') == id); + let alerts = this.get("alerts"); + let i = alerts.findIndex(alert => alert.get("id") == id); if (i != -1) { - this.set('alerts', alerts.delete(i)); + this.set("alerts", alerts.delete(i)); } } getAlerts() { - return this.get('alerts'); + return this.get("alerts"); } getCurrentUserName() { - return this.get('nav.user'); + return this.get("nav.user"); } getCurrentUserRoles() { - return this.get('nav.roles'); + return this.get("nav.roles"); } getTaCoordinator() { - return this.get('taCoordinator'); + return this.get("taCoordinator"); } - getDdahApprovedSignature(offers){ + getDdahApprovedSignature(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } let ddahs = this.getDdahsFromOffers(offers); - if (ddahs.length > 0){ - let signature = window.prompt('Please enter your initial for approving DDAH\'s:'); + if (ddahs.length > 0) { + let signature = window.prompt( + "Please enter your initial for approving DDAH's:" + ); if (signature && signature.trim()) { - fetch.setDdahApproved(ddahs, signature.trim()); + fetch.setDdahApproved(ddahs, signature.trim()); } } } - getDdahsFromOffers(offers){ - let ddahs = [] - let allOffers = this.getOffersList(); - offers.forEach(offer => { - let ddah = (this.get('ddahs.list').findKey(ddah => ddah.get('offer') == offer)); - if (ddah == null){ - this.alert('Error: There is no DDAH form of '+ - allOffers.getIn([offer, 'lastName']) + ', '+ - allOffers.getIn([offer, 'firstName']) + ' for '+ - allOffers.getIn([offer, 'course']) - ); - } - else{ - ddahs.push(ddah); - } - }); - return ddahs; + getDdahsFromOffers(offers) { + let ddahs = []; + let allOffers = this.getOffersList(); + offers.forEach(offer => { + let ddah = this.get("ddahs.list").findKey( + ddah => ddah.get("offer") == offer + ); + if (ddah == null) { + this.alert( + "Error: There is no DDAH form of " + + allOffers.getIn([offer, "lastName"]) + + ", " + + allOffers.getIn([offer, "firstName"]) + + " for " + + allOffers.getIn([offer, "course"]) + ); + } else { + ddahs.push(ddah); + } + }); + return ddahs; } // compute total ddah hours getDdahWorksheetTotal() { - let total = this.get('ddahWorksheet.allocations').reduce( - (sum, allocation) => sum + allocation.get('units') * allocation.get('time'), + let total = this.get("ddahWorksheet.allocations").reduce( + (sum, allocation) => + sum + allocation.get("units") * allocation.get("time"), 0 ); return isNaN(total) ? 0 : total / 60; } getDdahWorksheet() { - return this.get('ddahWorksheet'); + return this.get("ddahWorksheet"); } // returns a map of duty ids to totals for each duty getDutiesSummary() { let summary = this.getDutiesList().map(_ => 0); - this.get('ddahWorksheet.allocations').forEach(allocation => { + this.get("ddahWorksheet.allocations").forEach(allocation => { if ( - allocation.get('duty') && - allocation.get('time') != undefined && - allocation.get('units') != undefined + allocation.get("duty") && + allocation.get("time") != undefined && + allocation.get("units") != undefined ) { summary = summary.update( - allocation.get('duty').toString(), - time => time + allocation.get('units') * allocation.get('time') / 60 + allocation.get("duty").toString(), + time => + time + + (allocation.get("units") * allocation.get("time")) / 60 ); } }); @@ -299,145 +333,168 @@ class AppState { } getFilters() { - return this.get('selectedFilters'); + return this.get("selectedFilters"); } getSelectedDdahId() { - return this.get('selectedDdahData.id'); + return this.get("selectedDdahData.id"); } getSelectedOffer() { - return this.get('selectedOffer'); + return this.get("selectedOffer"); } getSelectedSession() { - return this.get('selectedSession'); + return this.get("selectedSession"); } getSelectedCourse() { - return this.get('selectedCourse'); + return this.get("selectedCourse"); } getSelectedNavTab() { - return this.get('nav.selectedTab'); + return this.get("nav.selectedTab"); } getSelectedUserRole() { - return this.get('nav.selectedRole'); + return this.get("nav.selectedRole"); } getSorts() { - return this.get('selectedSortFields'); + return this.get("selectedSortFields"); } getUnreadNotifications() { - return this.get('nav.notifications'); + return this.get("nav.notifications"); } // check whether a filter is selected on the offers table isFilterSelected(field, category) { - let filters = this.get('selectedFilters'); + let filters = this.get("selectedFilters"); return filters.has(field) && filters.get(field).includes(category); } // check whether the currently-selected ddah data corresponds to an offer isOfferSelected() { - return this.get('selectedDdahData.type') == 'offer'; + return this.get("selectedDdahData.type") == "offer"; } // check whether the currently-selected ddah data corresponds to a template isTemplateSelected() { - return this.get('selectedDdahData.type') == 'template'; + return this.get("selectedDdahData.type") == "template"; } // add a notification to the list of unread notifications notify(text) { - let notifications = this.get('nav.notifications'); - this.set('nav.notifications', notifications.push(text)); + let notifications = this.get("nav.notifications"); + this.set("nav.notifications", notifications.push(text)); } // clear the list of unread notifications readNotifications() { - this.set('nav.notifications', fromJS([])); + this.set("nav.notifications", fromJS([])); } // remove an allocation from the ddah worksheet removeAllocation(index) { this.set({ - 'ddahWorksheet.allocations': this.get('ddahWorksheet.allocations').delete(index), - 'ddahWorksheet.changed': true, + "ddahWorksheet.allocations": this.get( + "ddahWorksheet.allocations" + ).delete(index), + "ddahWorksheet.changed": true }); } // remove a sort from the offers table removeSort(field) { - let sorts = this.get('selectedSortFields'); + let sorts = this.get("selectedSortFields"); let i = sorts.findIndex(f => f.get(0) == field); - this.set('selectedSortFields', sorts.delete(i)); + this.set("selectedSortFields", sorts.delete(i)); + } + + // cycle through sort+, sort-, no sort + cycleSort(field) { + const sorts = this.get("selectedSortFields"); + const i = sorts.findIndex(f => f.get(0) === field); + if (i === -1) { + this.addSort(field); + return; + } + + const dir = sorts.get(i).get(1); + + if (dir === 1) { + this.toggleSortDir(field); + } else { + this.removeSort(field); + } } // select a navbar tab selectNavTab(eventKey) { - this.set('nav.selectedTab', eventKey); + this.set("nav.selectedTab", eventKey); } selectSession(id) { - this.set('selectedSession', id); + this.set("selectedSession", id); let role = appState.getSelectedUserRole(); - switch(role){ - case 'cp_admin': - case 'hr_assistant': - fetch.adminFetchAll(); - break; - case 'instructor': - fetch.instructorFetchAll(); - break; + switch (role) { + case "cp_admin": + case "hr_assistant": + fetch.adminFetchAll(); + break; + case "instructor": + fetch.instructorFetchAll(); + break; } } - getSessionName(id){ + getSessionName(id) { let sessions = this.getSessionsList(); - if(sessions.size>0){ - let selected = sessions.get(id); - if(selected) - return selected.get('semester')+' '+selected.get('year'); + if (sessions.size > 0) { + let selected = sessions.get(id); + if (selected) + return selected.get("semester") + " " + selected.get("year"); } - return ''; + return ""; } - getSessionPay(session){ - return this.get('sessions.list.'+session+'.pay'); + getSessionPay(session) { + return this.get("sessions.list." + session + ".pay"); } selectCourse(course) { - this.set('selectedCourse', course); + this.set("selectedCourse", course); } selectUserRole(role) { - this.set('nav.selectedRole', role); + this.set("nav.selectedRole", role); } setCurrentUserName(user) { - this.set('nav.user', user); + this.set("nav.user", user); } setCurrentUserRoles(roles) { - this.set('nav.roles', roles); + this.set("nav.roles", roles); } - setTaCoordinator(coordinator){ - this.set('taCoordinator', coordinator); + setTaCoordinator(coordinator) { + this.set("taCoordinator", coordinator); } setDdahWorksheet(ddah) { - this.set('ddahWorksheet', fromJS(Object.assign({}, ddah, { changed: true }))); + this.set( + "ddahWorksheet", + fromJS(Object.assign({}, ddah, { changed: true })) + ); } // toggle a filter on the offers table toggleFilter(field, category) { - let filters = this.get('selectedFilters'); + let filters = this.get("selectedFilters"); if (filters.has(field)) { let filter = filters.get(field); @@ -445,16 +502,19 @@ class AppState { if (i == -1) { // filter on this category is not already applied - this.set('selectedFilters[' + field + ']', filter.push(category)); + this.set( + "selectedFilters[" + field + "]", + filter.push(category) + ); } else if (filter.size > 1) { // filter on this category is already applied, along with other categories - this.set('selectedFilters[' + field + ']', filter.delete(i)); + this.set("selectedFilters[" + field + "]", filter.delete(i)); } else { // filter is only applied on this category - this.set('selectedFilters', filters.remove(field)); + this.set("selectedFilters", filters.remove(field)); } } else { - this.set('selectedFilters[' + field + ']', fromJS([category])); + this.set("selectedFilters[" + field + "]", fromJS([category])); } } @@ -464,27 +524,37 @@ class AppState { // this offer is currently selected this.set({ selectedDdahData: fromJS({ type: null, id: null }), - ddahWorksheet: fromJS(initialState.ddahWorksheet).set('changed', false), + ddahWorksheet: fromJS(initialState.ddahWorksheet).set( + "changed", + false + ) }); } else { // this offer is not currently selected - let newDdahData = this.get('ddahs.list').find(ddah => ddah.get('offer') == offer); + let newDdahData = this.get("ddahs.list").find( + ddah => ddah.get("offer") == offer + ); if (newDdahData) { // ddah found for this offer this.set({ - selectedDdahData: fromJS({ type: 'offer', id: offer }), - ddahWorksheet: this.createDdahWorksheet(newDdahData).set('changed', false), + selectedDdahData: fromJS({ type: "offer", id: offer }), + ddahWorksheet: this.createDdahWorksheet(newDdahData).set( + "changed", + false + ) }); } else { // ddah does not already exist for this offer, so create a new one fetch.createDdah(offer).then(newDdah => { // set the ddah worksheet data from the newly-created ddah - newDdahData = this.get('ddahs.list.' + newDdah); + newDdahData = this.get("ddahs.list." + newDdah); this.set({ - selectedDdahData: fromJS({ type: 'offer', id: offer }), - ddahWorksheet: this.createDdahWorksheet(newDdahData).set('changed', false), + selectedDdahData: fromJS({ type: "offer", id: offer }), + ddahWorksheet: this.createDdahWorksheet( + newDdahData + ).set("changed", false) }); }); } @@ -499,26 +569,35 @@ class AppState { // this template is currently selected, so unselect it this.set({ selectedDdahData: fromJS({ type: null, id: null }), - ddahWorksheet: fromJS(initialState.ddahWorksheet).set('changed', false), + ddahWorksheet: fromJS(initialState.ddahWorksheet).set( + "changed", + false + ) }); } else { - let newDdahData = this.get('templates.list.' + template); + let newDdahData = this.get("templates.list." + template); // this template is not currently selected, so select it this.set({ - selectedDdahData: fromJS({ type: 'template', id: template }), - ddahWorksheet: this.createDdahWorksheet(newDdahData).set('changed', false), + selectedDdahData: fromJS({ type: "template", id: template }), + ddahWorksheet: this.createDdahWorksheet(newDdahData).set( + "changed", + false + ) }); } } // toggle the sort direction of the sort currently applied to the offers table toggleSortDir(field) { - let sortFields = this.get('selectedSortFields'); + let sortFields = this.get("selectedSortFields"); let i = sortFields.findIndex(f => f.get(0) == field); if (i != -1) { - this.set('selectedSortFields[' + i + '][1]', -sortFields.get(i).get(1)); + this.set( + "selectedSortFields[" + i + "][1]", + -sortFields.get(i).get(1) + ); } } @@ -526,32 +605,38 @@ class AppState { updateDdahWorksheet(attribute, value) { // if the attribute is a array of values, toggle the presence of this value in the array // (i.e. if the value is absent, add it; otherwise, remove it) - if (attribute == 'trainings' || attribute == 'categories') { - let array = this.get('ddahWorksheet.' + attribute); + if (attribute == "trainings" || attribute == "categories") { + let array = this.get("ddahWorksheet." + attribute); let i = array.indexOf(parseInt(value)); if (i == -1) { // value is not present this.set({ - ['ddahWorksheet.' + attribute]: array.push(parseInt(value)), - 'ddahWorksheet.changed': true, + ["ddahWorksheet." + attribute]: array.push(parseInt(value)), + "ddahWorksheet.changed": true }); } else { this.set({ - ['ddahWorksheet.' + attribute]: array.delete(i), - 'ddahWorksheet.changed': true, + ["ddahWorksheet." + attribute]: array.delete(i), + "ddahWorksheet.changed": true }); } } else { - this.set({ ['ddahWorksheet.' + attribute]: value, 'ddahWorksheet.changed': true }); + this.set({ + ["ddahWorksheet." + attribute]: value, + "ddahWorksheet.changed": true + }); } } // update ddah allocation attribute updateDdahWorksheetAllocation(allocation, attribute, value) { this.set({ - ['ddahWorksheet.allocations[' + allocation + '].' + attribute]: value, - 'ddahWorksheet.changed': true, + ["ddahWorksheet.allocations[" + + allocation + + "]." + + attribute]: value, + "ddahWorksheet.changed": true }); } @@ -562,32 +647,32 @@ class AppState { // check if any data needed by instructors is being fetched instrAnyFetching() { return [ - this.get('categories.fetching'), - this.get('courses.fetching'), - this.get('ddahs.fetching'), - this.get('duties.fetching'), - this.get('offers.fetching'), - this.get('templates.fetching'), - this.get('trainings.fetching'), + this.get("categories.fetching"), + this.get("courses.fetching"), + this.get("ddahs.fetching"), + this.get("duties.fetching"), + this.get("offers.fetching"), + this.get("templates.fetching"), + this.get("trainings.fetching") ].some(val => val > 0); } // check if any data needed by instructors has not yet been fetched instrAnyNull() { return [ - this.get('categories.list'), - this.get('courses.list'), - this.get('ddahs.list'), - this.get('duties.list'), - this.get('offers.list'), - this.get('templates.list'), - this.get('trainings.list'), + this.get("categories.list"), + this.get("courses.list"), + this.get("ddahs.list"), + this.get("duties.list"), + this.get("offers.list"), + this.get("templates.list"), + this.get("trainings.list") ].some(val => val == null); } clearHrStatus(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } @@ -595,23 +680,26 @@ class AppState { } createTemplate() { - let name = window.prompt('Please enter a name for the new template:'); + let name = window.prompt("Please enter a name for the new template:"); if (name && name.trim()) { // the route to create a new template expects a position with which to associate the template // we don't associate templates with positions in the front-end model, so we pick a position id // without caring which - fetch.createTemplate(name) + fetch + .createTemplate(name) // when the request succeeds, display the new template .then(template => this.toggleSelectedTemplate(template)); } } createTemplateFromDdah(offer) { - let name = window.prompt('Please enter a name for the new template:'); + let name = window.prompt("Please enter a name for the new template:"); if (name && name.trim()) { - let ddahId = this.get('ddahs.list').findKey(ddah => ddah.get('offer') == offer); + let ddahId = this.get("ddahs.list").findKey( + ddah => ddah.get("offer") == offer + ); fetch.createTemplateFromDdah(name, ddahId); } } @@ -619,54 +707,61 @@ class AppState { // email applicants email(offers) { let allOffers = this.getOffersList(); - let emails = offers.map(offer => allOffers.getIn([offer, 'email'])); + let emails = offers.map(offer => allOffers.getIn([offer, "email"])); - var a = document.createElement('a'); + var a = document.createElement("a"); a.href = emails.length == 1 - ? 'mailto:' + emails[0] // if there is only a single recipient, send normally - : 'mailto:?bcc=' + emails.join(','); // if there are multiple recipients, bcc all + ? "mailto:" + emails[0] // if there is only a single recipient, send normally + : "mailto:?bcc=" + emails.join(","); // if there are multiple recipients, bcc all a.click(); } // email contract link to a single applicant emailContract(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } if (offers.length != 1) { - this.alert('Error: Can only email a contract link to a single applicant.'); + this.alert( + "Error: Can only email a contract link to a single applicant." + ); return; } let offer = this.getOffersList().get(offers[0]); - if (!offer.get('link')) { + if (!offer.get("link")) { // offer does not have a contract link this.alert( - 'Error: Offer to ' + - offer.get('lastName') + - ', ' + - offer.get('firstName') + - ' does not have an associated contract' + "Error: Offer to " + + offer.get("lastName") + + ", " + + offer.get("firstName") + + " does not have an associated contract" ); return; } - var a = document.createElement('a'); + var a = document.createElement("a"); a.href = - 'mailto:' + offer.get('email') + '?body=Link%20to%20contract:%20' + offer.get('link'); + "mailto:" + + offer.get("email") + + "?body=Link%20to%20contract:%20" + + offer.get("link"); a.click(); } // email ddah form link to a single applicant emailDdah(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } if (offers.length != 1) { - this.alert('Error: Can only email a DDAH form link to a single applicant.'); + this.alert( + "Error: Can only email a DDAH form link to a single applicant." + ); return; } @@ -674,21 +769,23 @@ class AppState { let ddah = this.getDdahsFromOffers(offers); if (ddah.length == 1) { let allDdahs = this.getDdahsList(); - if (allDdahs.getIn([ddah, 'link'])==null){ - // ddah does not have a ddah link - this.alert( - 'Error: Offer to ' + - offer.get('lastName') + - ', ' + - offer.get('firstName') + - ' does not have an associated DDAH form' - ); - return; - } - else{ - var a = document.createElement('a'); + if (allDdahs.getIn([ddah, "link"]) == null) { + // ddah does not have a ddah link + this.alert( + "Error: Offer to " + + offer.get("lastName") + + ", " + + offer.get("firstName") + + " does not have an associated DDAH form" + ); + return; + } else { + var a = document.createElement("a"); a.href = - 'mailto:' + offer.get('email') + '?body=Link%20to%20DDAH%20form:%20' + allDdahs.getIn([ddah, 'link']); + "mailto:" + + offer.get("email") + + "?body=Link%20to%20DDAH%20form:%20" + + allDdahs.getIn([ddah, "link"]); a.click(); } } @@ -696,10 +793,10 @@ class AppState { // export offers to CSV exportOffers() { - let session = this.get('selectedSession'); + let session = this.get("selectedSession"); if (!session) { this.alert( - 'Export offers from all sessions This functionality is not currently supported. Please select a session.' + "Export offers from all sessions This functionality is not currently supported. Please select a session." ); return; } @@ -711,126 +808,129 @@ class AppState { exportDdahs() { let course = this.getSelectedCourse(); let selectedSession = this.getSelectedSession(); - if(selectedSession=='N/A'){ - this.alert('Error: You do not have a session in the system. Please import data from TAPP.'); - return; + if (selectedSession == "N/A") { + this.alert( + "Error: You do not have a session in the system. Please import data from TAPP." + ); + return; } fetch.exportDdahs(course, selectedSession); } fetchAll() { let role = this.getSelectedUserRole(); - if (role == 'cp_admin' || role == 'hr_assistant') { + if (role == "cp_admin" || role == "hr_assistant") { fetch.adminFetchAll(); - } else if (role == 'instructor') { + } else if (role == "instructor") { fetch.instructorFetchAll(); } } // check if categories are being fetched fetchingCategories() { - return this.get('categories.fetching') > 0; + return this.get("categories.fetching") > 0; } // check if courses are being fetched fetchingCourses() { - return this.get('courses.fetching') > 0; + return this.get("courses.fetching") > 0; } // check if ddahs are being fetched fetchingDdahs() { - return this.get('ddahs.fetching') > 0; + return this.get("ddahs.fetching") > 0; } // check if duties are being fetched fetchingDuties() { - return this.get('duties.fetching') > 0; + return this.get("duties.fetching") > 0; } // check if offers are being fetched fetchingOffers() { - return this.get('offers.fetching') > 0; + return this.get("offers.fetching") > 0; } // check if sessions are being fetched fetchingSessions() { - return this.get('sessions.fetching') > 0; + return this.get("sessions.fetching") > 0; } // check if templates are being fetched fetchingTemplates() { - return this.get('templates.fetching') > 0; + return this.get("templates.fetching") > 0; } // check if trainings are being fetched fetchingTrainings() { - return this.get('trainings.fetching') > 0; + return this.get("trainings.fetching") > 0; } getCategoriesList() { - return this.get('categories.list'); + return this.get("categories.list"); } getCoursesList() { - return this.get('courses.list'); + return this.get("courses.list"); } - getSessionCourse(){ + getSessionCourse() { let session = this.getSelectedSession(); let courses = this.getCoursesList(); let selected = []; - if (session == ''){ - courses.forEach((course, key)=>{ - selected.push({ - id: key, - code: course.get('code'), + if (session == "") { + courses.forEach((course, key) => { + selected.push({ + id: key, + code: course.get("code") + }); + }); + } else { + courses.forEach((course, key) => { + if (course.get("session") == session) + selected.push({ + id: key, + code: course.get("code") + }); }); - }); - } - else{ - courses.forEach((course, key)=>{ - if (course.get("session")==session) - selected.push({ - id: key, - code: course.get('code'), - }); - }); } - selected.sort((a, b)=>this.compareString(a, b, 'code')); + selected.sort((a, b) => this.compareString(a, b, "code")); return selected; } - compareString(a, b, attr){ - a = a[attr]; - b = b[attr]; - if (a < b) return -1; - if (a > b) return 1; - return 0; + compareString(a, b, attr) { + a = a[attr]; + b = b[attr]; + if (a < b) return -1; + if (a > b) return 1; + return 0; } getDdahsList() { - return this.get('ddahs.list'); + return this.get("ddahs.list"); } getDutiesList() { - return this.get('duties.list'); + return this.get("duties.list"); } getOffersList() { - return this.get('offers.list'); + return this.get("offers.list"); } getOffersForCourse(course) { - return this.get('offers.list').filter(offer => offer.get('position') == course); + return this.get("offers.list").filter( + offer => offer.get("position") == course + ); } // get a sorted list of the positions in the current offers list as a JS array getPositions() { let offers = this.getOffersList(); - if (offers.size>0) { + if (offers.size > 0) { return offers - .map(offer => offer.get('course')) + .map(offer => offer.get("course")) .flip() .keySeq() .sort() @@ -840,15 +940,15 @@ class AppState { } getSessionsList() { - return this.get('sessions.list'); + return this.get("sessions.list"); } getTemplatesList() { - return this.get('templates.list'); + return this.get("templates.list"); } getTrainingsList() { - return this.get('trainings.list'); + return this.get("trainings.list"); } importAssignments() { @@ -864,19 +964,19 @@ class AppState { } importing() { - return this.get('importing') > 0; + return this.get("importing") > 0; } isCategoriesListNull() { - return this.get('categories.list') == null; + return this.get("categories.list") == null; } isCoursesListNull() { - return this.get('courses.list') == null; + return this.get("courses.list") == null; } isDdahsListNull() { - return this.get('ddahs.list') == null; + return this.get("ddahs.list") == null; } // verify that the current ddah is valid for submission: @@ -885,45 +985,56 @@ class AppState { // the total number of hours must be equal to the number of hours in the offer // all allocation fields must have non-null values isDdahValidForSubmission(ddahId, expectedHours) { - let ddah = this.get('ddahs.list.' + ddahId), + let ddah = this.get("ddahs.list." + ddahId), alerts = []; - if (ddah.get('optional') !== true && ddah.get('optional') !== false) { - alerts.push('Error: Must decide whether tutorial is "Optional" or "Mandatory".'); + if (ddah.get("optional") !== true && ddah.get("optional") !== false) { + alerts.push( + 'Error: Must decide whether tutorial is "Optional" or "Mandatory".' + ); } - if (ddah.get('categories').size == 0) { - alerts.push('Error: Must select at least one tutorial category.'); + if (ddah.get("categories").size == 0) { + alerts.push( + "Error: Must select at least one tutorial category." + ); } - let totalMin = - ddah - .get('allocations') - .reduce( - (sum, allocation) => sum + allocation.get('units') * allocation.get('time'), - 0.0 - ); + let totalMin = ddah + .get("allocations") + .reduce( + (sum, allocation) => + sum + allocation.get("units") * allocation.get("time"), + 0.0 + ); if (isNaN(totalMin)) { - alerts.push('Error:Total time (NaN) is not equal to the expected number of hours.'); + alerts.push( + "Error:Total time (NaN) is not equal to the expected number of hours." + ); } else { - //if the calculated total is within a minute of the TA's allocation then close enough - if (Math.abs(totalMin - expectedHours*60) < 1.0) { + //if the calculated total is within a minute of the TA's allocation then close enough + if (Math.abs(totalMin - expectedHours * 60) < 1.0) { } else { - alerts.push('Error: Total time is not equal to the expected number of hours.'); + alerts.push( + "Error: Total time is not equal to the expected number of hours." + ); } } - let allocations = ddah.get('allocations'); + let allocations = ddah.get("allocations"); if ( allocations.some( allocation => - !allocation.get('units') || - (!allocation.get('type') || !allocation.get('type').trim()) || - !allocation.get('time') || - !allocation.get('duty') + !allocation.get("units") || + (!allocation.get("type") || + !allocation.get("type").trim()) || + !allocation.get("time") || + !allocation.get("duty") ) ) { - alerts.push('Error: Incomplete field(s) in Allocation of Hours Worksheet.'); + alerts.push( + "Error: Incomplete field(s) in Allocation of Hours Worksheet." + ); } alerts.forEach(alert => this.alert(alert)); @@ -932,49 +1043,49 @@ class AppState { } isDutiesListNull() { - return this.get('duties.list') == null; + return this.get("duties.list") == null; } isOffersListNull() { - return this.get('offers.list') == null; + return this.get("offers.list") == null; } isSessionsListNull() { - return this.get('sessions.list') == null; + return this.get("sessions.list") == null; } isTemplatesListNull() { - return this.get('templates.list') == null; + return this.get("templates.list") == null; } isTrainingsListNull() { - return this.get('trainings.list') == null; + return this.get("trainings.list") == null; } nagApplicantDdahs(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } let ddahs = this.getDdahsFromOffers(offers); - if (ddahs.length > 0){ + if (ddahs.length > 0) { fetch.nagApplicantDdahs(ddahs); } } nagInstructors(offers) { - if (offers.length == 0) { - this.alert('Error: No offer selected'); - return; - } + if (offers.length == 0) { + this.alert("Error: No offer selected"); + return; + } - fetch.nagInstructors(offers); + fetch.nagInstructors(offers); } nagOffers(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } @@ -987,14 +1098,16 @@ class AppState { } previewDdah(offer) { - let ddahId = this.get('ddahs.list').findKey(ddah => ddah.get('offer') == offer); + let ddahId = this.get("ddahs.list").findKey( + ddah => ddah.get("offer") == offer + ); fetch.previewDdah(ddahId); } print(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } @@ -1003,12 +1116,12 @@ class AppState { resetOffer(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } if (offers.length != 1) { this.alert( - 'Error: Can only reset offer status for a single applicant at a time.' + "Error: Can only reset offer status for a single applicant at a time." ); return; } @@ -1016,9 +1129,20 @@ class AppState { fetch.resetOffer(offers[0]); } + setOfferDetails(offers) { + if (!offers.length) { + offers = [offers]; + } + fetch.setOfferDetails(offers); + } + + setApplicantDetails(details) { + fetch.setApplicantDetails(details); + } + sendContracts(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } @@ -1027,76 +1151,80 @@ class AppState { sendDdahs(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } let ddahs = this.getDdahsFromOffers(offers); - if (ddahs.length > 0){ + if (ddahs.length > 0) { fetch.sendDdahs(ddahs); } } - previewDdahs(offers){ - if (offers.length == 0) { - this.alert('Error: No offer selected'); - return; - } + previewDdahs(offers) { + if (offers.length == 0) { + this.alert("Error: No offer selected"); + return; + } - let ddahs = this.getDdahsFromOffers(offers); - if (ddahs.length > 0){ - fetch.previewDdahs(ddahs); - } + let ddahs = this.getDdahsFromOffers(offers); + if (ddahs.length > 0) { + fetch.previewDdahs(ddahs); + } } setCategoriesList(list) { - this.set('categories.list', list); + this.set("categories.list", list); } setCoursesList(list) { - this.set('courses.list', list); + this.set("courses.list", list); } setDdahAccepted(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } let ddahs = this.getDdahsFromOffers(offers); - if (ddahs.length > 0){ - fetch.setDdahAccepted(offers); + if (ddahs.length > 0) { + fetch.setDdahAccepted(offers); } } setDdahsList(list) { - this.set('ddahs.list', list); + this.set("ddahs.list", list); } setDutiesList(list) { - this.set('duties.list', list); + this.set("duties.list", list); } setFetchingDataList(data, fetching, success) { - let init = this.get(data + '.fetching'), - notifications = this.get('nav.notifications'); + let init = this.get(data + ".fetching"), + notifications = this.get("nav.notifications"); if (fetching) { this.set({ - [data + '.fetching']: init + 1, - 'nav.notifications': notifications.push('Fetching ' + data + '...'), + [data + ".fetching"]: init + 1, + "nav.notifications": notifications.push( + "Fetching " + data + "..." + ) }); } else if (success) { this.set({ - [data + '.fetching']: init - 1, - 'nav.notifications': notifications.push('Successfully fetched ' + data + '.'), + [data + ".fetching"]: init - 1, + "nav.notifications": notifications.push( + "Successfully fetched " + data + "." + ) }); } else { - this.set(data + '.fetching', init - 1); + this.set(data + ".fetching", init - 1); } } setHrProcessed(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } @@ -1104,30 +1232,36 @@ class AppState { } setImporting(importing, success) { - let init = this.get('importing'), - notifications = this.get('nav.notifications'); + let init = this.get("importing"), + notifications = this.get("nav.notifications"); if (importing) { this.set({ importing: init + 1, - 'nav.notifications': notifications.push('Import in progress...'), + "nav.notifications": notifications.push( + "Import in progress..." + ) }); } else if (success) { this.set({ importing: init - 1, - 'nav.notifications': notifications.push('Import completed successfully.'), + "nav.notifications": notifications.push( + "Import completed successfully." + ) }); } else { - this.set('importing', init - 1); + this.set("importing", init - 1); } } setOfferAccepted(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } if (offers.length != 1) { - this.alert('Error: Can only accept an offer for a single applicant at a time.'); + this.alert( + "Error: Can only accept an offer for a single applicant at a time." + ); return; } @@ -1135,47 +1269,46 @@ class AppState { } setOffersList(list) { - this.set('offers.list', list); + this.set("offers.list", list); } setSessionsList(list) { - let semesterOrder = ['Winter', 'Spring', 'Fall', 'Year']; + let semesterOrder = ["Fall", "Winter", "Spring", "Year", "Summer"]; // sort sesions in order of most recent to least recent list.sort((sessionA, sessionB) => { - if (sessionA.get('year') > sessionB.get('year')) { + if (sessionA.get("year") > sessionB.get("year")) { return -1; } - if (sessionA.get('year') < sessionB.get('year')) { + if (sessionA.get("year") < sessionB.get("year")) { return 1; } return ( - semesterOrder.indexOf(sessionA.get('semester')) - - semesterOrder.indexOf(sessionB.get('semester')) + semesterOrder.indexOf(sessionA.get("semester")) - + semesterOrder.indexOf(sessionB.get("semester")) ); }); - this.set('sessions.list', list); + this.set("sessions.list", list); } - getLatestSession(){ - let latest = null; - this.getSessionsList().forEach((key, id)=>{ - if(!latest|| id>latest) - latest = id; - }); - return latest?latest:'N/A'; + getLatestSession() { + let latest = null; + this.getSessionsList().forEach((key, id) => { + if (!latest || id > latest) latest = id; + }); + return latest ? latest : "N/A"; } - setLatestSession(){ - this.set('selectedSession', this.getLatestSession()); + setLatestSession() { + this.set("selectedSession", this.getLatestSession()); } setTemplatesList(list) { - this.set('templates.list', list); + this.set("templates.list", list); } setTrainingsList(list) { - this.set('trainings.list', list); + this.set("trainings.list", list); } showContractApplicant(offer) { @@ -1187,11 +1320,15 @@ class AppState { } submitDdah(offer) { - let ddahId = this.get('ddahs.list').findKey(ddah => ddah.get('offer') == offer); - let expectedHours = this.get('offers.list.' + offer).get('hours'); + let ddahId = this.get("ddahs.list").findKey( + ddah => ddah.get("offer") == offer + ); + let expectedHours = this.get("offers.list." + offer).get("hours"); if (this.isDdahValidForSubmission(ddahId, expectedHours)) { - let signature = window.prompt('Please type a signature to complete the submission:'); + let signature = window.prompt( + "Please type a signature to complete the submission:" + ); if (signature && signature.trim()) { fetch.submitDdah(signature, parseInt(ddahId)); @@ -1200,72 +1337,80 @@ class AppState { } updateDdah(offer) { - let ddahId = this.get('ddahs.list').findKey(ddah => ddah.get('offer') == offer); + let ddahId = this.get("ddahs.list").findKey( + ddah => ddah.get("offer") == offer + ); // process ddah for format - let ddah = this.get('ddahWorksheet'); + let ddah = this.get("ddahWorksheet"); let updates = { - instructor_id: ddah.get('supervisorId'), - optional: ddah.get('optional'), - categories: ddah.get('categories').toJS(), - trainings: ddah.get('trainings').toJS(), + instructor_id: ddah.get("supervisorId"), + optional: ddah.get("optional"), + categories: ddah.get("categories").toJS(), + trainings: ddah.get("trainings").toJS(), allocations: ddah - .get('allocations') + .get("allocations") // remove empty rows .filter( allocation => - allocation.get('units') || - (allocation.get('type') && allocation.get('type').trim()) || - allocation.get('time') || - allocation.get('duty') + allocation.get("units") || + (allocation.get("type") && + allocation.get("type").trim()) || + allocation.get("time") || + allocation.get("duty") ) .map(allocation => ({ - id: allocation.get('id'), - num_unit: allocation.get('units'), - unit_name: allocation.get('type'), - minutes: allocation.get('time'), - duty_id: allocation.get('duty'), + id: allocation.get("id"), + num_unit: allocation.get("units"), + unit_name: allocation.get("type"), + minutes: allocation.get("time"), + duty_id: allocation.get("duty") })) .toJS(), - scaling_learning: ddah.get('requiresTraining'), + scaling_learning: ddah.get("requiresTraining") }; - fetch.updateDdah(ddahId, updates).then(this.set('ddahWorksheet.changed', false)); + fetch + .updateDdah(ddahId, updates) + .then(this.set("ddahWorksheet.changed", false)); } - updateSessionPay(session, pay, dbUpdate=false) { - if(dbUpdate) - fetch.updateSessionPay(session, pay); - else - this.set('sessions.list.'+session+'.pay', pay); + updateSessionPay(session, pay, dbUpdate = false) { + if (dbUpdate) { + fetch.updateSessionPay(session, pay); + } else { + this.set("sessions.list." + session + ".pay", pay); + } } updateTemplate(template) { // process ddah for format - let ddah = this.get('ddahWorksheet'); + let ddah = this.get("ddahWorksheet"); let updates = { - optional: ddah.get('optional'), - categories: ddah.get('categories').toJS(), - trainings: ddah.get('trainings').toJS(), + optional: ddah.get("optional"), + categories: ddah.get("categories").toJS(), + trainings: ddah.get("trainings").toJS(), allocations: ddah - .get('allocations') + .get("allocations") .map(allocation => ({ - id: allocation.get('id'), - num_unit: allocation.get('units'), - unit_name: allocation.get('type'), - minutes: allocation.get('time'), - duty_id: allocation.get('duty'), + id: allocation.get("id"), + num_unit: allocation.get("units"), + unit_name: allocation.get("type"), + minutes: allocation.get("time"), + duty_id: allocation.get("duty") })) .toJS(), - scaling_learning: ddah.get('requiresTraining'), + scaling_learning: ddah.get("requiresTraining") }; - fetch.updateTemplate(template, updates).then(this.set('ddahWorksheet.changed', false)); + fetch + .updateTemplate(template, updates) + .then(this.set("ddahWorksheet.changed", false)); } withdrawOffers(offers) { if (offers.length == 0) { - this.alert('Error: No offer selected'); + this.alert("Error: No offer selected"); return; } diff --git a/app/javascript/cp/components/adminControlPanel.js b/app/javascript/cp/components/adminControlPanel.js index 03c09696..56c8c407 100644 --- a/app/javascript/cp/components/adminControlPanel.js +++ b/app/javascript/cp/components/adminControlPanel.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; import { Grid, ButtonToolbar, @@ -7,18 +7,135 @@ import { Button, OverlayTrigger, Popover, + Glyphicon, + Modal, Form, FormGroup, ControlLabel, - FormControl, -} from 'react-bootstrap'; + FormControl +} from "react-bootstrap"; -import { TableMenu } from './tableMenu.js'; -import { Table } from './table.js'; -import { ImportMenu } from './importMenu.js'; -import { SessionsForm } from './sessionsForm.js'; +import { TableMenu } from "./tableMenu.js"; +import { Table } from "./table.js"; +import { ImportMenu } from "./importMenu.js"; +import { SessionsForm } from "./sessionsForm.js"; -const getCheckboxElements = () => document.getElementsByClassName('offer-checkbox'); +class EditFieldDialog extends React.Component { + constructor(props) { + super(props); + this.fieldName = props.field; + this.state = { + field: props.offer.get(this.fieldName), + origField: props.offer.get(this.fieldName) + }; + this.setField = this.setField.bind(this); + this.cancelClick = this.cancelClick.bind(this); + this.onSave = this.onSave.bind(this); + } + onSave(val) { + this.props.onHide(); + // if we save, our "original" value is now the currently + // saved one. + this.setState({ origField: val }); + (this.props.onSave || (() => {}))(val); + } + setField(val) { + this.setState({ field: val }); + } + cancelClick() { + this.props.onHide(); + this.setState({ field: this.state.origField }); + } + render() { + const { show, onSave, onHide, header } = this.props; + const [fieldVal, setFieldVal] = [this.state.field, this.setField]; + const origFieldVal = this.state.origField; + + return ( + + + {header} + + + setFieldVal(e.currentTarget.value)} + />{" "} + {fieldVal != origFieldVal && ( + + Change from{" "} + + {origFieldVal} + {" "} + to{" "} + + {fieldVal} + + + )} + + + + + + + ); + } +} + +class EditFieldIcon extends React.Component { + constructor(props) { + super(props); + this.state = { + dialogShow: false + }; + + this.setDialogShow = this.setDialogShow.bind(this); + } + setDialogShow(state) { + this.setState({ dialogShow: state }); + } + render() { + const { offer, hidden, onSave, field, header } = this.props; + const setDialogShow = this.setDialogShow; + const dialogShow = this.state.dialogShow; + + if (hidden) { + return null; + } + return ( + +
setDialogShow(true)} + > + +
+ this.setDialogShow(false)} + onSave={onSave} + /> +
+ ); + } +} + +const getCheckboxElements = () => + document.getElementsByClassName("offer-checkbox"); const getSelectedOffers = () => Array.prototype.filter @@ -39,6 +156,15 @@ class AdminControlPanel extends React.Component { } } + updateOffer(offer, attrs) { + attrs.offer_id = offer; + this.props.appState.setOfferDetails(attrs); + } + + updateApplicant(attrs) { + this.props.appState.setApplicantDetails(attrs); + } + componentWillMount() { this.selectThisTab(); } @@ -49,18 +175,17 @@ class AdminControlPanel extends React.Component { render() { const role = this.props.appState.getSelectedUserRole(); - let nullCheck = this.props.appState.isOffersListNull() || - (role == 'cp_admin' && this.props.appState.isSessionsListNull()); + (role == "cp_admin" && this.props.appState.isSessionsListNull()); if (nullCheck) { return
; } let fetchCheck = this.props.appState.fetchingOffers() || - (role == 'cp_admin' && this.props.appState.fetchingSessions()); - let cursorStyle = { cursor: fetchCheck ? 'progress' : 'auto' }; + (role == "cp_admin" && this.props.appState.fetchingSessions()); + let cursorStyle = { cursor: fetchCheck ? "progress" : "auto" }; this.config = [ { @@ -70,12 +195,17 @@ class AdminControlPanel extends React.Component { defaultChecked={false} id="header-checkbox" onClick={event => - Array.prototype.forEach.call(getCheckboxElements(), box => { - box.checked = event.target.checked; - })} + Array.prototype.forEach.call( + getCheckboxElements(), + box => { + box.checked = event.target.checked; + } + ) + } /> ), - data: p => + headerNoSort: true, + data: p => ( { + for (let box of getCheckboxElements()) { if ( !first && - (box.id == p.offerId || box.id == this.lastClicked) + (box.id == p.offerId || + box.id == this.lastClicked) ) { // starting box first = true; box.checked = true; } else if (first && !last) { // box is in range - if (box.id == p.offerId || box.id == this.lastClicked) { + if ( + box.id == p.offerId || + box.id == this.lastClicked + ) { // ending box last = true; } box.checked = true; } - }); + } } this.lastClicked = p.offerId; }} - />, + /> + ), - style: { width: 0.01, textAlign: 'center' }, + style: { textAlign: "center" } }, { - header: 'Last Name', - data: p => p.offer.get('lastName'), - sortData: p => p.get('lastName'), - - style: { width: 0.08 }, + header: "Last Name", + data: p => ( +
+ {p.offer.get("lastName")} + { + this.updateApplicant({ + id: p.offer.get("applicantId"), + last_name: val + }); + }} + /> +
+ ), + sortData: p => p.get("lastName") }, { - header: 'First Name', - data: p => p.offer.get('firstName'), - sortData: p => p.get('firstName'), - - style: { width: 0.08 }, + header: "First Name", + data: p => ( +
+ {p.offer.get("firstName")} + { + this.updateApplicant({ + id: p.offer.get("applicantId"), + first_name: val + }); + }} + /> +
+ ), + sortData: p => p.get("firstName") }, { - header: 'Email', - data: p => p.offer.get('email'), - sortData: p => p.get('email'), - - style: { width: 0.16 }, + header: "Email", + data: p => ( +
+ {p.offer.get("email")} + { + this.updateApplicant({ + id: p.offer.get("applicantId"), + email: val + }); + }} + /> +
+ ), + sortData: p => p.get("email"), + style: { maxWidth: "15vw", overflow: "hidden" } }, { - header: 'Student Number', - data: p => p.offer.get('studentNumber'), - sortData: p => p.get('studentNumber'), - - style: { width: 0.07 }, + header: "Student Number", + data: p => ( +
+ {p.offer.get("studentNumber")} + { + this.updateApplicant({ + id: p.offer.get("applicantId"), + student_number: val + }); + }} + /> +
+ ), + sortData: p => p.get("studentNumber") }, { - header: 'Position', - data: p => p.offer.get('course'), - sortData: p => p.get('course'), + header: "Position", + data: p => p.offer.get("course"), + sortData: p => p.get("course"), - filterLabel: 'Position', + filterLabel: "Position", filterCategories: this.props.appState.getPositions(), // filter out offers not to that position filterFuncs: this.props.appState .getPositions() - .map(position => p => p.get('course') == position), - - style: { width: 0.1 }, + .map(position => p => p.get("course") == position) }, { - header: 'Hours', - data: p => p.offer.get('hours'), - sortData: p => p.get('hours'), - - style: { width: 0.03 }, + header: "Hours", + data: p => ( +
+ {p.offer.get("hours")} +
+ ), + sortData: p => p.get("hours") }, { - header: 'Status', - data: p => + header: "Status", + data: p => ( - {p.offer.get('status')} {p.offer.get('status') == 'Withdrawn' && + {p.offer.get("status")}  + {p.offer.get("status") == "Withdrawn" && ( +