diff --git a/app/assets/stylesheets/cp.scss b/app/assets/stylesheets/cp.scss index 610256f0..a7ec268b 100644 --- a/app/assets/stylesheets/cp.scss +++ b/app/assets/stylesheets/cp.scss @@ -4,7 +4,8 @@ --navbar-height: 71px; /* includes margins */ --table-menu-height: calc(34px + 1vh); --main-div-height: calc(100vh - var(--navbar-height) - var(--footer-height)); - --sessions-height: calc(100px + 1.5vh); + --min-row-height: 52px; /* assumes that header is two rows high */ + --sessions-height: calc(55px + 1.5vh); } html { @@ -100,24 +101,28 @@ footer div p { margin-left: 3vw; } -/* for Table in admin view */ #offers-grid table { margin: 0; } -#offers-grid .table-body { +/* for Table in admin view */ +#offers-grid .sessions ~ .table-container .table-body { /* note that this assumes that the header is at most one row high */ max-height: calc( - var(--main-div-height) - var(--table-menu-height) - var(--min-row-height) + var(--main-div-height) - var(--table-menu-height) - var(--min-row-height) - var(--sessions-height) ); - overflow: auto; } -#offers-grid .admin-table .table-body { +#offers-grid .table-body { /* note that this assumes that the header is at most one row high */ max-height: calc( - var(--main-div-height) - var(--table-menu-height) - var(--min-row-height) - var(--sessions-height) + var(--main-div-height) - var(--table-menu-height) - var(--min-row-height) ); + overflow: auto; +} + +#offers-grid .table-container th { + text-overflow: ellipsis; } #offers-grid .table-container td, @@ -127,20 +132,19 @@ footer div p { #offers-grid .sessions { margin: 0 0 1.5vh 0; - border: 1px solid #ddd; } -#offers-grid .nav-tabs { - background-color: #f5f5f5; +#offers-grid .sessions .panel-body { + height: calc(35px + 10px + 10px); + padding: 10px 15px; } -#offers-grid .tab-content { - height: calc(35px + 5px + 5px); - padding: 5px 15px; +#offers-grid .sessions #pay { + margin-left: 1vw; } -#offers-grid .sessions .input-group { - width: 150px; +#offers-grid .sessions #pay input { + width: 100px; } .highlightOnHover:hover { diff --git a/app/javascript/cp/appState.js b/app/javascript/cp/appState.js index 0c32bbe3..0ba8a1bd 100644 --- a/app/javascript/cp/appState.js +++ b/app/javascript/cp/appState.js @@ -17,6 +17,8 @@ const initialState = { selectedSortFields: [], selectedFilters: {}, + selectedSession: '', + /** DB data **/ offers: { fetching: 0, list: null }, sessions: { fetching: 0, list: null }, @@ -143,6 +145,10 @@ class AppState { return this.get('selectedFilters'); } + getSelectedSession() { + return this.get('selectedSession'); + } + getSorts() { return this.get('selectedSortFields'); } @@ -177,12 +183,16 @@ class AppState { this.set('selectedSortFields', sorts.delete(i)); } + selectSession(session) { + this.set('selectedSession', session); + } + setCurrentUserName(user) { - return this.set('user', user); + this.set('user', user); } setCurrentUserRole(role) { - return this.set('role', role); + this.set('role', role); } // toggle a filter on the offers table @@ -232,9 +242,43 @@ class AppState { return [this.get('offers.list'), this.get('sessions.list')].some(val => val == null); } + // email applicants email(offers) { let allOffers = this.getOffersList(); - fetch.email(offers.map(offer => allOffers.getIn([offer, 'email']))); + let emails = offers.map(offer => allOffers.getIn([offer, 'email'])); + + 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 + a.click(); + } + + // email mangled contract link to a single applicant + emailContract(offers) { + if (offers.length != 1) { + 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')) { + // offers does not have a contract link + this.alert( + 'Error: Offer to ' + + offer.get('lastName') + + ', ' + + offer.get('firstName') + + ' does not have an associated contract' + ); + return; + } + + var a = document.createElement('a'); + a.href = + 'mailto:' + offer.get('email') + '?body=Link%20to%20contract:%20' + offer.get('link'); + a.click(); } // check if offers are being fetched @@ -286,86 +330,39 @@ class AppState { } nag(offers) { - let pendingOffers = [], - allOffers = this.getOffersList(); - - for (var offer of offers) { - if (allOffers.getIn([offer, 'status']) == 'Pending') { - pendingOffers.push(parseInt(offer)); - } else { - // offers that are not 'pending' cannot be nagged about - this.alert( - 'Error: Offer to ' + - allOffers.getIn([offer, 'lastName']) + - ', ' + - allOffers.getIn([offer, 'firstName']) + - ' is not pending' - ); - } + if (offers.length == 0) { + this.alert('Error: No offer selected'); + return; } - fetch.nag(pendingOffers); + fetch.nag(offers.map(offer => parseInt(offer))); } print(offers) { - fetch.print(offers); + if (offers.length == 0) { + this.alert('Error: No offer selected'); + return; + } + + fetch.print(offers.map(offer => parseInt(offer))); } sendContracts(offers) { - let status, - unsentOffers = [], - allOffers = this.getOffersList(); - - for (var offer of offers) { - status = allOffers.getIn([offer, 'status']); - - switch (status) { - // can only send contracts that are unsent - case 'Unsent': - unsentOffers.push(parseInt(offer)); - break; - case 'Withdrawn': - this.alert( - 'Error: Cannot send contract to ' + - allOffers.getIn([offer, 'lastName']) + - ', ' + - allOffers.getIn([offer, 'firstName']) + - '. Offer was withdrawn.' - ); - break; - default: - this.alert( - 'Error: Contract has already been sent to ' + - allOffers.getIn([offer, 'lastName']) + - ', ' + - allOffers.getIn([offer, 'firstName']) - ); - } + if (offers.length == 0) { + this.alert('Error: No offer selected'); + return; } - fetch.sendContracts(unsentOffers); + fetch.sendContracts(offers.map(offer => parseInt(offer))); } setDdahAccepted(offers) { - let acceptedOffers = [], - allOffers = this.getOffersList(); - - for (var offer of offers) { - // can only accept DDAH form for accepted offers - if (allOffers.getIn([offer, 'status']) == 'Accepted') { - acceptedOffers.push(parseInt(offer)); - } else { - this.alert( - 'Error: Cannot accept DDAH form for ' + - allOffers.getIn([offer, 'lastName']) + - ', ' + - allOffers.getIn([offer, 'firstName']) + - '. Offer is not accepted.' - ); - } + if (offers.length == 0) { + this.alert('Error: No offer selected'); + return; } - fetch.setDdahAccepted(acceptedOffers); + fetch.setDdahAccepted(offers.map(offer => parseInt(offer))); } setFetchingOffersList(fetching, success) { @@ -405,25 +402,12 @@ class AppState { } setHrProcessed(offers) { - let acceptedOffers = [], - allOffers = this.getOffersList(); - - for (var offer of offers) { - // can only process contract for accepted offers - if (allOffers.getIn([offer, 'status']) == 'Accepted') { - acceptedOffers.push(parseInt(offer)); - } else { - this.alert( - 'Error: Cannot process contract for ' + - allOffers.getIn([offer, 'lastName']) + - ', ' + - allOffers.getIn([offer, 'firstName']) + - '. Offer is not accepted.' - ); - } + if (offers.length == 0) { + this.alert('Error: No offer selected'); + return; } - fetch.setHrProcessed(acceptedOffers); + fetch.setHrProcessed(offers.map(offer => parseInt(offer))); } setImporting(importing, success) { @@ -449,6 +433,18 @@ class AppState { } setSessionsList(list) { + let semesterOrder = ['Winter', 'Spring', 'Fall', 'Year']; + // sort sesions in order of most recent to least recent + list.sort((sessionA, sessionB) => { + if (sessionA.get('year') > sessionB.get('year')) { + return -1; + } + if (sessionA.get('year') < sessionB.get('year')) { + return 1; + } + return semesterOrder.indexOf(sessionA.get('semester')) - semesterOrder.indexOf(sessionB.get('semester')); + }); + this.set('sessions.list', list); } @@ -460,27 +456,17 @@ class AppState { fetch.showContractHr(offer); } + updateSessionPay(session, pay) { + fetch.updateSessionPay(session, pay); + } + withdrawOffers(offers) { - let status, - pendingOffers = [], - allOffers = this.getOffersList(); - - for (var offer of offers) { - // cannot withdraw unsent offers - if (allOffers.getIn([offer, 'status']) == 'Unsent') { - this.alert( - 'Error: Offer to ' + - allOffers.getIn([offer, 'lastName']) + - ', ' + - allOffers.getIn([offer, 'firstName']) + - ' has not been sent' - ); - } else { - pendingOffers.push(parseInt(offer)); - } + if (offers.length == 0) { + this.alert('Error: No offer selected'); + return; } - fetch.withdrawOffers(pendingOffers); + fetch.withdrawOffers(offers); } } diff --git a/app/javascript/cp/components/controlPanel.js b/app/javascript/cp/components/controlPanel.js index 05fa320d..69a94b01 100644 --- a/app/javascript/cp/components/controlPanel.js +++ b/app/javascript/cp/components/controlPanel.js @@ -58,26 +58,31 @@ class ControlPanel extends React.Component { className="offer-checkbox" id={p.offerId} onClick={event => { - // range selection using shift key - if (this.lastClicked && event.shiftKey) { - let first = false, last = false; - - Array.prototype.forEach.call(getCheckboxElements(), box => { - if (!first && (box.id == p.offerId || box.id == this.lastClicked)) { - first = true; - box.checked = true; - } - - if (first && !last) { - if (box.id == p.offerId || box.id == this.lastClicked) { - last = true; - } - box.checked = true; - } - }); - } - - this.lastClicked = p.offerId; + // range selection using shift key (range is from current box (offerId) to last-clicked box + if (this.lastClicked && event.shiftKey) { + let first = false, + last = false; + + Array.prototype.forEach.call(getCheckboxElements(), box => { + if ( + !first && + (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) { + // ending box + last = true; + } + box.checked = true; + } + }); + } + + this.lastClicked = p.offerId; }} />, @@ -88,28 +93,28 @@ class ControlPanel extends React.Component { data: p => p.offer.get('lastName'), sortData: p => p.get('lastName'), - style: { width: 0.09 }, + style: { width: 0.08 }, }, { header: 'First Name', data: p => p.offer.get('firstName'), sortData: p => p.get('firstName'), - style: { width: 0.06 }, + style: { width: 0.08 }, }, { header: 'Email', data: p => p.offer.get('email'), sortData: p => p.get('email'), - style: { width: 0.13 }, + style: { width: 0.16 }, }, { header: 'Student Number', data: p => p.offer.get('studentNumber'), sortData: p => p.get('studentNumber'), - style: { width: 0.06 }, + style: { width: 0.07 }, }, { header: 'Position', @@ -123,7 +128,7 @@ class ControlPanel extends React.Component { .getPositions() .map(position => p => p.get('position') == position), - style: { width: 0.08 }, + style: { width: 0.1 }, }, { header: 'Hours', @@ -147,7 +152,7 @@ class ControlPanel extends React.Component { 'Withdrawn', ].map(status => p => p.get('status') == status), - style: { width: 0.05 }, + style: { width: 0.07 }, }, { header: 'Contract Send Date', @@ -172,14 +177,14 @@ class ControlPanel extends React.Component { : '', sortData: p => p.get('sentAt'), - style: { width: 0.08 }, + style: { width: 0.1 }, }, { header: 'Nag Count', data: p => (p.offer.get('nagCount') ? p.offer.get('nagCount') : ''), sortData: p => p.get('nagCount'), - style: { width: 0.04 }, + style: { width: 0.05 }, }, { header: 'HRIS Status', @@ -192,7 +197,7 @@ class ControlPanel extends React.Component { ['Processed', 'Printed'].map(status => p => p.get('hrStatus') == status) ), - style: { width: 0.05 }, + style: { width: 0.07 }, }, { header: 'Printed Date', @@ -217,15 +222,13 @@ class ControlPanel extends React.Component { p.get('ddahStatus') == status ) ), - - style: { width: 0.06 }, }, ]; return ( {role == 'admin' && } - + {role == 'admin' && } {role == 'admin' && } @@ -249,9 +252,16 @@ class ControlPanel extends React.Component { this.props.appState.getOffersList()} + getOffers={() => { + let session = this.props.appState.getSelectedSession(); + if (session != '') { + return this.props.appState + .getOffersList() + .filter(offer => offer.get('session') == session); + } + return this.props.appState.getOffersList(); + }} getSelectedSortFields={() => this.props.appState.getSorts()} getSelectedFilters={() => this.props.appState.getFilters()} /> @@ -278,7 +288,10 @@ const OffersMenu = props => const CommMenu = props => - props.appState.email(getSelectedOffers())}>Email + props.appState.email(getSelectedOffers())}>Email [blank] + props.appState.emailContract(getSelectedOffers())}> + Email [contract] + props.appState.nag(getSelectedOffers())}>Nag ; diff --git a/app/javascript/cp/components/navbar.js b/app/javascript/cp/components/navbar.js index f1d04610..971b0022 100644 --- a/app/javascript/cp/components/navbar.js +++ b/app/javascript/cp/components/navbar.js @@ -1,7 +1,16 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { Navbar, Nav, NavItem, NavDropdown, MenuItem } from 'react-bootstrap'; +import { + Navbar, + Nav, + NavItem, + NavDropdown, + MenuItem, + FormGroup, + ControlLabel, + FormControl, +} from 'react-bootstrap'; /*** Navbar components ***/ @@ -34,7 +43,9 @@ const Auth = props => - props.appState.setCurrentUserRole('admin')}> + props.appState.setCurrentUserRole('admin')}> Switch to admin role @@ -46,9 +57,7 @@ const Auth = props => Switch to inst role - - Logout - + Logout ; /*** Navbar ***/ diff --git a/app/javascript/cp/components/sessionsForm.js b/app/javascript/cp/components/sessionsForm.js index 542fcc83..120056ca 100644 --- a/app/javascript/cp/components/sessionsForm.js +++ b/app/javascript/cp/components/sessionsForm.js @@ -1,41 +1,79 @@ import React from 'react'; -import { Panel, Tabs, Tab, Form, InputGroup, FormGroup, FormControl } from 'react-bootstrap'; +import { Panel, Form, FormGroup, ControlLabel, InputGroup, FormControl } from 'react-bootstrap'; class SessionsForm extends React.Component { render() { return ( -
- - Sessions} disabled /> - {this.props.appState.getSessionsList().map(session => - -
- Start date: {new Date(session.get('startDate')).toDateString()}   - End date: {new Date(session.get('endDate')).toDateString()}   - Pay:  - - - $ - (this.pay = input)} - pattern="[0-9]+\.[0-9]{2}" - /> - - - -
- )} -
-
+ +
{ + event.preventDefault(); + if ( + this.pay.value != + this.props.appState.getSessionsList().getIn([this.session.value, 'pay']) + ) { + this.props.appState.updateSessionPay( + this.session.value, + this.pay.value + ); + } + }}> + + Session:  + { + this.session = ref; + }} + onChange={event => { + this.props.appState.selectSession(event.target.value); + let pay = this.props.appState + .getSessionsList() + .getIn([event.target.value, 'pay']); + this.pay.value = pay ? pay : null; + }}> + + {this.props.appState.getSessionsList().map((session, sessionId) => + + )} + + + + Pay:  + + $ + { + this.pay = ref; + }} + onBlur={event => { + if ( + event.target.value != + this.props.appState + .getSessionsList() + .getIn([this.session.value, 'pay']) + ) { + this.props.appState.updateSessionPay( + this.session.value, + event.target.value + ); + } + }} + /> + + + +
); } } diff --git a/app/javascript/cp/fetch.js b/app/javascript/cp/fetch.js index e51f7dbf..1c20e6c2 100644 --- a/app/javascript/cp/fetch.js +++ b/app/javascript/cp/fetch.js @@ -4,9 +4,9 @@ import { appState } from './appState.js'; /* General helpers */ -function defaultFailure(resp) { - appState.notify('Action Failed: ' + resp.statusText); - return Promise.reject(resp); +function defaultFailure(text) { + appState.alert('Action Failed: ' + text); + return Promise.reject(); } // extract and display a message which is sent in the (JSON) body of a response @@ -20,64 +20,87 @@ function showMessageInJsonBody(resp) { } } -function fetchHelper(URL, init, success, failure = defaultFailure) { - return fetch(URL, init) - .then(function(response) { - if (response.ok) { - return success(response); +function fetchHelper(URL, init) { + return fetch(URL, init).then( + function(resp) { + if (resp.ok) { + return Promise.resolve(resp); } - return failure(response); + return Promise.reject(resp); + }, + function(error) { + appState.alert('' + init.method + ' error ' + URL + ': ' + error.message); + return Promise.reject(error); + } + ); +} + +// fetching for 'can-*' batch methods +function fetchCheckHelper(URL, body) { + return fetch(URL, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + }, + method: 'POST', + body: JSON.stringify(body), + }) + .then(function(resp) { + if (resp.ok || resp.status == 404) { + return Promise.resolve(resp); + } + return Promise.reject(resp); }) .catch(function(error) { - appState.notify('Error: ' + URL + ' ' + error.message); + appState.alert('' + init.method + ' error ' + URL + ': ' + error.message); return Promise.reject(error); }); } -function getHelper(URL, success, failure) { - let init = { +function getHelper(URL) { + return fetchHelper(URL, { headers: { Accept: 'application/json', }, method: 'GET', - }; - - return fetchHelper(URL, init, success, failure); + }); } -function postHelper(URL, body, success, failure) { - let init = { +function postHelper(URL, body) { + return fetchHelper(URL, { headers: { Accept: 'application/json', 'Content-Type': 'application/json; charset=utf-8', }, method: 'POST', body: JSON.stringify(body), - }; - - return fetchHelper(URL, init, success, failure); + }); } -function deleteHelper(URL, success, failure) { - return fetchHelper(URL, { method: 'DELETE' }, success, failure); +function deleteHelper(URL) { + return fetchHelper(URL, { method: 'DELETE' }); } -function putHelper(URL, body, success, failure) { - let init = { +function putHelper(URL, body) { + return fetchHelper(URL, { headers: { 'Content-Type': 'application/json; charset=utf-8', }, method: 'PUT', body: JSON.stringify(body), - }; - - return fetchHelper(URL, init, success, failure); + }); } /* Resource GETters */ -const getOffers = () => getHelper('/offers', resp => resp.json()).then(onFetchOffersSuccess); -const getSessions = () => getHelper('/sessions', resp => resp.json()).then(onFetchSessionsSuccess); +const getOffers = () => + getHelper('/offers').then(resp => resp.json()).then(onFetchOffersSuccess).catch(defaultFailure); + +const getSessions = () => + getHelper('/sessions') + .then(resp => resp.json()) + .then(onFetchSessionsSuccess) + .catch(defaultFailure); /* Success callbacks for resource GETters */ @@ -100,6 +123,7 @@ function onFetchOffersSuccess(resp) { ddahStatus: offer.ddah_status, sentAt: offer.send_date, printedAt: offer.print_time, + link: offer.link, }; }); @@ -152,72 +176,197 @@ function fetchAll() { function importAssignments() { appState.setImporting(true); - return postHelper( - '/import/locked-assignments', - {}, + postHelper('/import/locked-assignments', {}).then( () => { appState.setImporting(false, true); - fetchAll(); + + appState.setFetchingOffersList(true); + getOffers() + .then(offers => { + appState.setOffersList(fromJS(offers)); + appState.setFetchingOffersList(false, true); + }) + .catch(() => appState.setFetchingOffersList(false)); }, - showMessageInJsonBody - ).catch(() => appState.setImporting(false)); + resp => { + appState.setImporting(false); + showMessageInJsonBody(resp); + } + ); } // send CHASS offers data function importOffers(data) { appState.setImporting(true); - return postHelper('/import/offers', { chass_offers: data }, () => { - appState.setImporting(false, true); - fetchAll(); - }).catch(() => appState.setImporting(false)); -} + postHelper('/import/offers', { chass_offers: data }).then( + () => { + appState.setImporting(false, true); -// send contracts -function sendContracts(offers) { - return postHelper( - '/offers/send-contracts', - { offers: offers }, - fetchAll, - showMessageInJsonBody + appState.setFetchingOffersList(true); + getOffers() + .then(offers => { + appState.setOffersList(fromJS(offers)); + appState.setFetchingOffersList(false, true); + }) + .catch(() => appState.setFetchingOffersList(false)); + }, + resp => { + appState.setImporting(false); + showMessageInJsonBody(resp); + } ); } -// email applicants -function email(emails) { - let ref = - 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 - - var a = document.createElement('a'); - a.href = ref; - a.click(); +// send contracts +function sendContracts(offers) { + let validOffers = offers; + + // check which contracts can be sent + fetchCheckHelper('/offers/can-send-contract', { contracts: offers }) + .catch(defaultFailure) + .then(resp => { + if (resp.status == 404) { + // some contracts cannot be sent + return resp.json().then(res => { + let invalidOffers = res.invalid_offers; + invalidOffers.forEach(offer => { + appState.alert('Error: Cannot nag send contract for offer ' + offer); + // remove invalid offer(s) from offer list + validOffers.splice(validOffers.indexOf(offer), 1); + }); + + if (validOffers.length == 0) { + return Promise.reject(); + } + }, defaultFailure); + } + }) + // send offers to valid offers + .then(() => postHelper('/offers/send-contracts', { offers: offers })) + .then(() => { + appState.setFetchingOffersList(true); + getOffers() + .then(offers => { + appState.setOffersList(fromJS(offers)); + appState.setFetchingOffersList(false, true); + }) + .catch(() => appState.setFetchingOffersList(false)); + }); } // nag applicants function nag(offers) { - return postHelper('/offers/nag', { contracts: offers }, fetchAll, showMessageInJsonBody); + let validOffers = offers; + + // check which applicants can be nagged + fetchCheckHelper('/offers/can-nag', { contracts: offers }) + .catch(defaultFailure) + .then(resp => { + if (resp.status == 404) { + // some contracts cannot be sent + return resp.json().then(res => { + let invalidOffers = res.invalid_offers; + invalidOffers.forEach(offer => { + appState.alert('Error: Cannot nag applicant about offer ' + offer); + // remove invalid offer(s) from offer list + validOffers.splice(validOffers.indexOf(offer), 1); + }); + + if (validOffers.length == 0) { + return Promise.reject(); + } + }, defaultFailure); + } + }) + // nag valid offers + .then(() => postHelper('/offers/nag', { contracts: validOffers })) + .then(() => { + appState.setFetchingOffersList(true); + getOffers() + .then(offers => { + appState.setOffersList(fromJS(offers)); + appState.setFetchingOffersList(false, true); + }) + .catch(() => appState.setFetchingOffersList(false)); + }); } // mark contracts as hr_processed function setHrProcessed(offers) { - return putHelper( - '/offers/batch-update', - { offers: offers, hr_status: 'Processed' }, - fetchAll, - showMessageInJsonBody - ); + let validOffers = offers; + + // check which offers can be marked as hr_processed + fetchCheckHelper('/offers/can-hr-update', { offers: offers }) + .catch(defaultFailure) + .then(resp => { + if (resp.status == 404) { + // some offers cannot be updated + return resp.json().then(res => { + let invalidOffers = res.invalid_offers; + invalidOffers.forEach(offer => { + appState.alert( + 'Error: Cannot mark offer ' + offer + ' as HR processed' + ); + // remove invalid offer(s) from offer list + validOffers.splice(validOffers.indexOf(offer), 1); + }); + + if (validOffers.length == 0) { + return Promise.reject(); + } + }, defaultFailure); + } + }) + // update valid offers + .then(() => putHelper('/offers/batch-update', { offers: offers, hr_status: 'Processed' })) + .then(() => { + appState.setFetchingOffersList(true); + getOffers() + .then(offers => { + appState.setOffersList(fromJS(offers)); + appState.setFetchingOffersList(false, true); + }) + .catch(() => appState.setFetchingOffersList(false)); + }); } // mark contracts as ddah_accepted function setDdahAccepted(offers) { - return putHelper( - '/offers/batch-update', - { offers: offers, ddah_status: 'Accepted' }, - fetchAll, - showMessageInJsonBody - ); + let validOffers = offers; + + // check which offers can be marked as ddah_accepted + fetchCheckHelper('/offers/can-ddah-update', { offers: offers }) + .catch(defaultFailure) + .then(resp => { + if (resp.status == 404) { + // some offers cannot be updated + return resp.json().then(res => { + let invalidOffers = res.invalid_offers; + invalidOffers.forEach(offer => { + appState.alert( + 'Error: Cannot mark offer ' + offer + ' as DDAH accepted' + ); + // remove invalid offer(s) from offer list + validOffers.splice(validOffers.indexOf(offer), 1); + }); + if (validOffers.length == 0) { + return Promise.reject(); + } + }, defaultFailure); + } + }) + // update valid offers + .then(() => putHelper('/offers/batch-update', { offers: offers, ddah_status: 'Accepted' })) + .then(() => { + appState.setFetchingOffersList(true); + getOffers() + .then(offers => { + appState.setOffersList(fromJS(offers)); + appState.setFetchingOffersList(false, true); + }) + .catch(() => appState.setFetchingOffersList(false)); + }); } // show the contract for this offer in a new window, as an applicant would see it @@ -227,66 +376,113 @@ function showContractApplicant(offer) { // show the contract for this offer in a new window, as HR would see it function showContractHr(offer) { - return postHelper('/offers/print', { contracts: [offer], update: false }, resp => - resp.blob() - ).then(blob => { - let fileURL = URL.createObjectURL(blob); - let contractWindow = window.open(fileURL); - contractWindow.onclose = () => URL.revokeObjectURL(fileURL); - }); + postHelper('/offers/print', { contracts: [offer], update: false }) + .then(resp => resp.blob()) + .then(blob => { + let fileURL = URL.createObjectURL(blob); + let contractWindow = window.open(fileURL); + contractWindow.onclose = () => URL.revokeObjectURL(fileURL); + }) + .catch(defaultFailure); } // withdraw offers function withdrawOffers(offers) { // create an array of promises for each offer being withdrawn - return Promise.all( - offers.map(offer => - postHelper( - '/offers/' + offer + '/decision/withdraw', - {}, - resp => resp, - showMessageInJsonBody - ) + // force each promise to resolve so that we can see which failed + let promises = offers.map(offer => + postHelper('/offers/' + offer + '/decision/withdraw', {}).then( + resp => Promise.resolve(resp), + resp => Promise.resolve(resp) ) - ).then(fetchAll); + ); + + Promise.all(promises).then(responses => + responses.forEach(resp => { + if (resp.ok) { + appState.setFetchingOffersList(true); + getOffers() + .then(offers => { + appState.setOffersList(fromJS(offers)); + appState.setFetchingOffersList(false, true); + }) + .catch(() => appState.setFetchingOffersList(false)); + } else { + showMessageInJsonBody(resp); + } + }) + ); } // print contracts function print(offers) { - return postHelper( - '/offers/print', - { contracts: offers, update: true }, - resp => resp.blob(), - showMessageInJsonBody - ).then(blob => { + let validOffers = offers; + + // check which contracts can be printed + let printPromise = fetchCheckHelper('/offers/can-print', { contracts: offers }) + .catch(defaultFailure) + .then(resp => { + if (resp.status == 404) { + // some contracts cannot be printed + return resp.json().then(res => { + let invalidOffers = res.invalid_offers; + invalidOffers.forEach(offer => { + appState.alert('Error: Cannot print contract for offer ' + offer); + // remove invalid offer(s) from offer list + validOffers.splice(validOffers.indexOf(offer), 1); + }); + + if (validOffers.length == 0) { + return Promise.reject(); + } + }, defaultFailure); + } + }) + // print valid offers + .then(() => postHelper('/offers/print', { contracts: validOffers, update: true })) + .then(resp => resp.blob().catch(defaultFailure)); + + printPromise.then(blob => { let fileURL = URL.createObjectURL(blob); let pdfWindow = window.open(fileURL); pdfWindow.onclose = () => URL.revokeObjectURL(fileURL); pdfWindow.document.onload = pdfWindow.print(); + }); - return fetchAll(); + printPromise.then(() => { + appState.setFetchingOffersList(true); + getOffers() + .then(offers => { + appState.setOffersList(fromJS(offers)); + appState.setFetchingOffersList(false, true); + }) + .catch(() => appState.setFetchingOffersList(false)); }); } -/* - function updateSession(input, id){ - let data = {pay: input.value}; - let init = { - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - method: 'PUT', - body: JSON.stringify(data) - }; - fetchHelper("/sessions/"+id, init, "Pay updated"); - }*/ +// change session pay +function updateSessionPay(session, pay) { + putHelper('/sessions/' + session, { pay: pay }).then( + () => { + appState.setFetchingSessionsList(true); + getSessions() + .then(sessions => { + appState.setSessionsList(fromJS(sessions)); + appState.setFetchingSessionsList(false, true); + }) + .catch(() => appState.setFetchingSessionsList(false)); + }, + resp => { + showMessageInJsonBody(resp); + } + ); +} export { fetchAll, importOffers, importAssignments, sendContracts, - email, nag, setHrProcessed, setDdahAccepted, @@ -294,4 +490,5 @@ export { showContractHr, withdrawOffers, print, + updateSessionPay, }; diff --git a/app/javascript/tapp/appState.js b/app/javascript/tapp/appState.js index 4e968246..fa75f1a9 100644 --- a/app/javascript/tapp/appState.js +++ b/app/javascript/tapp/appState.js @@ -569,7 +569,7 @@ class AppState { addInstructor(courseId, instructorId) { let val = this.getCoursesList().get(courseId.toString()).get('instructors').toJS(); val.push(parseInt(instructorId)); - fetch.updateCourse(courseId, { instructors: val }, 'instructors'); + fetch.updateCourse(courseId, { instructors: val }); } // check if any data is being fetched @@ -833,7 +833,7 @@ class AppState { // get a sorted list of course codes getCourseCodes() { - return this.getCoursesList().valueSeq().map(course => course.get('code')).sort(); + return this.getCoursesList().map(course => course.get('code')).flip().keySeq().sort(); } getCourseCodeById(course) { @@ -886,7 +886,7 @@ class AppState { importEnrolment(data) { fetch.importEnrolment(data); } - + importing() { return this.get('importing') > 0; } @@ -934,7 +934,7 @@ class AppState { // thinks they are strings let val = this.getCoursesList().get(courseId.toString()).get('instructors').toJS(); val.splice(index, 1); - fetch.updateCourse(courseId, { instructors: val }, 'instructors'); + fetch.updateCourse(courseId, { instructors: val }); } setApplicantsList(list) { @@ -1073,9 +1073,9 @@ class AppState { fetch.unlockAssignment(applicant, assignment); } - updateCourse(courseId, val, props) { + updateCourse(courseId, val, attr) { let data = {}; - switch (props) { + switch (attr) { case 'estimatedPositions': data['estimated_count'] = val; break; @@ -1098,7 +1098,7 @@ class AppState { data['num_waitlisted'] = val; break; } - fetch.updateCourse(courseId, data, props); + fetch.updateCourse(courseId, data); } } diff --git a/app/javascript/tapp/fetch.js b/app/javascript/tapp/fetch.js index e823dbf3..fda5321f 100644 --- a/app/javascript/tapp/fetch.js +++ b/app/javascript/tapp/fetch.js @@ -3,9 +3,9 @@ import { appState } from './appState.js'; /* General helpers */ -function defaultFailure(resp) { - appState.notify('Action Failed: ' + resp.statusText); - return Promise.reject(resp); +function defaultFailure(text) { + appState.alert('Action Failed: ' + text); + return Promise.reject(); } // extract and display a message which is sent in the (JSON) body of a response @@ -19,77 +19,85 @@ function showMessageInJsonBody(resp) { } } -function fetchHelper(URL, init, success, failure = defaultFailure) { +function fetchHelper(URL, init) { return fetch(URL, init) - .then(function(response) { - if (response.ok) { - // parse the body of the response as JSON - if (['GET', 'POST'].includes(init.method)) { - return response.json().then(resp => success(resp)); - } - - return success(response); + .then(function(resp) { + if (resp.ok) { + return Promise.resolve(resp); } - - return failure(response); + return Promise.reject(resp); }) .catch(function(error) { - appState.notify('Error: ' + URL + ' ' + error.message); + appState.alert('' + init.method + ' error ' + URL + ': ' + error.message); return Promise.reject(error); }); } -function getHelper(URL, success, failure) { - let init = { +function getHelper(URL) { + return fetchHelper(URL, { headers: { Accept: 'application/json', }, method: 'GET', - }; - - return fetchHelper(URL, init, success, failure); + }); } -function postHelper(URL, body, success, failure) { - let init = { +function postHelper(URL, body) { + return fetchHelper(URL, { headers: { Accept: 'application/json', 'Content-Type': 'application/json; charset=utf-8', }, method: 'POST', body: JSON.stringify(body), - }; - - return fetchHelper(URL, init, success, failure); + }); } -function deleteHelper(URL, success, failure) { - return fetchHelper(URL, { method: 'DELETE' }, success, failure); +function deleteHelper(URL) { + return fetchHelper(URL, { method: 'DELETE' }); } -function putHelper(URL, body, success, failure) { - let init = { +function putHelper(URL, body) { + return fetchHelper(URL, { headers: { 'Content-Type': 'application/json; charset=utf-8', }, method: 'PUT', body: JSON.stringify(body), - }; - - return fetchHelper(URL, init, success, failure); + }); } /* Resource GETters */ -const getApplicants = () => getHelper('/applicants', onFetchApplicantsSuccess); - -const getApplications = () => getHelper('/applications', onFetchApplicationsSuccess); - -const getCourses = () => getHelper('/positions', onFetchCoursesSuccess); - -const getAssignments = () => getHelper('/assignments', onFetchAssignmentsSuccess); - -const getInstructors = () => getHelper('/instructors', onFetchInstructorsSuccess); +const getApplicants = () => + getHelper('/applicants') + .then(resp => resp.json()) + .then(onFetchApplicantsSuccess) + .catch(defaultFailure); + +const getApplications = () => + getHelper('/applications') + .then(resp => resp.json()) + .then(onFetchApplicationsSuccess) + .catch(defaultFailure); + +const getCourses = () => + getHelper('/positions') + .then(resp => resp.json()) + .then(onFetchCoursesSuccess) + .catch(defaultFailure); + +const getAssignments = () => + getHelper('/assignments') + .then(resp => resp.json()) + .then(onFetchAssignmentsSuccess) + .catch(defaultFailure); + +const getInstructors = () => + getHelper('/instructors') + .then(resp => resp.json()) + .then(onFetchInstructorsSuccess) + .catch(defaultFailure); /* Success callbacks for resource GETters */ @@ -298,158 +306,161 @@ function fetchAll() { // create a new assignment function postAssignment(applicant, course, hours) { - appState.setFetchingAssignmentsList(true); - - return postHelper( - '/applicants/' + applicant + '/assignments', - { position_id: course, hours: hours }, - getAssignments - ) - .then(assignments => { - appState.setAssignmentsList(assignments); - appState.setFetchingAssignmentsList(false, true); - }) - .catch(() => appState.setFetchingAssignmentsList(false)); + postHelper('/applicants/' + applicant + '/assignments', { + position_id: course, + hours: hours, + }).then(() => { + appState.setFetchingAssignmentsList(true); + getAssignments() + .then(assignments => { + appState.setAssignmentsList(assignments); + appState.setFetchingAssignmentsList(false, true); + }) + .catch(() => appState.setFetchingAssignmentsList(false)); + }); } // remove an assignment function deleteAssignment(applicant, assignment) { - appState.setFetchingAssignmentsList(true); - - return deleteHelper('/applicants/' + applicant + '/assignments/' + assignment, getAssignments) - .then(assignments => { - appState.setAssignmentsList(assignments); - appState.setFetchingAssignmentsList(false, true); - }) - .catch(() => appState.setFetchingAssignmentsList(false)); + deleteHelper('/applicants/' + applicant + '/assignments/' + assignment).then(() => { + appState.setFetchingAssignmentsList(true); + getAssignments() + .then(assignments => { + appState.setAssignmentsList(assignments); + appState.setFetchingAssignmentsList(false, true); + }) + .catch(() => appState.setFetchingAssignmentsList(false)); + }); } // add/update the notes for an applicant function noteApplicant(applicant, notes) { - appState.setFetchingApplicantsList(true); - - return putHelper('/applicants/' + applicant, { commentary: notes }, getApplicants) - .then(applicants => { - appState.setApplicantsList(applicants); - appState.setFetchingApplicantsList(false, true); - }) - .catch(() => appState.setFetchingApplicantsList(false)); + putHelper('/applicants/' + applicant, { commentary: notes }).then(() => { + appState.setFetchingApplicantsList(true); + getApplicants() + .then(applicants => { + appState.setApplicantsList(applicants); + appState.setFetchingApplicantsList(false, true); + }) + .catch(() => appState.setFetchingApplicantsList(false)); + }); } // update the number of hours for an assignment function updateAssignmentHours(applicant, assignment, hours) { - appState.setFetchingAssignmentsList(true); - - return putHelper( - '/applicants/' + applicant + '/assignments/' + assignment, - { hours: hours }, - getAssignments - ) - .then(assignments => { - appState.setAssignmentsList(assignments); - appState.setFetchingAssignmentsList(false, true); - }) - .catch(() => appState.setFetchingAssignmentsList(false)); + putHelper('/applicants/' + applicant + '/assignments/' + assignment, { + hours: hours, + }).then(() => { + appState.setFetchingAssignmentsList(true); + getAssignments() + .then(assignments => { + appState.setAssignmentsList(assignments); + appState.setFetchingAssignmentsList(false, true); + }) + .catch(() => appState.setFetchingAssignmentsList(false)); + }); } // update attribute(s) of a course -function updateCourse(courseId, data, attr) { - appState.setFetchingCoursesList(true); - - return putHelper('/positions/' + courseId, data, getCourses) - .then(courses => { - appState.setCoursesList(courses); - appState.setFetchingCoursesList(false, true); - }) - .catch(() => appState.setFetchingCoursesList(false)); +function updateCourse(courseId, data) { + putHelper('/positions/' + courseId, data).then(() => { + appState.setFetchingCoursesList(true); + getCourses() + .then(courses => { + appState.setCoursesList(courses); + appState.setFetchingCoursesList(false, true); + }) + .catch(() => appState.setFetchingCoursesList(false)); + }); } // send CHASS data function importChass(data) { appState.setImporting(true); - return postHelper( - '/import/chass', - { chass_json: data }, + postHelper('/import/chass', { chass_json: data }).then( () => { appState.setImporting(false, true); fetchAll(); }, - showMessageInJsonBody - ).catch(() => appState.setImporting(false)); + resp => { + appState.setImporting(false); + showMessageInJsonBody(resp); + } + ); } // send enrolment data function importEnrolment(data) { appState.setImporting(true); - appState.setFetchingCoursesList(true); - return postHelper( - '/import/enrollment', - { enrollment_data: data }, - resp => { - showMessageInJsonBody(resp); + postHelper('/import/enrollment', { enrollment_data: data }).then( + () => { appState.setImporting(false, true); + + appState.setFetchingCoursesList(true); + getCourses() + .then(courses => { + appState.setCoursesList(courses); + appState.setFetchingCoursesList(false, true); + }) + .catch(() => appState.setFetchingCoursesList(false)); }, resp => { - showMessageInJsonBody(resp); appState.setImporting(false); + showMessageInJsonBody(resp); } - ) - .then(getCourses) - .then(courses => { - appState.setCoursesList(courses); - appState.setFetchingCoursesList(false, true); - }) - .catch(() => appState.setFetchingCoursesList(false)); + ); } // unlock a single assignment function unlockAssignment(applicant, assignment) { - appState.setFetchingAssignmentsList(true); - - return putHelper( - '/applicants/' + applicant + '/assignments/' + assignment, - { export_date: null }, - getAssignments - ) - .then(assignments => { - appState.setAssignmentsList(assignments); - appState.setFetchingAssignmentsList(false, true); - }) - .catch(() => appState.setFetchingAssignmentsList(false)); + putHelper('/applicants/' + applicant + '/assignments/' + assignment, { + export_date: null, + }).then(() => { + appState.setFetchingAssignmentsList(true); + getAssignments() + .then(assignments => { + appState.setAssignmentsList(assignments); + appState.setFetchingAssignmentsList(false, true); + }) + .catch(() => appState.setFetchingAssignmentsList(false)); + }); } // export offers from CHASS (locking the corresponding assignments) function exportOffers(round) { - appState.setFetchingAssignmentsList(true); + let exportPromise = fetchHelper('/export/chass/' + round, {}).catch(defaultFailure); let filename; - return ( - fetchHelper('/export/chass/' + round, {}, response => { + exportPromise + .then(resp => { // extract the filename from the response headers - filename = response.headers.get('Content-Disposition').match(/filename="(.*)"/)[1]; + filename = resp.headers.get('Content-Disposition').match(/filename="(.*)"/)[1]; // parse the response body as a blob - return response.blob(); + return resp.blob(); }) - // create a URL for the object body of the response - .then(blob => URL.createObjectURL(blob)) - .then(url => { - // associate the download with an anchor tag - var a = document.createElement('a'); - a.href = url; - a.download = filename; - // trigger a click -> download - a.click(); - URL.revokeObjectURL(url); - }) - .then(getAssignments) + // create a URL for the object body of the response + .then(blob => URL.createObjectURL(blob)) + .then(url => { + // associate the download with an anchor tag + var a = document.createElement('a'); + a.href = url; + a.download = filename; + // trigger a click -> download + a.click(); + URL.revokeObjectURL(url); + }); + + exportPromise.then(() => { + appState.setFetchingAssignmentsList(true); + getAssignments() .then(assignments => { appState.setAssignmentsList(assignments); appState.setFetchingAssignmentsList(false, true); }) - .catch(() => appState.setFetchingAssignmentsList(false)) - ); + .catch(() => appState.setFetchingAssignmentsList(false)); + }); } export {