From 72fb903127268e6219f19152dd25ef116baa64e6 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 17 Apr 2024 15:59:53 -0400 Subject: [PATCH 001/274] #5 --- deploy/cmds/get-env.sh | 50 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100755 deploy/cmds/get-env.sh diff --git a/deploy/cmds/get-env.sh b/deploy/cmds/get-env.sh new file mode 100755 index 0000000..0bfa804 --- /dev/null +++ b/deploy/cmds/get-env.sh @@ -0,0 +1,50 @@ +#! /bin/bash + +### +# Downloads env from google cloud secret manager +# Usage: ./cmds/get-env.sh [-f] [-l] +# -f: force overwrite of existing .env file +# -l: download to local dev directory +### + +set -e +CMDS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd $CMDS_DIR/.. + +source config.sh + +FORCE=false +LOCAL=false +ENV_PATH=".env" + +while getopts "fl" opt; do + case ${opt} in + f ) + FORCE=true + ;; + l ) + LOCAL=true + ;; + \? ) + echo "Invalid option: -$OPTARG" 1>&2 + exit 1 + ;; + esac +done + +if [ "$LOCAL" = true ]; then + if [ -d "$LOCAL_DEV_DIRECTORY" ]; then + mkdir -p "$LOCAL_DEV_DIRECTORY" + fi + ENV_PATH="$LOCAL_DEV_DIRECTORY/.env" +fi + +if [ -f "$ENV_PATH" ] && [ "$FORCE" != true ]; then + echo ".env file already exists. Use -f flag to overwrite." + exit 1 +fi + +gcloud secrets versions access latest --secret=itis-travel-env > $ENV_PATH + +echo "Env downloaded to deploy/$ENV_PATH" + From f4b3b98133e5adba73725b6b43322fe2c9b71e3b Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 17 Apr 2024 16:28:27 -0400 Subject: [PATCH 002/274] #4 --- deploy/cmds/get-reader-key.sh | 2 ++ deploy/cmds/get-writer-key.sh | 2 ++ deploy/cmds/init-local-dev.sh | 4 ++++ deploy/config.sh | 16 ++++++---------- src/api/index.js | 2 -- src/client/js/app-main.js | 2 -- src/client/js/components/readme.md | 1 - src/lib/cork/services/BaseService.js | 2 -- src/lib/cork/stores/AppStateStore.js | 1 - src/lib/serverConfig.js | 10 ++++------ 10 files changed, 18 insertions(+), 24 deletions(-) diff --git a/deploy/cmds/get-reader-key.sh b/deploy/cmds/get-reader-key.sh index b98f72d..b9442e4 100755 --- a/deploy/cmds/get-reader-key.sh +++ b/deploy/cmds/get-reader-key.sh @@ -8,4 +8,6 @@ set -e CMDS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" cd $CMDS_DIR/../.. +source ./deploy/config.sh + gcloud secrets versions access latest --secret=$GC_READER_KEY_SECRET > gc-reader-key.json diff --git a/deploy/cmds/get-writer-key.sh b/deploy/cmds/get-writer-key.sh index e184607..43e851f 100755 --- a/deploy/cmds/get-writer-key.sh +++ b/deploy/cmds/get-writer-key.sh @@ -8,4 +8,6 @@ set -e CMDS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" cd $CMDS_DIR/../.. +source ./deploy/config.sh + gcloud secrets versions access latest --secret=$GC_WRITER_KEY_SECRET > gc-writer-key.json diff --git a/deploy/cmds/init-local-dev.sh b/deploy/cmds/init-local-dev.sh index 6237d2e..cb6c20e 100755 --- a/deploy/cmds/init-local-dev.sh +++ b/deploy/cmds/init-local-dev.sh @@ -15,3 +15,7 @@ source ./config.sh touch ../gc-writer-key.json touch ../gc-reader-key.json + +./cmds/get-env.sh -f -l +./cmds/get-reader-key.sh +./cmds/get-writer-key.sh diff --git a/deploy/config.sh b/deploy/config.sh index d7bd905..b089001 100755 --- a/deploy/config.sh +++ b/deploy/config.sh @@ -30,15 +30,12 @@ APP_CONTAINER_PORT=3000 REPO_TAG=dev # Dependency tags/branches -# TODO: change these versions to match current LTS versions -NODE_TAG=18 -POSTGRES_TAG=15.3 +NODE_TAG=20 +POSTGRES_TAG=16 ADMINER_TAG=4.8.1 # for local dev only # Container Registery -# TODO: decide whether to use public or private container registry -CONTAINER_REG_ORG=gcr.io/ucdlib-pubreg # public -CONTAINER_REG_ORG=gcr.io/digital-ucdavis-edu # private +CONTAINER_REG_ORG=gcr.io/ucdlib-pubreg if [[ ! -z $LOCAL_BUILD ]]; then CONTAINER_REG_ORG='localhost/local-dev' fi @@ -77,11 +74,10 @@ JS_BUNDLES=( $CLIENT_DIR ) -# TODO: If using init/backup utilities, set the following # Google Cloud -GC_READER_KEY_SECRET="" # name of secret in secret manager for reading from bucket -GC_WRITER_KEY_SECRET="" # name of secret in secret manager for writing to bucket -GC_BACKUP_BUCKET="" # name of bucket that will be used for database backups +GC_READER_KEY_SECRET="itis-backup-reader-key" # name of secret in secret manager for reading from bucket +GC_WRITER_KEY_SECRET="itis-backup-writer-key" # name of secret in secret manager for writing to bucket +GC_BACKUP_BUCKET="itis-backups/travel" # name of bucket that will be used for database backups BACKUP_FILE_NAME="db.sql.gz" # You may also need to set additional variables in your env file: # RUN_INIT/INIT_DATA_ENV - used to hydrate db on startup diff --git a/src/api/index.js b/src/api/index.js index 73083e5..f0fe48e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -10,8 +10,6 @@ if ( config.auth.requireAuth ) { auth(router); } -// TODO: add your api routes here -// API routes are mounted at config.apiRoot foo(router); export default (app) => { diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 8f0ddd8..e448ab4 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -22,11 +22,9 @@ import AppStateModel from "../../lib/cork/models/AppStateModel.js"; AppStateModel.init(appConfig.routes); // import data models -// TODO: Replace with your own models import "../../lib/cork/models/FooModel.js"; // auth -// TODO: If not using auth, you can remove these imports import Keycloak from 'keycloak-js'; import AuthModel from "../../lib/cork/models/AuthModel.js"; diff --git a/src/client/js/components/readme.md b/src/client/js/components/readme.md index 82ccdc9..0ec79d9 100644 --- a/src/client/js/components/readme.md +++ b/src/client/js/components/readme.md @@ -1,4 +1,3 @@ # Components Any custom elements that can be used in multiple pages should be placed here. -TODO: Put your components here, and delete this readme. diff --git a/src/lib/cork/services/BaseService.js b/src/lib/cork/services/BaseService.js index 1761174..bcb9264 100644 --- a/src/lib/cork/services/BaseService.js +++ b/src/lib/cork/services/BaseService.js @@ -1,8 +1,6 @@ import { BaseService } from '@ucd-lib/cork-app-utils'; import { appConfig } from '../../appGlobals.js'; -// TODO: If not using auth, you can remove this file - /** * @class BaseServiceImp * @description Extends the cork-app-utils BaseService to add auth headers to requests diff --git a/src/lib/cork/stores/AppStateStore.js b/src/lib/cork/stores/AppStateStore.js index 8ddec18..4f920fa 100644 --- a/src/lib/cork/stores/AppStateStore.js +++ b/src/lib/cork/stores/AppStateStore.js @@ -8,7 +8,6 @@ class AppStateStoreImpl extends AppStateStore { super(); this.defaultPage = 'home'; - // TODO: Replace these with your own default values this.breadcrumbs = { home: {text: 'Home', link: '/'}, foo: {text: 'Foo', link: '/foo'} diff --git a/src/lib/serverConfig.js b/src/lib/serverConfig.js index fa80984..b379598 100644 --- a/src/lib/serverConfig.js +++ b/src/lib/serverConfig.js @@ -8,8 +8,7 @@ class ServerConfig { this.version = this.getEnv('APP_VERSION', '0.0.9'); this.env = process?.env?.APP_ENV === 'dev' ? 'dev' : 'prod'; - // TODO: Replace these with your own app title - this.title = this.getEnv('APP_TITLE', 'UC Davis Library Travel, Training, and Professional Development'); + this.title = this.getEnv('APP_TITLE', 'Travel, Training, and Professional Development'); // TODO: Replace these with the routes that your SPA should handle this.routes = ['foo']; @@ -23,14 +22,13 @@ class ServerConfig { // TODO: Replace these with your own app slug this.assetFileNames = { - css: 'ucdlib-simple-spa.css', - js: 'ucdlib-simple-spa.js' + css: 'ucdlib-travel.css', + js: 'ucdlib-travel.js' } // sets robots meta tag to discourage search engines from indexing the site this.discourageRobots = this.getEnv('APP_DISCOURAGE_ROBOTS', true); - // TODO: Review auth settings // Made available to the browser-side app, so don't put any secrets here. this.auth = { // forces browser-side authentication. Browser then passes auth token to server. @@ -43,7 +41,7 @@ class ServerConfig { clientId: this.getEnv('APP_KEYCLOAK_CLIENT_ID', 'travel-app') }, oidcScope: this.getEnv('APP_OIDC_SCOPE', 'profile roles ucd-ids'), - serverCacheExpiration: this.getEnv('APP_SERVER_CACHE_EXPIRATION', '10 minutes') + serverCacheExpiration: this.getEnv('APP_SERVER_CACHE_EXPIRATION', '12 hours') }; } From da42bad6232e1fdf2476bcb50b407b19f62cad89 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 18 Apr 2024 16:19:18 -0400 Subject: [PATCH 003/274] #2 --- deploy/db-entrypoint/001-tables.sql | 4 +++ deploy/db-entrypoint/002-options.sql | 54 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 deploy/db-entrypoint/002-options.sql diff --git a/deploy/db-entrypoint/001-tables.sql b/deploy/db-entrypoint/001-tables.sql index 822965c..c6b94cc 100644 --- a/deploy/db-entrypoint/001-tables.sql +++ b/deploy/db-entrypoint/001-tables.sql @@ -31,6 +31,8 @@ CREATE TABLE funding_source ( has_cap BOOLEAN DEFAULT FALSE, cap_default NUMERIC, require_description BOOLEAN DEFAULT FALSE, + form_order INTEGER NOT NULL DEFAULT 0, + hide_from_form BOOLEAN DEFAULT FALSE, archived BOOLEAN DEFAULT FALSE ); COMMENT ON TABLE funding_source IS 'Funding sources that users can select from when creating a travel approval request.'; @@ -53,6 +55,7 @@ COMMENT ON COLUMN approver_type.hide_from_fund_assignment IS 'If true, this appr CREATE TABLE approver_type_employee ( approver_type_id INTEGER REFERENCES approver_type(approver_type_id), employee_kerberos VARCHAR(100) REFERENCES employee(kerberos), + approval_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (approver_type_id, employee_kerberos) ); @@ -126,6 +129,7 @@ CREATE TABLE expenditure_option ( expenditure_option_id SERIAL PRIMARY KEY, label VARCHAR(200) NOT NULL, description TEXT, + form_order INTEGER NOT NULL DEFAULT 0, archived BOOLEAN DEFAULT FALSE ); COMMENT ON TABLE expenditure_option IS 'Line item expenditure options that can be selected by users when creating a travel approval request.'; diff --git a/deploy/db-entrypoint/002-options.sql b/deploy/db-entrypoint/002-options.sql new file mode 100644 index 0000000..ee222a9 --- /dev/null +++ b/deploy/db-entrypoint/002-options.sql @@ -0,0 +1,54 @@ +INSERT INTO expenditure_option (label, form_order) VALUES ('Registration Fees', 0); +INSERT INTO expenditure_option (label, form_order) VALUES ('Airfare', 1); +INSERT INTO expenditure_option (label, form_order, description) VALUES ('Lodging', 2, 'max nightly rate is $275, not including taxes/fees'); +INSERT INTO expenditure_option (label, form_order, description) VALUES ('Meals/Incidentals', 3, 'not applicable for travel of less than 24 hours unless the traveler is away from his or her home overnight as supported by lodging receipt or other evidence explaining why the traveler was unable to obtain a receipt'); +INSERT INTO expenditure_option (label, form_order, description) VALUES ('Ground Transportation', 4, 'rideshare, taxi, shuttle, parking, tolls, etc.'); +INSERT INTO expenditure_option (label, form_order, description) VALUES ('Personal Car Mileage', 5, 'current mileage rate'); +INSERT INTO expenditure_option (label, form_order) VALUES ('Miscellaneous', 6); + +INSERT INTO funding_source(label, has_cap, cap_default, form_order) VALUES ('Represented Librarian Professional Development', true, 2000, 0); +INSERT INTO funding_source(label, form_order) VALUES ('LAUC-D or Statewide LAUC', 1); +INSERT INTO funding_source(label, form_order, require_description) VALUES ('Grant', 2, true); +INSERT INTO funding_source(label, form_order) VALUES ('Department Funding', 3); +INSERT INTO funding_source(label, form_order) VALUES ('Development Related', 4); +INSERT INTO funding_source(label, form_order) VALUES ('Administrative Funding', 5); +INSERT INTO funding_source(label, form_order, require_description) VALUES ('Other Funding', 6, true); +INSERT INTO funding_source(label, form_order, hide_from_form) VALUES ('No funding/program time only', 7, true); + +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Supervisor', 'The current direct supervisor of the requester from iam.staff.library.ucdavis.edu.', true, false); +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Department Head', 'The current department head of the requester from iam.staff.library.ucdavis.edu. Often times will be the same as the supervisor.', true, false); +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Finance Head', 'The head of the Library Finance department', false, false); +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Requester', '', true, true); +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Application Admin', '', true, true); + +-- use the approver_type_employee table to link approver types to employees +-- but i dont want to include kerb ids in a public repo +-- INSERT INTO approver_type_employee(approver_type_id, employee_kerberos) VALUES (3, 'financeheadkerb'); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (1, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (1, 2, 1); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (2, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (2, 2, 1); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (3, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (3, 2, 1); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (4, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (4, 2, 1); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 2, 1); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 3, 2); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 2, 1); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 3, 2); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 2, 1); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 3, 2); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (8, 1, 0); + +INSERT INTO settings(key, value, label, description, input_type, categories) VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-request-form"}'); From bae85ff1f09393110e4e802f7e4f0e5e01335407 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Fri, 19 Apr 2024 12:49:53 -0400 Subject: [PATCH 004/274] fixes for #3 --- src/api/foo.js | 25 ------ src/api/index.js | 3 +- src/client/js/app-main.js | 13 +++- src/client/js/app-main.tpl.js | 25 +++--- .../pages/admin/app-page-admin-allocations.js | 42 ++++++++++ .../app-page-admin-allocations.tpl.js | 0 .../pages/admin/app-page-admin-approvers.js | 41 ++++++++++ .../app-page-admin-approvers.tpl.js | 0 .../pages/admin/app-page-admin-line-items.js | 41 ++++++++++ .../app-page-admin-line-items.tpl.js} | 0 .../admin/app-page-admin-reimbursement.js | 41 ++++++++++ .../app-page-admin-reimbursement.tpl.js | 0 .../js/pages/admin/app-page-admin-settings.js | 41 ++++++++++ .../app-page-admin-settings.tpl.js | 0 src/client/js/pages/admin/app-page-admin.js | 41 ++++++++++ .../pages/{ => admin}/app-page-admin.tpl.js | 0 .../js/pages/app-page-admin-allocations.js | 23 ------ .../js/pages/app-page-admin-approvers.js | 22 ------ src/client/js/pages/app-page-admin-items.js | 22 ------ .../js/pages/app-page-admin-reimbursement.js | 22 ------ .../js/pages/app-page-admin-settings.js | 22 ------ src/client/js/pages/app-page-admin.js | 23 ------ .../js/pages/app-page-approval-request-new.js | 22 ------ src/client/js/pages/app-page-approver.js | 23 ------ src/client/js/pages/app-page-foo.js | 68 ----------------- src/client/js/pages/app-page-foo.tpl.js | 28 ------- src/client/js/pages/app-page-reports.js | 23 ------ src/client/js/pages/app-page-reports.tpl.js | 17 ----- .../app-page-approval-request-new.js | 41 ++++++++++ .../app-page-approval-request-new.tpl.js | 0 .../app-page-approval-request.js | 8 +- .../app-page-approval-request.tpl.js | 0 .../app-page-approval-requests.js | 40 ++++++++++ .../app-page-approval-requests.tpl.js} | 0 .../approval-requests/app-page-approver.js | 42 ++++++++++ .../app-page-approver.tpl.js | 7 ++ src/client/js/pages/bundles/admin.js | 12 +-- .../js/pages/bundles/approval-requests.js | 7 +- src/client/js/pages/bundles/index.js | 11 +-- .../pages/bundles/reimbursement-requests.js | 4 +- src/client/js/pages/bundles/reports.js | 2 +- .../app-page-reimbursement-new.js | 8 +- .../app-page-reimbursement-new.tpl.js | 0 .../app-page-reimbursement.js | 8 +- .../app-page-reimbursement.tpl.js | 0 .../js/pages/reports/app-page-reports.js | 41 ++++++++++ .../js/pages/reports/app-page-reports.tpl.js | 8 ++ src/lib/cork/models/AppStateModel.js | 76 +++++++++++-------- src/lib/cork/models/FooModel.js | 31 -------- src/lib/cork/services/FooService.js | 25 ------ src/lib/cork/stores/AppStateStore.js | 11 ++- src/lib/cork/stores/FooStore.js | 45 ----------- src/lib/db-models/foo.js | 16 ---- 53 files changed, 538 insertions(+), 533 deletions(-) delete mode 100644 src/api/foo.js create mode 100644 src/client/js/pages/admin/app-page-admin-allocations.js rename src/client/js/pages/{ => admin}/app-page-admin-allocations.tpl.js (100%) create mode 100644 src/client/js/pages/admin/app-page-admin-approvers.js rename src/client/js/pages/{ => admin}/app-page-admin-approvers.tpl.js (100%) create mode 100644 src/client/js/pages/admin/app-page-admin-line-items.js rename src/client/js/pages/{app-page-admin-items.tpl.js => admin/app-page-admin-line-items.tpl.js} (100%) create mode 100644 src/client/js/pages/admin/app-page-admin-reimbursement.js rename src/client/js/pages/{ => admin}/app-page-admin-reimbursement.tpl.js (100%) create mode 100644 src/client/js/pages/admin/app-page-admin-settings.js rename src/client/js/pages/{ => admin}/app-page-admin-settings.tpl.js (100%) create mode 100644 src/client/js/pages/admin/app-page-admin.js rename src/client/js/pages/{ => admin}/app-page-admin.tpl.js (100%) delete mode 100644 src/client/js/pages/app-page-admin-allocations.js delete mode 100644 src/client/js/pages/app-page-admin-approvers.js delete mode 100644 src/client/js/pages/app-page-admin-items.js delete mode 100644 src/client/js/pages/app-page-admin-reimbursement.js delete mode 100644 src/client/js/pages/app-page-admin-settings.js delete mode 100644 src/client/js/pages/app-page-admin.js delete mode 100644 src/client/js/pages/app-page-approval-request-new.js delete mode 100644 src/client/js/pages/app-page-approver.js delete mode 100644 src/client/js/pages/app-page-foo.js delete mode 100644 src/client/js/pages/app-page-foo.tpl.js delete mode 100644 src/client/js/pages/app-page-reports.js delete mode 100644 src/client/js/pages/app-page-reports.tpl.js create mode 100644 src/client/js/pages/approval-requests/app-page-approval-request-new.js rename src/client/js/pages/{ => approval-requests}/app-page-approval-request-new.tpl.js (100%) rename src/client/js/pages/{ => approval-requests}/app-page-approval-request.js (79%) rename src/client/js/pages/{ => approval-requests}/app-page-approval-request.tpl.js (100%) create mode 100644 src/client/js/pages/approval-requests/app-page-approval-requests.js rename src/client/js/pages/{app-page-approver.tpl.js => approval-requests/app-page-approval-requests.tpl.js} (100%) create mode 100644 src/client/js/pages/approval-requests/app-page-approver.js create mode 100644 src/client/js/pages/approval-requests/app-page-approver.tpl.js rename src/client/js/pages/{ => reimbursement-requests}/app-page-reimbursement-new.js (79%) rename src/client/js/pages/{ => reimbursement-requests}/app-page-reimbursement-new.tpl.js (100%) rename src/client/js/pages/{ => reimbursement-requests}/app-page-reimbursement.js (80%) rename src/client/js/pages/{ => reimbursement-requests}/app-page-reimbursement.tpl.js (100%) create mode 100644 src/client/js/pages/reports/app-page-reports.js create mode 100644 src/client/js/pages/reports/app-page-reports.tpl.js delete mode 100644 src/lib/cork/models/FooModel.js delete mode 100644 src/lib/cork/services/FooService.js delete mode 100644 src/lib/cork/stores/FooStore.js delete mode 100644 src/lib/db-models/foo.js diff --git a/src/api/foo.js b/src/api/foo.js deleted file mode 100644 index 5ac2b94..0000000 --- a/src/api/foo.js +++ /dev/null @@ -1,25 +0,0 @@ -import foo from "../lib/db-models/foo.js"; -import protect from "../lib/protect.js"; - -/** - * @param {Router} api - Express router instance - */ -export default (api) => { - - api.get('/foo', protect('hasBasicAccess'), async (req, res) => { - - let response = await foo.getAll(); - if ( response.error ) { - console.error('Error retrieving foo: ', response.error); - res.status(500).json({ - error: true, - message: 'Error retrieving foo' - }); - return; - } - - res.json(response.res.rows); - - }); - -} diff --git a/src/api/index.js b/src/api/index.js index f0fe48e..4125e84 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -2,7 +2,6 @@ import express from 'express'; import config from '../lib/serverConfig.js'; import auth from './auth.js'; -import foo from './foo.js'; const router = express.Router(); @@ -10,7 +9,7 @@ if ( config.auth.requireAuth ) { auth(router); } -foo(router); +// routes export default (app) => { app.use(config.apiRoot, router); diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index a38d903..feae5f5 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -4,6 +4,7 @@ import { render } from "./app-main.tpl.js"; // brand components import '@ucd-lib/theme-elements/brand/ucd-theme-primary-nav/ucd-theme-primary-nav.js'; import '@ucd-lib/theme-elements/brand/ucd-theme-header/ucd-theme-header.js'; +import '@ucd-lib/theme-elements/brand/ucd-theme-quick-links/ucd-theme-quick-links.js' import '@ucd-lib/theme-elements/ucdlib/ucdlib-branding-bar/ucdlib-branding-bar.js'; import '@ucd-lib/theme-elements/ucdlib/ucdlib-pages/ucdlib-pages.js'; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; @@ -22,7 +23,6 @@ import AppStateModel from "../../lib/cork/models/AppStateModel.js"; AppStateModel.init(appConfig.routes); // import data models -import "../../lib/cork/models/FooModel.js"; // auth import Keycloak from 'keycloak-js'; @@ -111,6 +111,15 @@ export default class AppMain extends Mixin(LitElement) */ async _onAppStateUpdate(state) { const { page } = state; + if ( ['home', 'page-not-loaded'].includes(page) ) { + this.page = page; + window.scroll(0,0); + return; + } + if ( !page ) { + this.AppStateModel.showError('Page not found'); + return; + } const bundle = this._getBundleName(page); let bundleAlreadyLoaded = true; @@ -218,7 +227,7 @@ export default class AppMain extends Mixin(LitElement) return import(/* webpackChunkName: "reimbursement-requests" */ "./pages/bundles/reimbursement-requests.js"); } console.warn(`AppMain: bundle ${bundle} not found for page ${page}. Check pages/bundles`); - return false; + return true; } } diff --git a/src/client/js/app-main.tpl.js b/src/client/js/app-main.tpl.js index 8c3f2eb..8c8ca94 100644 --- a/src/client/js/app-main.tpl.js +++ b/src/client/js/app-main.tpl.js @@ -9,24 +9,25 @@ return html` ` : html``} - - - Submit a Request - Your Submitted Request + Get Approval + Submitted Approval Requests Approve a Request Reports - - Approvers and Funding Sources - General Settings - Employee Allocations - Line Items + + Approvers and Funding Sources Reimbursement Requests + Employee Allocations + General Settings + Line Items -

${this.pageTitle}

@@ -40,7 +41,6 @@ return html` `)} - @@ -49,12 +49,13 @@ return html` - + + diff --git a/src/client/js/pages/admin/app-page-admin-allocations.js b/src/client/js/pages/admin/app-page-admin-allocations.js new file mode 100644 index 0000000..04c7a2f --- /dev/null +++ b/src/client/js/pages/admin/app-page-admin-allocations.js @@ -0,0 +1,42 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-admin-allocations.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageAdminAllocations extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Employee Allocations'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin, + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-admin-allocations', AppPageAdminAllocations); diff --git a/src/client/js/pages/app-page-admin-allocations.tpl.js b/src/client/js/pages/admin/app-page-admin-allocations.tpl.js similarity index 100% rename from src/client/js/pages/app-page-admin-allocations.tpl.js rename to src/client/js/pages/admin/app-page-admin-allocations.tpl.js diff --git a/src/client/js/pages/admin/app-page-admin-approvers.js b/src/client/js/pages/admin/app-page-admin-approvers.js new file mode 100644 index 0000000..69b63e8 --- /dev/null +++ b/src/client/js/pages/admin/app-page-admin-approvers.js @@ -0,0 +1,41 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-admin-approvers.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageAdminApprovers extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Approvers and Funding Sources'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin, + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-admin-approvers', AppPageAdminApprovers); diff --git a/src/client/js/pages/app-page-admin-approvers.tpl.js b/src/client/js/pages/admin/app-page-admin-approvers.tpl.js similarity index 100% rename from src/client/js/pages/app-page-admin-approvers.tpl.js rename to src/client/js/pages/admin/app-page-admin-approvers.tpl.js diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js new file mode 100644 index 0000000..7b77ab2 --- /dev/null +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -0,0 +1,41 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-admin-line-items.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageAdminLineItems extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Line Items'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin, + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-admin-line-items', AppPageAdminLineItems); diff --git a/src/client/js/pages/app-page-admin-items.tpl.js b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js similarity index 100% rename from src/client/js/pages/app-page-admin-items.tpl.js rename to src/client/js/pages/admin/app-page-admin-line-items.tpl.js diff --git a/src/client/js/pages/admin/app-page-admin-reimbursement.js b/src/client/js/pages/admin/app-page-admin-reimbursement.js new file mode 100644 index 0000000..c2c3b08 --- /dev/null +++ b/src/client/js/pages/admin/app-page-admin-reimbursement.js @@ -0,0 +1,41 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-admin-reimbursement.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageAdminReimbursement extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Reimbursement Requests'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin, + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-admin-reimbursement', AppPageAdminReimbursement); diff --git a/src/client/js/pages/app-page-admin-reimbursement.tpl.js b/src/client/js/pages/admin/app-page-admin-reimbursement.tpl.js similarity index 100% rename from src/client/js/pages/app-page-admin-reimbursement.tpl.js rename to src/client/js/pages/admin/app-page-admin-reimbursement.tpl.js diff --git a/src/client/js/pages/admin/app-page-admin-settings.js b/src/client/js/pages/admin/app-page-admin-settings.js new file mode 100644 index 0000000..9cd30fa --- /dev/null +++ b/src/client/js/pages/admin/app-page-admin-settings.js @@ -0,0 +1,41 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-admin-settings.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageAdminSettings extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('General Settings'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin, + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-admin-settings', AppPageAdminSettings); diff --git a/src/client/js/pages/app-page-admin-settings.tpl.js b/src/client/js/pages/admin/app-page-admin-settings.tpl.js similarity index 100% rename from src/client/js/pages/app-page-admin-settings.tpl.js rename to src/client/js/pages/admin/app-page-admin-settings.tpl.js diff --git a/src/client/js/pages/admin/app-page-admin.js b/src/client/js/pages/admin/app-page-admin.js new file mode 100644 index 0000000..3737a0e --- /dev/null +++ b/src/client/js/pages/admin/app-page-admin.js @@ -0,0 +1,41 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-admin.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + + +export default class AppPageAdmin extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + static get properties() { + return { + + } + } + + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Application Administration'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-admin', AppPageAdmin); diff --git a/src/client/js/pages/app-page-admin.tpl.js b/src/client/js/pages/admin/app-page-admin.tpl.js similarity index 100% rename from src/client/js/pages/app-page-admin.tpl.js rename to src/client/js/pages/admin/app-page-admin.tpl.js diff --git a/src/client/js/pages/app-page-admin-allocations.js b/src/client/js/pages/app-page-admin-allocations.js deleted file mode 100644 index 625baa4..0000000 --- a/src/client/js/pages/app-page-admin-allocations.js +++ /dev/null @@ -1,23 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-admin-allocations.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - -export default class AppPageAdminAllocations extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - - static get properties() { - return { - - } - } - - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-admin-allocations', AppPageAdminAllocations); \ No newline at end of file diff --git a/src/client/js/pages/app-page-admin-approvers.js b/src/client/js/pages/app-page-admin-approvers.js deleted file mode 100644 index e797a43..0000000 --- a/src/client/js/pages/app-page-admin-approvers.js +++ /dev/null @@ -1,22 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-admin-approvers.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - -export default class AppPageAdminApprovers extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - - static get properties() { - return { - - } - } - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-admin-approvers', AppPageAdminApprovers); \ No newline at end of file diff --git a/src/client/js/pages/app-page-admin-items.js b/src/client/js/pages/app-page-admin-items.js deleted file mode 100644 index 3527c97..0000000 --- a/src/client/js/pages/app-page-admin-items.js +++ /dev/null @@ -1,22 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-admin-items.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - -export default class AppPageAdminItems extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - - static get properties() { - return { - - } - } - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-admin-items', AppPageAdminItems); \ No newline at end of file diff --git a/src/client/js/pages/app-page-admin-reimbursement.js b/src/client/js/pages/app-page-admin-reimbursement.js deleted file mode 100644 index 7d3de16..0000000 --- a/src/client/js/pages/app-page-admin-reimbursement.js +++ /dev/null @@ -1,22 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-admin-reimbursement.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - -export default class AppPageAdminReimbursement extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - - static get properties() { - return { - - } - } - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-admin-reimbursement', AppPageAdminReimbursement); \ No newline at end of file diff --git a/src/client/js/pages/app-page-admin-settings.js b/src/client/js/pages/app-page-admin-settings.js deleted file mode 100644 index cb9e187..0000000 --- a/src/client/js/pages/app-page-admin-settings.js +++ /dev/null @@ -1,22 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-admin-settings.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - -export default class AppPageAdminSettings extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - - static get properties() { - return { - - } - } - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-admin-settings', AppPageAdminSettings); \ No newline at end of file diff --git a/src/client/js/pages/app-page-admin.js b/src/client/js/pages/app-page-admin.js deleted file mode 100644 index 5f00e05..0000000 --- a/src/client/js/pages/app-page-admin.js +++ /dev/null @@ -1,23 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-admin.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - - -export default class AppPageAdmin extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - static get properties() { - return { - - } - } - - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-admin', AppPageAdmin); \ No newline at end of file diff --git a/src/client/js/pages/app-page-approval-request-new.js b/src/client/js/pages/app-page-approval-request-new.js deleted file mode 100644 index 9e65d05..0000000 --- a/src/client/js/pages/app-page-approval-request-new.js +++ /dev/null @@ -1,22 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-approval-request-new.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - -export default class AppPageApprovalRequestNew extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - - static get properties() { - return { - - } - } - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-approval-request-new', AppPageApprovalRequestNew); \ No newline at end of file diff --git a/src/client/js/pages/app-page-approver.js b/src/client/js/pages/app-page-approver.js deleted file mode 100644 index 5aa83b6..0000000 --- a/src/client/js/pages/app-page-approver.js +++ /dev/null @@ -1,23 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-approver.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - - -export default class AppPageApprover extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - - static get properties() { - return { - - } - } - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-approver', AppPageApprover); \ No newline at end of file diff --git a/src/client/js/pages/app-page-foo.js b/src/client/js/pages/app-page-foo.js deleted file mode 100644 index 1b26d75..0000000 --- a/src/client/js/pages/app-page-foo.js +++ /dev/null @@ -1,68 +0,0 @@ -import { LitElement } from 'lit'; -import { render } from "./app-page-foo.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - - -export default class AppPageFoo extends Mixin(LitElement) - .with(LitCorkUtils, MainDomElement) { - - - static get properties() { - return { - fooData: {type: Array} - } - } - - constructor() { - super(); - this.render = render.bind(this); - this.fooData = []; - - this._injectModel('AppStateModel', 'FooModel'); - } - - /** - * @description bound to AppStateModel app-state-update event - * @param {Object} state - AppStateModel state - */ - async _onAppStateUpdate(state) { - if ( this.id !== state.page ) return; - - this.AppStateModel.showLoading(); - this.AppStateModel.setTitle('Foo'); - - const breadcrumbs = [ - this.AppStateModel.store.breadcrumbs.home, - this.AppStateModel.store.breadcrumbs.foo - ]; - this.AppStateModel.setBreadcrumbs(breadcrumbs); - - const d = await this.getPageData(); - const hasError = d.some(e => e.state === 'error'); - if ( !hasError ) this.AppStateModel.showLoaded(this.id); - - } - - /** - * @description Get any data required for rendering this page - */ - async getPageData(){ - const promises = []; - promises.push(this.FooModel.getFoo()); - const resolvedPromises = await Promise.all(promises); - return resolvedPromises; - } - - _onFooFetched(e) { - if ( e.state === 'loaded' ) { - this.fooData = e.payload; - } else if ( e.state === 'error' ) { - this.fooData = []; - this.AppStateModel.showError(e); - } - } - -} - -customElements.define('app-page-foo', AppPageFoo); diff --git a/src/client/js/pages/app-page-foo.tpl.js b/src/client/js/pages/app-page-foo.tpl.js deleted file mode 100644 index 622a02f..0000000 --- a/src/client/js/pages/app-page-foo.tpl.js +++ /dev/null @@ -1,28 +0,0 @@ -import { html } from 'lit'; - -export function render() { -return html` -
-
-
-

Here is a list of foo retrieved from the database/api using a cork-app-utils model:

- ${this.fooData.length ? html` -
    - ${this.fooData.map(item => html` -
  • ${item.name}
  • - `)} -
- ` : html` -

There is no foo to display

- `} -
- -
-
- -`;} diff --git a/src/client/js/pages/app-page-reports.js b/src/client/js/pages/app-page-reports.js deleted file mode 100644 index 03ac643..0000000 --- a/src/client/js/pages/app-page-reports.js +++ /dev/null @@ -1,23 +0,0 @@ -import { LitElement } from 'lit'; -import {render} from "./app-page-reports.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - -export default class AppPageReports extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { - - static get properties() { - return { - - } - } - - - constructor() { - super(); - this.render = render.bind(this); - } - -} - -customElements.define('app-page-reports', AppPageReports); \ No newline at end of file diff --git a/src/client/js/pages/app-page-reports.tpl.js b/src/client/js/pages/app-page-reports.tpl.js deleted file mode 100644 index ac51c94..0000000 --- a/src/client/js/pages/app-page-reports.tpl.js +++ /dev/null @@ -1,17 +0,0 @@ -import { html, css } from 'lit'; - -export function styles() { - const elementStyles = css` - :host { - display: block; - } - `; - - return [elementStyles]; -} - -export function render() { -return html` - - -`;} \ No newline at end of file diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js new file mode 100644 index 0000000..6760c06 --- /dev/null +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -0,0 +1,41 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-approval-request-new.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageApprovalRequestNew extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('New Approval Request'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs['approval-requests'], + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-approval-request-new', AppPageApprovalRequestNew); diff --git a/src/client/js/pages/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js similarity index 100% rename from src/client/js/pages/app-page-approval-request-new.tpl.js rename to src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js diff --git a/src/client/js/pages/app-page-approval-request.js b/src/client/js/pages/approval-requests/app-page-approval-request.js similarity index 79% rename from src/client/js/pages/app-page-approval-request.js rename to src/client/js/pages/approval-requests/app-page-approval-request.js index 33042b4..b4ba785 100644 --- a/src/client/js/pages/app-page-approval-request.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request.js @@ -1,6 +1,6 @@ import { LitElement } from 'lit'; import {render} from "./app-page-approval-request.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; export default class AppPageApprovalRequest extends Mixin(LitElement) @@ -8,15 +8,17 @@ export default class AppPageApprovalRequest extends Mixin(LitElement) static get properties() { return { - + } } constructor() { super(); this.render = render.bind(this); + + this._injectModel('AppStateModel'); } } -customElements.define('app-page-approval-request', AppPageApprovalRequest); \ No newline at end of file +customElements.define('app-page-approval-request', AppPageApprovalRequest); diff --git a/src/client/js/pages/app-page-approval-request.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request.tpl.js similarity index 100% rename from src/client/js/pages/app-page-approval-request.tpl.js rename to src/client/js/pages/approval-requests/app-page-approval-request.tpl.js diff --git a/src/client/js/pages/approval-requests/app-page-approval-requests.js b/src/client/js/pages/approval-requests/app-page-approval-requests.js new file mode 100644 index 0000000..9ad70ce --- /dev/null +++ b/src/client/js/pages/approval-requests/app-page-approval-requests.js @@ -0,0 +1,40 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-approval-requests.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageApprovalRequests extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Submitted Approval Requests'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-approval-requests', AppPageApprovalRequests); diff --git a/src/client/js/pages/app-page-approver.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-requests.tpl.js similarity index 100% rename from src/client/js/pages/app-page-approver.tpl.js rename to src/client/js/pages/approval-requests/app-page-approval-requests.tpl.js diff --git a/src/client/js/pages/approval-requests/app-page-approver.js b/src/client/js/pages/approval-requests/app-page-approver.js new file mode 100644 index 0000000..75a76a6 --- /dev/null +++ b/src/client/js/pages/approval-requests/app-page-approver.js @@ -0,0 +1,42 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-approver.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + + +export default class AppPageApprover extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Review Approval Requests'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + + +} + +customElements.define('app-page-approver', AppPageApprover); diff --git a/src/client/js/pages/approval-requests/app-page-approver.tpl.js b/src/client/js/pages/approval-requests/app-page-approver.tpl.js new file mode 100644 index 0000000..6b4d94e --- /dev/null +++ b/src/client/js/pages/approval-requests/app-page-approver.tpl.js @@ -0,0 +1,7 @@ +import { html } from 'lit'; + +export function render() { +return html` + + +`;} \ No newline at end of file diff --git a/src/client/js/pages/bundles/admin.js b/src/client/js/pages/bundles/admin.js index 23be01a..2c1ff04 100644 --- a/src/client/js/pages/bundles/admin.js +++ b/src/client/js/pages/bundles/admin.js @@ -1,6 +1,6 @@ -import "../app-page-admin.js"; -import "../app-page-admin-allocations.js"; -import "../app-page-admin-approvers.js"; -import "../app-page-admin-items.js"; -import "../app-page-admin-reimbursement.js"; -import "../app-page-admin-settings.js"; +import "../admin/app-page-admin.js"; +import "../admin/app-page-admin-allocations.js"; +import "../admin/app-page-admin-approvers.js"; +import "../admin/app-page-admin-line-items.js"; +import "../admin/app-page-admin-reimbursement.js"; +import "../admin/app-page-admin-settings.js"; diff --git a/src/client/js/pages/bundles/approval-requests.js b/src/client/js/pages/bundles/approval-requests.js index cdf3d08..17e69c5 100644 --- a/src/client/js/pages/bundles/approval-requests.js +++ b/src/client/js/pages/bundles/approval-requests.js @@ -1,3 +1,4 @@ -import "../app-page-approval-request.js"; -import "../app-page-approval-request-new.js"; -import "../app-page-approver.js"; \ No newline at end of file +import "../approval-requests/app-page-approval-request.js"; +import "../approval-requests/app-page-approval-requests.js"; +import "../approval-requests/app-page-approval-request-new.js"; +import "../approval-requests/app-page-approver.js"; diff --git a/src/client/js/pages/bundles/index.js b/src/client/js/pages/bundles/index.js index 3c4735f..eefbce8 100644 --- a/src/client/js/pages/bundles/index.js +++ b/src/client/js/pages/bundles/index.js @@ -5,18 +5,13 @@ * The array value is a list of page ids that are in that bundle. */ -// TODO: Replace these with your own bundle->pageid mappings const defs = { - // all : [ - // 'home', 'foo' - // ], - "approval-requests": [ - 'approval-request', 'approval-request-new', 'approver' + 'approval-request', 'approval-request-new', 'approver', 'approval-requests' ], "admin": [ - 'admin', 'admin-approvers', 'admin-settings', - 'admin-allocations', 'admin-items', 'admin-reimbursement' + 'admin', 'admin-approvers', 'admin-settings', + 'admin-allocations', 'admin-line-items', 'admin-reimbursement' ], "reports": [ 'reports' diff --git a/src/client/js/pages/bundles/reimbursement-requests.js b/src/client/js/pages/bundles/reimbursement-requests.js index c65b295..a3e84e1 100644 --- a/src/client/js/pages/bundles/reimbursement-requests.js +++ b/src/client/js/pages/bundles/reimbursement-requests.js @@ -1,2 +1,2 @@ -import "../app-page-reimbursement.js"; -import "../app-page-reimbursement-new.js"; \ No newline at end of file +import "../reimbursement-requests/app-page-reimbursement.js"; +import "../reimbursement-requests/app-page-reimbursement-new.js"; diff --git a/src/client/js/pages/bundles/reports.js b/src/client/js/pages/bundles/reports.js index 3a160e4..0478442 100644 --- a/src/client/js/pages/bundles/reports.js +++ b/src/client/js/pages/bundles/reports.js @@ -1,2 +1,2 @@ -import "../app-page-reports.js"; +import "../reports/app-page-reports.js"; diff --git a/src/client/js/pages/app-page-reimbursement-new.js b/src/client/js/pages/reimbursement-requests/app-page-reimbursement-new.js similarity index 79% rename from src/client/js/pages/app-page-reimbursement-new.js rename to src/client/js/pages/reimbursement-requests/app-page-reimbursement-new.js index 239e479..2ce8262 100644 --- a/src/client/js/pages/app-page-reimbursement-new.js +++ b/src/client/js/pages/reimbursement-requests/app-page-reimbursement-new.js @@ -1,6 +1,6 @@ import { LitElement } from 'lit'; import {render} from "./app-page-reimbursement-new.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; export default class AppPageReimbursementNew extends Mixin(LitElement) @@ -8,15 +8,17 @@ export default class AppPageReimbursementNew extends Mixin(LitElement) static get properties() { return { - + } } constructor() { super(); this.render = render.bind(this); + + this._injectModel('AppStateModel'); } } -customElements.define('app-page-reimbursement-new', AppPageReimbursementNew); \ No newline at end of file +customElements.define('app-page-reimbursement-new', AppPageReimbursementNew); diff --git a/src/client/js/pages/app-page-reimbursement-new.tpl.js b/src/client/js/pages/reimbursement-requests/app-page-reimbursement-new.tpl.js similarity index 100% rename from src/client/js/pages/app-page-reimbursement-new.tpl.js rename to src/client/js/pages/reimbursement-requests/app-page-reimbursement-new.tpl.js diff --git a/src/client/js/pages/app-page-reimbursement.js b/src/client/js/pages/reimbursement-requests/app-page-reimbursement.js similarity index 80% rename from src/client/js/pages/app-page-reimbursement.js rename to src/client/js/pages/reimbursement-requests/app-page-reimbursement.js index 0c89fb4..5ccb91b 100644 --- a/src/client/js/pages/app-page-reimbursement.js +++ b/src/client/js/pages/reimbursement-requests/app-page-reimbursement.js @@ -1,6 +1,6 @@ import { LitElement } from 'lit'; import {render} from "./app-page-reimbursement.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; export default class AppPageReimbursement extends Mixin(LitElement) @@ -8,7 +8,7 @@ export default class AppPageReimbursement extends Mixin(LitElement) static get properties() { return { - + } } @@ -16,8 +16,10 @@ export default class AppPageReimbursement extends Mixin(LitElement) constructor() { super(); this.render = render.bind(this); + + this._injectModel('AppStateModel'); } } -customElements.define('app-page-reimbursement', AppPageReimbursement); \ No newline at end of file +customElements.define('app-page-reimbursement', AppPageReimbursement); diff --git a/src/client/js/pages/app-page-reimbursement.tpl.js b/src/client/js/pages/reimbursement-requests/app-page-reimbursement.tpl.js similarity index 100% rename from src/client/js/pages/app-page-reimbursement.tpl.js rename to src/client/js/pages/reimbursement-requests/app-page-reimbursement.tpl.js diff --git a/src/client/js/pages/reports/app-page-reports.js b/src/client/js/pages/reports/app-page-reports.js new file mode 100644 index 0000000..0a165dc --- /dev/null +++ b/src/client/js/pages/reports/app-page-reports.js @@ -0,0 +1,41 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-reports.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageReports extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Reports'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-reports', AppPageReports); diff --git a/src/client/js/pages/reports/app-page-reports.tpl.js b/src/client/js/pages/reports/app-page-reports.tpl.js new file mode 100644 index 0000000..3112d99 --- /dev/null +++ b/src/client/js/pages/reports/app-page-reports.tpl.js @@ -0,0 +1,8 @@ +import { html, css } from 'lit'; + + +export function render() { +return html` + + +`;} diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index ccccb2e..6fc7f6e 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -31,7 +31,6 @@ class AppStateModelImpl extends AppStateModel { } this.stripStateFromHash(update); this._setPage(update); - this.setBreadcrumbs(false, update); this.closeNav(); let res = super.set(update); @@ -54,36 +53,49 @@ class AppStateModelImpl extends AppStateModel { */ _setPage(update){ if ( !update ) return; - let p = this.store.defaultPage; - const route = this.getPathByIndex(0, update); - - // TODO: Replace these with your own route->pageid mappings - if ( route === 'foo' ){ - p = 'foo'; - } else if ( route === 'admin' ){ - p = 'admin'; - } else if ( route === 'admin-approvers' ){ - p = 'admin-approvers'; - } else if ( route === 'admin-settings' ){ - p = 'admin-settings'; - } else if ( route === 'admin-allocations' ){ - p = 'admin-allocations'; - } else if ( route === 'admin-items' ){ - p = 'admin-items'; - } else if ( route === 'admin-reimbursement' ){ - p = 'admin-reimbursement'; - } else if ( route === 'approver' ){ + let p = ''; + const baseRoute = this.getPathByIndex(0, update); + const secondaryRoute = this.getPathByIndex(1, update); + const tertiaryRoute = this.getPathByIndex(2, update); + + if ( !baseRoute ) { + p = this.store.defaultPage; + } + + else if ( baseRoute === 'admin' ){ + if ( secondaryRoute === 'approvers' ){ + p = 'admin-approvers'; + } else if ( secondaryRoute === 'settings' ){ + p = 'admin-settings'; + } else if ( secondaryRoute === 'allocations' ){ + p = 'admin-allocations'; + } else if ( secondaryRoute === 'line-items' ){ + p = 'admin-line-items'; + } else if ( secondaryRoute === 'reimbursement' ){ + p = 'admin-reimbursement'; + } else if ( !secondaryRoute ) { + p = 'admin'; + } + } + + else if ( baseRoute === 'approval-request' ){ + if ( secondaryRoute ){ + if ( secondaryRoute === 'new' ){ + p = 'approval-request-new'; + } else if ( tertiaryRoute ){ + p = 'approval-request'; + } + } else { + p = 'approval-requests'; + } + } + + else if ( baseRoute === 'approver' ){ p = 'approver'; - } else if ( route === 'reimbursement' ){ - p = 'reimbursement'; - } else if ( route === 'reimbursement-new' ){ - p = 'reimbursement-new'; - } else if ( route === 'reports' ){ + } + + else if ( baseRoute === 'reports' ){ p = 'reports'; - } else if ( route === 'approval-request' ){ - p = 'approval-request'; - } else if ( route === 'approval-request-new' ){ - p = 'approval-request-new'; } update.page = p; @@ -216,7 +228,11 @@ class AppStateModelImpl extends AppStateModel { * @description Close the app's primary nav menu */ closeNav(){ - const ele = document.querySelector('ucd-theme-header'); + let ele = document.querySelector('ucd-theme-header'); + if ( ele ) { + ele.close(); + } + ele = document.querySelector('ucd-theme-quick-links'); if ( ele ) { ele.close(); } diff --git a/src/lib/cork/models/FooModel.js b/src/lib/cork/models/FooModel.js deleted file mode 100644 index b74bd8f..0000000 --- a/src/lib/cork/models/FooModel.js +++ /dev/null @@ -1,31 +0,0 @@ -import {BaseModel} from '@ucd-lib/cork-app-utils'; -import FooService from '../services/FooService.js'; -import FooStore from '../stores/FooStore.js'; - -class FooModel extends BaseModel { - - constructor() { - super(); - - this.store = FooStore; - this.service = FooService; - - this.register('FooModel'); - } - - async getFoo(){ - let state = this.store.data.foo; - try { - if ( state.state === 'loading' ){ - await state.request - } else { - await this.service.getFoo(); - } - } catch(e) {} - return this.store.data.foo; - } - -} - -const model = new FooModel(); -export default model; diff --git a/src/lib/cork/services/FooService.js b/src/lib/cork/services/FooService.js deleted file mode 100644 index c2c7f25..0000000 --- a/src/lib/cork/services/FooService.js +++ /dev/null @@ -1,25 +0,0 @@ -import BaseService from "./BaseService.js"; -import FooStore from '../stores/FooStore.js'; -import { appConfig } from '../../appGlobals.js'; - -class FooService extends BaseService { - - constructor() { - super(); - this.store = FooStore; - } - - getFoo(){ - return this.request({ - url : `${appConfig.apiRoot}/foo`, - onLoading : request => this.store.getFooLoading(request), - checkCached : () => this.store.data.foo, - onLoad : result => this.store.getFooLoaded(result.body), - onError : e => this.store.getFooError(e) - }); - } - -} - -const service = new FooService(); -export default service; diff --git a/src/lib/cork/stores/AppStateStore.js b/src/lib/cork/stores/AppStateStore.js index 4f920fa..60955d4 100644 --- a/src/lib/cork/stores/AppStateStore.js +++ b/src/lib/cork/stores/AppStateStore.js @@ -10,7 +10,16 @@ class AppStateStoreImpl extends AppStateStore { this.breadcrumbs = { home: {text: 'Home', link: '/'}, - foo: {text: 'Foo', link: '/foo'} + 'approval-request-new': {text: 'New', link: '/approval-request/new'}, + 'approval-requests': {text: 'Your Approval Requests', link: '/approval-request'}, + 'approver': {text: 'Approve a Request', link: '/approver'}, + 'reports': {text: 'Reports', link: '/reports'}, + 'admin': {text: 'Admin', link: '/admin'}, + 'admin-allocations': {text: 'Employee Allocations', link: '/admin/allocations'}, + 'admin-approvers': {text: 'Approvers and Funding Sources', link: '/admin/approvers'}, + 'admin-reimbursement': {text: 'Reimbursement Requests', link: '/admin/reimbursement'}, + 'admin-settings': {text: 'General Settings', link: '/admin/settings'}, + 'admin-line-items': {text: 'Line Items', link: '/admin/items'} }; this.userProfile = {}; diff --git a/src/lib/cork/stores/FooStore.js b/src/lib/cork/stores/FooStore.js deleted file mode 100644 index 7e62bcb..0000000 --- a/src/lib/cork/stores/FooStore.js +++ /dev/null @@ -1,45 +0,0 @@ -import {BaseStore} from '@ucd-lib/cork-app-utils'; - -class FooStore extends BaseStore { - - constructor() { - super(); - - this.data = { - foo: {} - }; - this.events = { - FOO_FETCHED: 'foo-fetched' - }; - } - - getFooLoading(request) { - this._setFooState({ - state : this.STATE.LOADING, - request - }); - } - - getFooLoaded(payload) { - this._setFooState({ - state : this.STATE.LOADED, - payload - }); - } - - getFooError(error) { - this._setFooState({ - state : this.STATE.ERROR, - error - }); - } - - _setFooState(state) { - this.data.foo = state; - this.emit(this.events.FOO_FETCHED, state); - } - -} - -const store = new FooStore(); -export default store; diff --git a/src/lib/db-models/foo.js b/src/lib/db-models/foo.js deleted file mode 100644 index ae906c2..0000000 --- a/src/lib/db-models/foo.js +++ /dev/null @@ -1,16 +0,0 @@ -import pg from "./pg.js"; - -/** - * @description Model for accessing the foo table - */ -class Foo { - - async getAll(){ - let text = ` - SELECT * FROM foo - `; - return await pg.query(text); - } -} - -export default new Foo(); From d4782a9b529bc6ac64c2b59c80a3d7d0df6f9470 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Fri, 19 Apr 2024 12:58:28 -0400 Subject: [PATCH 005/274] fix single approval request routing --- src/lib/cork/models/AppStateModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index 6fc7f6e..40b8892 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -82,7 +82,7 @@ class AppStateModelImpl extends AppStateModel { if ( secondaryRoute ){ if ( secondaryRoute === 'new' ){ p = 'approval-request-new'; - } else if ( tertiaryRoute ){ + } else { p = 'approval-request'; } } else { From 310a4322c59bfc42ca864b1c82d73439d92f93bf Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Mon, 22 Apr 2024 16:32:07 -0400 Subject: [PATCH 006/274] #16 --- deploy/db-entrypoint/foo.sql | 7 --- deploy/docker-compose.yaml | 12 ++--- deploy/templates/local-dev.yaml | 1 + src/api/employee.js | 16 +++++++ src/api/index.js | 2 + src/lib/db-models/employee.js | 54 +++++++++++++++++++++ src/lib/serverConfig.js | 9 +++- src/lib/tail.sh | 3 ++ src/lib/utils/LibraryIam.js | 84 +++++++++++++++++++++++++++++++++ 9 files changed, 173 insertions(+), 15 deletions(-) delete mode 100644 deploy/db-entrypoint/foo.sql create mode 100644 src/api/employee.js create mode 100644 src/lib/db-models/employee.js create mode 100755 src/lib/tail.sh create mode 100644 src/lib/utils/LibraryIam.js diff --git a/deploy/db-entrypoint/foo.sql b/deploy/db-entrypoint/foo.sql deleted file mode 100644 index e819dd6..0000000 --- a/deploy/db-entrypoint/foo.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE foo ( - id SERIAL PRIMARY KEY, - name varchar(100) -); -INSERT INTO foo (name) VALUES ('bar'); -INSERT INTO foo (name) VALUES ('baz'); -INSERT INTO foo (name) VALUES ('qux'); diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 9eff886..b39cfba 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -1,7 +1,7 @@ version: '3' services: app: - image: gcr.io/digital-ucdavis-edu/ucdlib-travel:dev + image: gcr.io/ucdlib-pubreg/ucdlib-travel:dev env_file: - .env ports: @@ -11,33 +11,33 @@ services: restart: always command: ./start-server.sh init: - image: gcr.io/digital-ucdavis-edu/ucdlib-travel:dev + image: gcr.io/ucdlib-pubreg/ucdlib-travel:dev env_file: - .env depends_on: - db environment: GOOGLE_APPLICATION_CREDENTIALS: /etc/service-account.json - GC_BACKUP_BUCKET: + GC_BACKUP_BUCKET: itis-backups/travel BACKUP_FILE_NAME: db.sql.gz volumes: - ../gc-reader-key.json:/etc/service-account.json command: ./deploy-utils/init/init.sh backup: - image: gcr.io/digital-ucdavis-edu/ucdlib-travel:dev + image: gcr.io/ucdlib-pubreg/ucdlib-travel:dev env_file: - .env depends_on: - db environment: GOOGLE_APPLICATION_CREDENTIALS: /etc/service-account.json - GC_BACKUP_BUCKET: + GC_BACKUP_BUCKET: itis-backups/travel BACKUP_FILE_NAME: db.sql.gz volumes: - ../gc-writer-key.json:/etc/service-account.json command: ./deploy-utils/backup/entrypoint.sh db: - image: postgres:15.3 + image: postgres:16 env_file: - .env restart: always diff --git a/deploy/templates/local-dev.yaml b/deploy/templates/local-dev.yaml index 4a88480..1245901 100644 --- a/deploy/templates/local-dev.yaml +++ b/deploy/templates/local-dev.yaml @@ -23,6 +23,7 @@ services: - ../../src/package.json:/app/package.json - ../../src/package-lock.json:/app/package-lock.json command: ./start-server.sh + # command: ./lib/tail.sh init: image: {{APP_IMAGE_NAME_TAG}} env_file: diff --git a/src/api/employee.js b/src/api/employee.js new file mode 100644 index 0000000..456c84c --- /dev/null +++ b/src/api/employee.js @@ -0,0 +1,16 @@ +import employee from "../lib/db-models/employee.js"; +import protect from "../lib/protect.js"; + +/** + * @param {Router} api - Express router instance + */ +export default (api) => { + + api.get('/employee', protect('hasBasicAccess'), async (req, res) => { + + + res.json({config: employee.configured}); + + }); + +} diff --git a/src/api/index.js b/src/api/index.js index 4125e84..27da560 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -2,6 +2,7 @@ import express from 'express'; import config from '../lib/serverConfig.js'; import auth from './auth.js'; +import employee from './employee.js'; const router = express.Router(); @@ -10,6 +11,7 @@ if ( config.auth.requireAuth ) { } // routes +employee(router); export default (app) => { app.use(config.apiRoot, router); diff --git a/src/lib/db-models/employee.js b/src/lib/db-models/employee.js new file mode 100644 index 0000000..6a4a911 --- /dev/null +++ b/src/lib/db-models/employee.js @@ -0,0 +1,54 @@ +import cache from "./cache.js"; +import libraryIam from "../utils/LibraryIam.js"; +import pg from "./pg.js"; + +/** + * @class Employee + * @description Class for querying employee data from the library IAM API. + */ +class Employee { + + constructor(){ + + this.api = libraryIam; + + // DB cache keys + this.cacheKeys = { + singleEmployee: 'ucdlib-iam-employee' + }; + } + + async getById(id, idType='username', skipCache=false) { + const returnSingle = Array.isArray(id) ? false : true; + const cacheType = `${this.cacheKeys.singleEmployee}--${idType}`; + + // create array of ids regardless of input type + const ids = (Array.isArray(id) ? id : [id]).map(id => String(id)).map(id => id.trim()).filter(id => id); + if ( ids.length === 0 ) { + return pg.returnError('No employee ids provided.'); + } + const recordsById = {}; + for ( const id of ids ) { + recordsById[id] = null; + } + + // Check cache + if ( !skipCache ) { + for (const id of ids) { + const cacheRes = await cache.get(cacheType, id, this.apiConfig.serverCacheExpiration); + if ( cacheRes.rows.length ) { + recordsById[id] = cacheRes.rows[0].data; + } + } + } + + // return if all records were found in cache + const idsNotInCache = ids.filter(id => !recordsById[id]); + if ( idsNotInCache.length === 0 ) { + return returnSingle ? recordsById[ids[0]] : ids.map(id => recordsById[id]).filter(record => record); + } + + } +} + +export default new Employee(); diff --git a/src/lib/serverConfig.js b/src/lib/serverConfig.js index ef6e5df..f6e9659 100644 --- a/src/lib/serverConfig.js +++ b/src/lib/serverConfig.js @@ -10,7 +10,6 @@ class ServerConfig { this.title = this.getEnv('APP_TITLE', 'Travel, Training, and Professional Development'); - // TODO: Replace these with the routes that your SPA should handle this.routes = ['approval-request', 'approver', 'reimbursement', 'reports', 'admin']; this.apiRoot = this.getEnv('APP_API_ROOT', '/api'); @@ -20,7 +19,6 @@ class ServerConfig { host: this.getEnv('APP_HOST_PORT', 3000), // server port on host machine } - // TODO: Replace these with your own app slug this.assetFileNames = { css: 'ucdlib-travel.css', js: 'ucdlib-travel.js' @@ -43,6 +41,13 @@ class ServerConfig { oidcScope: this.getEnv('APP_OIDC_SCOPE', 'profile roles ucd-ids'), serverCacheExpiration: this.getEnv('APP_SERVER_CACHE_EXPIRATION', '12 hours') }; + + this.libraryIam = { + url: this.getEnv('UCDLIB_PERSONNEL_API_USER_URL', 'https://iam.staff.library.ucdavis.edu/json'), + user: this.getEnv('UCDLIB_PERSONNEL_API_USER', ''), + key: this.getEnv('UCDLIB_PERSONNEL_API_KEY', ''), + serverCacheExpiration: this.getEnv('UCDLIB_PERSONNEL_API_CACHE_EXPIRATION', '24 hours') + } } /** diff --git a/src/lib/tail.sh b/src/lib/tail.sh new file mode 100755 index 0000000..6c59ef3 --- /dev/null +++ b/src/lib/tail.sh @@ -0,0 +1,3 @@ +#! /bin/bash + +tail -f /dev/null; diff --git a/src/lib/utils/LibraryIam.js b/src/lib/utils/LibraryIam.js new file mode 100644 index 0000000..912ebbb --- /dev/null +++ b/src/lib/utils/LibraryIam.js @@ -0,0 +1,84 @@ +import serverConfig from "../serverConfig.js"; +import pg from "../db-models/pg.js"; +import fetch from 'node-fetch'; + +/** + * @class LibraryIam + * @description Utility class for querying the library IAM API. + * Does auth. + */ +class LibraryIam { + + constructor(){ + + // Credentials from env file + this.config = serverConfig.libraryIam; + this.configured = (this.config.url && this.config.user && this.config.key) ? true : false; + this.configuredError = 'Library IAM API not configured in env file. Cannot query employee data.'; + } + + /** + * @description Log the configuration error to the console. + */ + logConfigError(){ + console.error(this.configuredError); + } + + /** + * @description Get the Authorization header for the Library IAM API + * @returns {String} Authorization header for IAM API + */ + getAuthorizationHeader(){ + const auth = Buffer.from(`${this.config.user}:${this.config.key}`).toString('base64'); + return `Basic ${auth}`; + } + + async get(url, searchParams={}, options={}){ + if ( !this.configured ) { + this.logConfigError(); + return pg.returnError(this.configuredError); + } + + // remove trailing slash + if ( url.endsWith('/') ) url = url.slice(0, -1); + const baseUrl = this.config.url.endsWith('/') ? this.config.url.slice(0, -1) : this.config.url; + + // add search params + const searchParamsString = new URLSearchParams(searchParams).toString(); + if ( searchParamsString ) url += `?${searchParamsString}`; + + try { + const response = await fetch(`${baseUrl}${url}`, { + headers: { + 'Authorization': this.getAuthorizationHeader(), + 'Content-Type': 'application/json' + }, + ...options + }); + + if ( response.ok ) { + return response.json(); + } else { + throw new HTTPResponseError(response); + } + } + catch (error) { + return pg.returnError(error.message); + } + + + } + + +} + +class HTTPResponseError extends Error { + constructor(response) { + super(`HTTP Error Response: ${response.status} ${response.statusText}`); + this.response = response; + this.is404 = response.status == 404; + } +} + + +export default new LibraryIam(); From 6e00af77fc54535808e595d740bedfa3fdb8ccb8 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 23 Apr 2024 14:32:26 -0400 Subject: [PATCH 007/274] #16 --- deploy/db-entrypoint/001-tables.sql | 12 +- src/api/employee.js | 2 +- src/lib/db-models/employee.js | 107 ++++++++++++++++-- src/lib/serverConfig.js | 2 +- .../utils/{LibraryIam.js => LibraryIamApi.js} | 24 ++-- 5 files changed, 121 insertions(+), 26 deletions(-) rename src/lib/utils/{LibraryIam.js => LibraryIamApi.js} (80%) diff --git a/deploy/db-entrypoint/001-tables.sql b/deploy/db-entrypoint/001-tables.sql index c6b94cc..f3ecf73 100644 --- a/deploy/db-entrypoint/001-tables.sql +++ b/deploy/db-entrypoint/001-tables.sql @@ -19,11 +19,19 @@ COMMENT ON TABLE department IS 'Historical department information. Most departme CREATE TABLE employee ( kerberos VARCHAR(100) PRIMARY KEY, first_name VARCHAR(100) NOT NULL, - last_name VARCHAR(100) NOT NULL, - department_id INTEGER REFERENCES department(department_id) + last_name VARCHAR(100) NOT NULL ); COMMENT ON TABLE employee IS 'Historical employee information. Most employee information is pulled from our IAM system'; +CREATE TABLE employee_department ( + employee_department_id SERIAL PRIMARY KEY, + employee_kerberos VARCHAR(100) REFERENCES employee(kerberos), + department_id INTEGER REFERENCES department(department_id), + start_date DATE NOT NULL DEFAULT NOW(), + end_date DATE +) +COMMENT ON TABLE employee_department IS 'Mapping table for employees and departments.'; + CREATE TABLE funding_source ( funding_source_id SERIAL PRIMARY KEY, label VARCHAR(200) NOT NULL, diff --git a/src/api/employee.js b/src/api/employee.js index 456c84c..375969a 100644 --- a/src/api/employee.js +++ b/src/api/employee.js @@ -9,7 +9,7 @@ export default (api) => { api.get('/employee', protect('hasBasicAccess'), async (req, res) => { - res.json({config: employee.configured}); + res.json({test: await employee.queryIam({department: [19,22]})}); }); diff --git a/src/lib/db-models/employee.js b/src/lib/db-models/employee.js index 6a4a911..b882641 100644 --- a/src/lib/db-models/employee.js +++ b/src/lib/db-models/employee.js @@ -1,43 +1,99 @@ import cache from "./cache.js"; -import libraryIam from "../utils/LibraryIam.js"; +import libraryIamApi from "../utils/LibraryIamApi.js"; import pg from "./pg.js"; /** * @class Employee - * @description Class for querying employee data from the library IAM API. + * @description Class for querying data about library employees */ class Employee { constructor(){ - this.api = libraryIam; + this.api = libraryIamApi; // DB cache keys this.cacheKeys = { - singleEmployee: 'ucdlib-iam-employee' + singleEmployee: 'ucdlib-iam-employee', + employeeQuery: 'ucdlib-iam-employees' }; + + this.idTypes = [ + {methodParam: 'user-id', responseProp: 'user_id', name: 'Kerberos ID'}, + {methodParam: 'iam-id', responseProp: 'iam_id', name: 'UCD IAM ID'}, + {methodParam: 'email', responseProp: 'email', name: 'Email'}, + {methodParam: 'employee-id', responseProp: 'employee_id', name: 'Employee ID'}, + {methodParam: 'db-id', responseProp: 'id', name: 'Library IAM DB ID'} + ]; + } + + async queryIam(query={}, skipCache=false){ + + // ensure certain query props are arrays and then make them comma separated strings + const arrayQueryProps = ['department', 'title-code']; + arrayQueryProps.forEach(prop => { + if ( query[prop] && !Array.isArray(query[prop]) ) query[prop] = [query[prop]]; + if ( query[prop] ) { + query[prop].sort(); + query[prop] = query[prop].join(','); + } + }); + + // Check cache + const cacheKey = JSON.stringify(query, Object.keys(query).sort()); + if ( !skipCache ) { + const cacheRes = await cache.get(this.cacheKeys.employeeQuery, cacheKey, this.api.config.serverCacheExpiration); + if ( cacheRes.res?.rows?.length ) { + return cacheRes.res.rows[0].data; + } + } + + // Get records from API + const res = await this.api.get('/employees', query); + if ( res.error ) return res; + + // Update cache + if ( !skipCache ){ + await cache.set(this.cacheKeys.employeeQuery, cacheKey, res); + } + + return res; } - async getById(id, idType='username', skipCache=false) { + /** + * @description Get library IAM employee record by id + * @param {String|String[]} id - id or array of ids + * @param {String} idType - id type (user-id, iam-id, email, employee-id, db-id) + * @param {Boolean} skipCache - Will not use local db cache + * @returns {Object} {res, error} - where res is a single record or array of records + */ + async getIamRecordById(id, idType='user-id', skipCache=false) { const returnSingle = Array.isArray(id) ? false : true; const cacheType = `${this.cacheKeys.singleEmployee}--${idType}`; // create array of ids regardless of input type - const ids = (Array.isArray(id) ? id : [id]).map(id => String(id)).map(id => id.trim()).filter(id => id); + const ids = (Array.isArray(id) ? id : [id]).filter(id => id).map(id => String(id)).map(id => id.trim()).filter(id => id); if ( ids.length === 0 ) { return pg.returnError('No employee ids provided.'); } + + // Check idType + const idTypeObj = this.idTypes.find(type => type.methodParam === idType); + if ( !idTypeObj ) { + return pg.returnError('Invalid id type provided.'); + } + + // Check cache for each employee record const recordsById = {}; for ( const id of ids ) { recordsById[id] = null; } - - // Check cache if ( !skipCache ) { for (const id of ids) { - const cacheRes = await cache.get(cacheType, id, this.apiConfig.serverCacheExpiration); - if ( cacheRes.rows.length ) { - recordsById[id] = cacheRes.rows[0].data; + const cacheRes = await cache.get(cacheType, id, this.api.config.serverCacheExpiration); + if ( cacheRes.error) continue; + if ( cacheRes.res?.rows?.length ) { + recordsById[id] = cacheRes.res.rows[0].data; } } } @@ -45,9 +101,36 @@ class Employee { // return if all records were found in cache const idsNotInCache = ids.filter(id => !recordsById[id]); if ( idsNotInCache.length === 0 ) { - return returnSingle ? recordsById[ids[0]] : ids.map(id => recordsById[id]).filter(record => record); + return returnSingle ? {res: recordsById[ids[0]]} : {res: ids.map(id => recordsById[id]).filter(record => record)}; + } + + // Get records from API + const queryParams = { + 'id-type': idType, + groups: true, + supervisor: true, + 'department-head': true + }; + const res = await this.api.get(`/employees/${idsNotInCache.join(',')}`, queryParams); + if ( res.error ) return res; + + (Array.isArray(res.res) ? res.res : [res.res]).forEach(record => { + const id = record[idTypeObj.responseProp]; + if ( !id ) return; + recordsById[id] = record; + }); + + // Update cache + if ( !skipCache ){ + for ( const id of idsNotInCache ) { + if ( recordsById[id] ) { + await cache.set(cacheType, id, recordsById[id]); + } + } } + // return records + return returnSingle ? {res: recordsById[ids[0]]} : {res: ids.map(id => recordsById[id]).filter(record => record)}; } } diff --git a/src/lib/serverConfig.js b/src/lib/serverConfig.js index f6e9659..b6487a6 100644 --- a/src/lib/serverConfig.js +++ b/src/lib/serverConfig.js @@ -42,7 +42,7 @@ class ServerConfig { serverCacheExpiration: this.getEnv('APP_SERVER_CACHE_EXPIRATION', '12 hours') }; - this.libraryIam = { + this.libraryIamApi = { url: this.getEnv('UCDLIB_PERSONNEL_API_USER_URL', 'https://iam.staff.library.ucdavis.edu/json'), user: this.getEnv('UCDLIB_PERSONNEL_API_USER', ''), key: this.getEnv('UCDLIB_PERSONNEL_API_KEY', ''), diff --git a/src/lib/utils/LibraryIam.js b/src/lib/utils/LibraryIamApi.js similarity index 80% rename from src/lib/utils/LibraryIam.js rename to src/lib/utils/LibraryIamApi.js index 912ebbb..02b33b0 100644 --- a/src/lib/utils/LibraryIam.js +++ b/src/lib/utils/LibraryIamApi.js @@ -3,16 +3,16 @@ import pg from "../db-models/pg.js"; import fetch from 'node-fetch'; /** - * @class LibraryIam + * @class LibraryIamApi * @description Utility class for querying the library IAM API. * Does auth. */ -class LibraryIam { +class LibraryIamApi { constructor(){ // Credentials from env file - this.config = serverConfig.libraryIam; + this.config = serverConfig.libraryIamApi; this.configured = (this.config.url && this.config.user && this.config.key) ? true : false; this.configuredError = 'Library IAM API not configured in env file. Cannot query employee data.'; } @@ -33,6 +33,13 @@ class LibraryIam { return `Basic ${auth}`; } + /** + * @description Send GET request to Library IAM API + * @param {String} url - URL path + * @param {Object} searchParams - URL search parameters + * @param {Object} options - Fetch options + * @returns {Object} {res, error} + */ async get(url, searchParams={}, options={}){ if ( !this.configured ) { this.logConfigError(); @@ -48,6 +55,7 @@ class LibraryIam { if ( searchParamsString ) url += `?${searchParamsString}`; try { + //console.log(`${baseUrl}${url}`); const response = await fetch(`${baseUrl}${url}`, { headers: { 'Authorization': this.getAuthorizationHeader(), @@ -57,19 +65,15 @@ class LibraryIam { }); if ( response.ok ) { - return response.json(); + return {res: await response.json()}; } else { throw new HTTPResponseError(response); } } catch (error) { - return pg.returnError(error.message); + return {error}; } - - } - - } class HTTPResponseError extends Error { @@ -81,4 +85,4 @@ class HTTPResponseError extends Error { } -export default new LibraryIam(); +export default new LibraryIamApi(); From 986f56e70b2a30d06fd1696fb6015168a8ff19f0 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 24 Apr 2024 13:59:04 -0400 Subject: [PATCH 008/274] #16 --- deploy/db-entrypoint/001-tables.sql | 5 +- src/api/department.js | 22 ++++ src/api/employee.js | 78 ++++++++++++- src/api/index.js | 2 + src/client/js/app-main.js | 4 +- src/client/js/components/readme.md | 3 - .../ucdlib-employee-search-basic.js | 27 +++++ .../ucdlib-employee-search-basic.tpl.js | 15 +++ .../admin/app-page-admin-approvers.tpl.js | 8 +- src/lib/cork/models/AppStateModel.js | 2 +- src/lib/cork/models/DepartmentModel.js | 31 +++++ src/lib/cork/models/EmployeeModel.js | 94 ++++++++++++++++ src/lib/cork/services/DepartmentService.js | 24 ++++ src/lib/cork/services/EmployeeService.js | 45 ++++++++ src/lib/cork/stores/DepartmentStore.js | 45 ++++++++ src/lib/cork/stores/EmployeeStore.js | 106 ++++++++++++++++++ src/lib/db-models/department.js | 46 ++++++++ src/lib/db-models/employee.js | 46 +++++++- src/lib/utils/apiUtils.js | 14 +++ src/lib/utils/urlUtils.js | 33 ++++++ 20 files changed, 635 insertions(+), 15 deletions(-) create mode 100644 src/api/department.js delete mode 100644 src/client/js/components/readme.md create mode 100644 src/client/js/components/ucdlib-employee-search-basic.js create mode 100644 src/client/js/components/ucdlib-employee-search-basic.tpl.js create mode 100644 src/lib/cork/models/DepartmentModel.js create mode 100644 src/lib/cork/models/EmployeeModel.js create mode 100644 src/lib/cork/services/DepartmentService.js create mode 100644 src/lib/cork/services/EmployeeService.js create mode 100644 src/lib/cork/stores/DepartmentStore.js create mode 100644 src/lib/cork/stores/EmployeeStore.js create mode 100644 src/lib/db-models/department.js create mode 100644 src/lib/utils/apiUtils.js create mode 100644 src/lib/utils/urlUtils.js diff --git a/deploy/db-entrypoint/001-tables.sql b/deploy/db-entrypoint/001-tables.sql index f3ecf73..2d03d36 100644 --- a/deploy/db-entrypoint/001-tables.sql +++ b/deploy/db-entrypoint/001-tables.sql @@ -10,11 +10,12 @@ CREATE TABLE cache ( COMMENT ON TABLE cache IS 'Cache table for storing http requests and other data'; CREATE TABLE department ( - department_id SERIAL PRIMARY KEY, + department_id INTEGER PRIMARY KEY, label VARCHAR(200) NOT NULL, archived BOOLEAN DEFAULT FALSE ); COMMENT ON TABLE department IS 'Historical department information. Most department information is pulled from our IAM system'; +COMMENT ON COLUMN department.department_id IS 'The group ID from the Library IAM system.'; CREATE TABLE employee ( kerberos VARCHAR(100) PRIMARY KEY, @@ -29,7 +30,7 @@ CREATE TABLE employee_department ( department_id INTEGER REFERENCES department(department_id), start_date DATE NOT NULL DEFAULT NOW(), end_date DATE -) +); COMMENT ON TABLE employee_department IS 'Mapping table for employees and departments.'; CREATE TABLE funding_source ( diff --git a/src/api/department.js b/src/api/department.js new file mode 100644 index 0000000..d7aed21 --- /dev/null +++ b/src/api/department.js @@ -0,0 +1,22 @@ +import department from "../lib/db-models/department.js"; +import protect from "../lib/protect.js"; + +/** + * @param {Router} api - Express router instance + */ +export default (api) => { + + /** + * @description Get an array of all active library departments from the library IAM API + */ + api.get('/active-departments', protect('hasBasicAccess'), async (req, res) => { + const result = await department.getActiveDepartments(); + + if ( result.error ) { + console.error(result.error); + return res.status(500).json({error: true, message: 'Error querying department data.'}); + } + + res.json(result.res); + }); +}; diff --git a/src/api/employee.js b/src/api/employee.js index 375969a..53469aa 100644 --- a/src/api/employee.js +++ b/src/api/employee.js @@ -1,15 +1,91 @@ import employee from "../lib/db-models/employee.js"; import protect from "../lib/protect.js"; +import apiUtils from "../lib/utils/apiUtils.js"; /** * @param {Router} api - Express router instance */ export default (api) => { + /** + * @description Query the library IAM API for employee records + */ api.get('/employee', protect('hasBasicAccess'), async (req, res) => { + const PAGE_SIZE = 20; + // query params + const name = req.query.name; + const department = (req.query.department || '').split(','); + const titleCode = (req.query['title-code'] || '').split(','); + const page = apiUtils.getPageNumber(req); - res.json({test: await employee.queryIam({department: [19,22]})}); + // construct iam query object + const query = {}; + if ( name ) query.name = name; + if ( department.length ) query.department = department; + if ( titleCode.length ) query['title-code'] = titleCode; + + const apiResult = await employee.queryIam(query); + + if ( apiResult.error ) { + console.error(apiResult.error); + return res.status(500).json({error: true, message: 'Error querying employee data.'}); + } + + // pagination + const total = apiResult.res.length; + const totalPages = Math.ceil(total / PAGE_SIZE); + const start = (page - 1) * PAGE_SIZE; + const end = start + PAGE_SIZE; + const data = apiResult.res.slice(start, end); + + res.json({ + total, + totalPages, + page, + data + }); + + }); + + /** + * @description Get a single library IAM employee record or array of records by id + */ + api.get('/employee/:id', protect('hasBasicAccess'), async (req, res) => { + const maxIds = 20; + const id = req.params.id || ''; + + const ids = id.split(','); + if ( ids.length > maxIds ) { + return res.status(400).json({error: true, message: `Too many IDs. Maximum is ${maxIds}.`}); + } + + const idType = req.query['id-type'] || 'user-id'; + const apiResult = await employee.getIamRecordById(ids.length > 1 ? ids : ids[0], idType); + + if ( apiResult.error ) { + if ( apiResult.error.is404 ) { + return res.status(404).json({error: true, message: 'Employee record not found.'}); + } + console.error(apiResult.error); + return res.status(500).json({error: true, message: 'Error querying employee data.'}); + } + + res.json(apiResult.res); + }); + + /** + * @description Get a list of active title codes + */ + api.get('/active-titles', protect('hasBasicAccess'), async (req, res) => { + const apiResult = await employee.getActiveTitleCodes(); + + if ( apiResult.error ) { + console.error(apiResult.error); + return res.status(500).json({error: true, message: 'Error querying active titles.'}); + } + + res.json(apiResult.res); }); diff --git a/src/api/index.js b/src/api/index.js index 27da560..fcc025a 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -2,6 +2,7 @@ import express from 'express'; import config from '../lib/serverConfig.js'; import auth from './auth.js'; +import department from './department.js'; import employee from './employee.js'; const router = express.Router(); @@ -11,6 +12,7 @@ if ( config.auth.requireAuth ) { } // routes +department(router); employee(router); export default (app) => { diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index feae5f5..1662430 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -22,7 +22,9 @@ import { appConfig, LitCorkUtils, Mixin } from "../../lib/appGlobals.js"; import AppStateModel from "../../lib/cork/models/AppStateModel.js"; AppStateModel.init(appConfig.routes); -// import data models +// import cork models +import "../../lib/cork/models/DepartmentModel.js"; +import "../../lib/cork/models/EmployeeModel.js"; // auth import Keycloak from 'keycloak-js'; diff --git a/src/client/js/components/readme.md b/src/client/js/components/readme.md deleted file mode 100644 index 0ec79d9..0000000 --- a/src/client/js/components/readme.md +++ /dev/null @@ -1,3 +0,0 @@ -# Components -Any custom elements that can be used in multiple pages should be placed here. - diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js new file mode 100644 index 0000000..160cd92 --- /dev/null +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -0,0 +1,27 @@ +import { LitElement } from 'lit'; +import {render, styles} from "./ucdlib-employee-search-basic.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; + +export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) +.with(LitCorkUtils) { + + static get properties() { + return { + + } + } + + static get styles() { + return styles(); + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('EmployeeModel'); + } + +} + +customElements.define('ucdlib-employee-search-basic', UcdlibEmployeeSearchBasic); diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js new file mode 100644 index 0000000..04ec816 --- /dev/null +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -0,0 +1,15 @@ +import { html, css } from 'lit'; + +export function styles() { + const elementStyles = css` + :host { + display: block; + } + `; + + return [elementStyles]; +} + +export function render() { +return html` +`;} diff --git a/src/client/js/pages/admin/app-page-admin-approvers.tpl.js b/src/client/js/pages/admin/app-page-admin-approvers.tpl.js index 6b4d94e..52bec91 100644 --- a/src/client/js/pages/admin/app-page-admin-approvers.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-approvers.tpl.js @@ -1,7 +1,9 @@ import { html } from 'lit'; -export function render() { -return html` +import "../../components/ucdlib-employee-search-basic.js"; +export function render() { +return html` + -`;} \ No newline at end of file +`;} diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index 40b8892..8bbcc00 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -129,7 +129,7 @@ class AppStateModelImpl extends AppStateModel { stripStateFromHash(update){ if ( !update || !update.location || !update.location.hash ) return; let hash = new URLSearchParams(update.location.hash); - const toStrip = ['state', 'session_state', 'code']; + const toStrip = ['state', 'session_state', 'code', 'iss']; let replace = false; for (const key of toStrip) { if ( hash.has(key) ) { diff --git a/src/lib/cork/models/DepartmentModel.js b/src/lib/cork/models/DepartmentModel.js new file mode 100644 index 0000000..cc225a9 --- /dev/null +++ b/src/lib/cork/models/DepartmentModel.js @@ -0,0 +1,31 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import DepartmentService from '../services/DepartmentService.js'; +import DepartmentStore from '../stores/DepartmentStore.js'; + +class DepartmentModel extends BaseModel { + + constructor() { + super(); + + this.store = DepartmentStore; + this.service = DepartmentService; + + this.register('DepartmentModel'); + } + + async getActiveDepartments(){ + let state = this.store.data.activeDepartments; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.getActiveDepartments(); + } + } catch(e) {} + return this.store.data.activeDepartments; + } + +} + +const model = new DepartmentModel(); +export default model; diff --git a/src/lib/cork/models/EmployeeModel.js b/src/lib/cork/models/EmployeeModel.js new file mode 100644 index 0000000..fca26eb --- /dev/null +++ b/src/lib/cork/models/EmployeeModel.js @@ -0,0 +1,94 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import EmployeeService from '../services/EmployeeService.js'; +import EmployeeStore from '../stores/EmployeeStore.js'; + +import urlUtils from '../../utils/urlUtils.js'; + +class EmployeeModel extends BaseModel { + + constructor() { + super(); + + this.store = EmployeeStore; + this.service = EmployeeService; + + this.register('EmployeeModel'); + } + + /** + * @description Query the library employee IAM API + * @param {Object} query - query parameters include: + * - name: string + * - department: group id or array of group ids + * - titleCode: UC PATH title code or array of title codes + * - page: page number + * @returns + */ + async queryIam(query={}){ + + const q = {}; + + if ( query.name) q.name = query.name; + if ( query.page ) q.page = query.page; + + let department = urlUtils.sortAndJoin(query.department); + if( department ) q.department = department; + + let titleCode = urlUtils.sortAndJoin(query.titleCode); + if ( titleCode ) q['title-code'] = titleCode; + + const queryString = urlUtils.queryStringFromObject(q); + + let state = this.store.data.iamQuery[queryString]; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.iamQuery(queryString); + } + } catch(e) {} + return this.store.data.iamQuery[queryString]; + + } + + /** + * @description Get a library employee IAM record by id(s) + * @param {String|String[]} id - single id or array of ids + * @param {String} idType - type of id, default 'user-id' aka kerberos. See API endpoint for more options + * @returns + */ + async getIamRecordById(id, idType='user-id') { + id = urlUtils.sortAndJoin(id); + const cacheKey = this.store.getIamIdKey(id, idType); + + let state = this.store.data.iamById[cacheKey]; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.iamQueryById(id, idType); + } + } catch(e) {} + return this.store.data.iamById[cacheKey]; + } + + /** + * @description Get a list of UC PATH titles that are occupied by current library employees (as primary appointment) + * @returns + */ + async getActiveTitles(){ + let state = this.store.data.activeTitles; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.getActiveTitles(); + } + } catch(e) {} + return this.store.data.activeTitles; + } + +} + +const model = new EmployeeModel(); +export default model; diff --git a/src/lib/cork/services/DepartmentService.js b/src/lib/cork/services/DepartmentService.js new file mode 100644 index 0000000..207ce86 --- /dev/null +++ b/src/lib/cork/services/DepartmentService.js @@ -0,0 +1,24 @@ +import BaseService from './BaseService.js'; +import DepartmentStore from '../stores/DepartmentStore.js'; + +class DepartmentService extends BaseService { + + constructor() { + super(); + this.store = DepartmentStore; + } + + getActiveDepartments(){ + return this.request({ + url : `/api/active-departments`, + checkCached: () => this.store.data.activeDepartments, + onLoading : request => this.store.activeDepartmentsLoading(request), + onLoad : result => this.store.activeDepartmentsLoaded(result.body), + onError : e => this.store.activeDepartmentsError(e) + }); + } + +} + +const service = new DepartmentService(); +export default service; diff --git a/src/lib/cork/services/EmployeeService.js b/src/lib/cork/services/EmployeeService.js new file mode 100644 index 0000000..2421a48 --- /dev/null +++ b/src/lib/cork/services/EmployeeService.js @@ -0,0 +1,45 @@ +import BaseService from './BaseService.js'; +import EmployeeStore from '../stores/EmployeeStore.js'; + +class EmployeeService extends BaseService { + + constructor() { + super(); + this.store = EmployeeStore; + } + + getActiveTitles(){ + return this.request({ + url : `/api/active-titles`, + checkCached: () => this.store.data.activeTitles, + onLoading : request => this.store.activeTitlesLoading(request), + onLoad : result => this.store.activeTitlesLoaded(result.body), + onError : e => this.store.activeTitlesError(e) + }); + } + + iamQuery(query){ + return this.request({ + url : `/api/employee${query ? '?' + query : ''}`, + checkCached: () => this.store.data.iamQuery[query], + onLoading : request => this.store.iamQueryLoading(request, query), + onLoad : result => this.store.iamQueryLoaded(result.body, query), + onError : e => this.store.iamQueryError(e, query) + }); + } + + + iamQueryById(id, idType){ + return this.request({ + url : `/api/employee/${id}?id-type=${idType}`, + checkCached: () => this.store.data.iamById[this.store.getIamIdKey(id, idType)], + onLoading : request => this.store.iamQueryByIdLoading(request, id, idType), + onLoad : result => this.store.iamQueryByIdLoaded(result.body, id, idType), + onError : e => this.store.iamQueryByIdError(e, id, idType) + }); + } + +} + +const service = new EmployeeService(); +export default service; diff --git a/src/lib/cork/stores/DepartmentStore.js b/src/lib/cork/stores/DepartmentStore.js new file mode 100644 index 0000000..78ad7b8 --- /dev/null +++ b/src/lib/cork/stores/DepartmentStore.js @@ -0,0 +1,45 @@ +import {BaseStore} from '@ucd-lib/cork-app-utils'; + +class DepartmentStore extends BaseStore { + + constructor() { + super(); + + this.data = { + activeDepartments: {} + }; + this.events = { + ACTIVE_DEPARTMENTS_FETCHED: 'active-departments-fetched' + }; + } + + activeDepartmentsLoading(request) { + this._setActiveDepartmentsState({ + state : this.STATE.LOADING, + request + }); + } + + activeDepartmentsLoaded(payload) { + this._setActiveDepartmentsState({ + state : this.STATE.LOADED, + payload + }); + } + + activeDepartmentsError(error) { + this._setActiveDepartmentsState({ + state : this.STATE.ERROR, + error + }); + } + + _setActiveDepartmentsState(state) { + this.data.activeDepartments = state; + this.emit(this.events.ACTIVE_DEPARTMENTS_FETCHED, state); + } + +} + +const store = new DepartmentStore(); +export default store; diff --git a/src/lib/cork/stores/EmployeeStore.js b/src/lib/cork/stores/EmployeeStore.js new file mode 100644 index 0000000..5e6fb04 --- /dev/null +++ b/src/lib/cork/stores/EmployeeStore.js @@ -0,0 +1,106 @@ +import {BaseStore} from '@ucd-lib/cork-app-utils'; + +class EmployeeStore extends BaseStore { + + constructor() { + super(); + + this.data = { + iamQuery: {}, + iamById: {}, + activeTitles: {} + }; + this.events = { + IAM_QUERIED: 'iam-queried', + IAM_BY_ID_QUERIED: 'iam-by-id-queried', + ACTIVE_TITLES_FETCHED: 'active-titles-fetched' + }; + } + + activeTitlesLoading(request) { + this._setActiveTitlesState({ + state : this.STATE.LOADING, + request + }); + } + + activeTitlesLoaded(payload) { + this._setActiveTitlesState({ + state : this.STATE.LOADED, + payload + }); + } + + activeTitlesError(error) { + this._setActiveTitlesState({ + state : this.STATE.ERROR, + error + }); + } + + _setActiveTitlesState(state) { + this.data.activeTitles = state; + this.emit(this.events.ACTIVE_TITLES_FETCHED, state); + } + + iamQueryLoading(request, query) { + this._setIamQueryState({ + state : this.STATE.LOADING, + request + }, query); + } + + iamQueryLoaded(payload, query) { + this._setIamQueryState({ + state : this.STATE.LOADED, + payload + }, query); + } + + iamQueryError(error, query) { + this._setIamQueryState({ + state : this.STATE.ERROR, + error + }, query); + } + + _setIamQueryState(state, query) { + this.data.iamQuery[query] = state; + this.emit(this.events.IAM_QUERIED, state); + } + + iamQueryByIdLoading(request, id, idType) { + this._setIamQueryByIdState({ + state : this.STATE.LOADING, + request + }, id, idType); + } + + iamQueryByIdLoaded(payload, id, idType) { + this._setIamQueryByIdState({ + state : this.STATE.LOADED, + payload + }, id, idType); + } + + iamQueryByIdError(error, id, idType) { + this._setIamQueryByIdState({ + state : this.STATE.ERROR, + error + }, id, idType); + } + + _setIamQueryByIdState(state, id, idType) { + this.data.iamById[this.getIamIdKey(id, idType)] = state; + this.emit(this.events.IAM_BY_ID_QUERIED, state); + } + + getIamIdKey(id, idType) { + return idType+'--'+id; + + } + +} + +const store = new EmployeeStore(); +export default store; diff --git a/src/lib/db-models/department.js b/src/lib/db-models/department.js new file mode 100644 index 0000000..4fd957d --- /dev/null +++ b/src/lib/db-models/department.js @@ -0,0 +1,46 @@ +import cache from "./cache.js"; +import libraryIamApi from "../utils/LibraryIamApi.js"; + +/** + * @class Department + * @description Class for querying data about library departments + */ +class Department { + + constructor(){ + + this.api = libraryIamApi; + + // DB cache keys + this.cacheKeys = { + activeDepartments: 'ucdlib-iam-active-departments' + }; + } + + /** + * @description Get an array of all active library departments from the library IAM API + * @param {Boolean} skipCache - Will not use local db cache + * @returns {Object} {res, error} - where res is an array of group objects + */ + async getActiveDepartments(skipCache=false){ + if ( !skipCache ) { + const cacheRes = await cache.get(this.cacheKeys.activeDepartments, this.cacheKeys.activeDepartments, this.api.config.serverCacheExpiration); + if ( cacheRes.res?.rows?.length ) { + return cacheRes.res.rows[0].data; + } + } + + const res = await this.api.get('/groups', {'filter-active': true, 'filter-part-of-org': true, head: true}); + if ( res.error ) return res; + + if ( !skipCache ){ + await cache.set(this.cacheKeys.activeDepartments, this.cacheKeys.activeDepartments, res); + } + + return res; + } + + +} + +export default new Department(); diff --git a/src/lib/db-models/employee.js b/src/lib/db-models/employee.js index b882641..1d72012 100644 --- a/src/lib/db-models/employee.js +++ b/src/lib/db-models/employee.js @@ -15,7 +15,8 @@ class Employee { // DB cache keys this.cacheKeys = { singleEmployee: 'ucdlib-iam-employee', - employeeQuery: 'ucdlib-iam-employees' + employeeQuery: 'ucdlib-iam-employees', + titleCodes: 'ucdlib-iam-title-codes' }; this.idTypes = [ @@ -27,6 +28,39 @@ class Employee { ]; } + /** + * @description Get an array of all UC PATH title codes that are primary appointment of library employees + * @param {Boolean} skipCache - Will not use local db cache + * @returns {Object} {res, error} - where res is an array of title code objects + */ + async getActiveTitleCodes(skipCache=false){ + + if ( !skipCache ) { + const cacheRes = await cache.get(this.cacheKeys.titleCodes, this.cacheKeys.titleCodes, this.api.config.serverCacheExpiration); + if ( cacheRes.res?.rows?.length ) { + return cacheRes.res.rows[0].data; + } + } + + const res = await this.api.get('/active-titles'); + if ( res.error ) return res; + + if ( !skipCache ){ + await cache.set(this.cacheKeys.titleCodes, this.cacheKeys.titleCodes, res); + } + + return res; + }; + + /** + * @description Query the library IAM API for employee records + * @param {Object} query - Query object with the following available properties: + * - name: Employee name + * - department: Array of department numbers + * - title-code: Array of title codes + * @param {Boolean} skipCache - Will not use local db cache + * @returns {Object} {res, error} - where res is an array of records + */ async queryIam(query={}, skipCache=false){ // ensure certain query props are arrays and then make them comma separated strings @@ -53,7 +87,7 @@ class Employee { if ( res.error ) return res; // Update cache - if ( !skipCache ){ + if ( !skipCache && res.res.length ){ await cache.set(this.cacheKeys.employeeQuery, cacheKey, res); } @@ -111,8 +145,12 @@ class Employee { supervisor: true, 'department-head': true }; - const res = await this.api.get(`/employees/${idsNotInCache.join(',')}`, queryParams); - if ( res.error ) return res; + let res = await this.api.get(`/employees/${idsNotInCache.join(',')}`, queryParams); + if ( res.error && res.error.is404 && !returnSingle) { + res = {res: []}; + } else if ( res.error ) { + return res; + } (Array.isArray(res.res) ? res.res : [res.res]).forEach(record => { const id = record[idTypeObj.responseProp]; diff --git a/src/lib/utils/apiUtils.js b/src/lib/utils/apiUtils.js new file mode 100644 index 0000000..a4ec4e1 --- /dev/null +++ b/src/lib/utils/apiUtils.js @@ -0,0 +1,14 @@ +class ApiUtils { + + /** + * @description Get the page number from the query string + * @param {*} req - Express request object + * @returns {Number} + */ + getPageNumber(req){ + const page = parseInt(req.query.page); + return isNaN(page) ? 1 : page; + } +} + +export default new ApiUtils(); diff --git a/src/lib/utils/urlUtils.js b/src/lib/utils/urlUtils.js new file mode 100644 index 0000000..604d7ae --- /dev/null +++ b/src/lib/utils/urlUtils.js @@ -0,0 +1,33 @@ +/** + * @class UrlUtils + * @description Utility class for URL manipulation + */ +class UrlUtils { + + /** + * @description Get the query string from an object + * @param {Object} q - query object + * @param {String} empty - return value if query object is empty - default + * @returns {String} + */ + queryStringFromObject(q, empty=''){ + if ( !q || !Object.keys(q).length) return empty; + const searchParams = new URLSearchParams(q); + searchParams.sort(); + return searchParams.toString(); + } + + /** + * @description Sort and comma join an array or single value + * @param {Array|String} v - value to sort and join + * @returns {String} - comma separated string + */ + sortAndJoin(v){ + v = v || []; + if( !Array.isArray(v) ) v = [v]; + v.sort(); + return v.join(','); + } +} + +export default new UrlUtils(); From 1e5b224f3828dc30095a9acd8235808e324f9c14 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 25 Apr 2024 12:57:55 -0400 Subject: [PATCH 009/274] settings db model. #25 --- deploy/db-entrypoint/001-tables.sql | 3 +- deploy/db-entrypoint/002-options.sql | 47 -------------- deploy/db-entrypoint/003-funding.sql | 44 +++++++++++++ deploy/db-entrypoint/004-settings.sql | 1 + src/api/admin/index.js | 26 ++++++++ src/api/admin/settings.js | 20 ++++++ src/api/index.js | 2 + src/lib/db-models/settings.js | 94 +++++++++++++++++++++++++++ src/lib/utils/EntityFields.js | 61 +++++++++++++++++ 9 files changed, 249 insertions(+), 49 deletions(-) create mode 100644 deploy/db-entrypoint/003-funding.sql create mode 100644 deploy/db-entrypoint/004-settings.sql create mode 100644 src/api/admin/index.js create mode 100644 src/api/admin/settings.js create mode 100644 src/lib/db-models/settings.js create mode 100644 src/lib/utils/EntityFields.js diff --git a/deploy/db-entrypoint/001-tables.sql b/deploy/db-entrypoint/001-tables.sql index 2d03d36..4babc25 100644 --- a/deploy/db-entrypoint/001-tables.sql +++ b/deploy/db-entrypoint/001-tables.sql @@ -82,9 +82,9 @@ CREATE TABLE settings ( value TEXT NOT NULL, label VARCHAR(200), description TEXT, + default_value TEXT, use_default_value BOOLEAN DEFAULT FALSE, keywords TEXT, - hide_on_settings_page BOOLEAN DEFAULT FALSE, settings_page_order INTEGER DEFAULT 0, input_type VARCHAR(100) DEFAULT 'text', categories TEXT[], @@ -93,7 +93,6 @@ CREATE TABLE settings ( COMMENT ON TABLE settings IS 'Settings table for storing key-value pairs of configuration data. So we dont have to hard code so many values in the application.'; COMMENT ON COLUMN settings.use_default_value IS 'The value will be ignored and the hardcoded default value will be used instead.'; COMMENT ON COLUMN settings.keywords IS 'Just to help with searching for settings.'; -COMMENT ON COLUMN settings.hide_on_settings_page IS 'If true, this setting will not be displayed on the settings page.'; COMMENT ON COLUMN settings.settings_page_order IS 'The order in which this setting will be displayed on the settings page.'; COMMENT ON COLUMN settings.input_type IS 'The type of input to use for this setting. Options are text, textarea, checkbox, number'; COMMENT ON COLUMN settings.categories IS 'List of categories that this setting belongs to - for easier querying by client'; diff --git a/deploy/db-entrypoint/002-options.sql b/deploy/db-entrypoint/002-options.sql index ee222a9..4034a7b 100644 --- a/deploy/db-entrypoint/002-options.sql +++ b/deploy/db-entrypoint/002-options.sql @@ -5,50 +5,3 @@ INSERT INTO expenditure_option (label, form_order, description) VALUES ('Meals/I INSERT INTO expenditure_option (label, form_order, description) VALUES ('Ground Transportation', 4, 'rideshare, taxi, shuttle, parking, tolls, etc.'); INSERT INTO expenditure_option (label, form_order, description) VALUES ('Personal Car Mileage', 5, 'current mileage rate'); INSERT INTO expenditure_option (label, form_order) VALUES ('Miscellaneous', 6); - -INSERT INTO funding_source(label, has_cap, cap_default, form_order) VALUES ('Represented Librarian Professional Development', true, 2000, 0); -INSERT INTO funding_source(label, form_order) VALUES ('LAUC-D or Statewide LAUC', 1); -INSERT INTO funding_source(label, form_order, require_description) VALUES ('Grant', 2, true); -INSERT INTO funding_source(label, form_order) VALUES ('Department Funding', 3); -INSERT INTO funding_source(label, form_order) VALUES ('Development Related', 4); -INSERT INTO funding_source(label, form_order) VALUES ('Administrative Funding', 5); -INSERT INTO funding_source(label, form_order, require_description) VALUES ('Other Funding', 6, true); -INSERT INTO funding_source(label, form_order, hide_from_form) VALUES ('No funding/program time only', 7, true); - -INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Supervisor', 'The current direct supervisor of the requester from iam.staff.library.ucdavis.edu.', true, false); -INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Department Head', 'The current department head of the requester from iam.staff.library.ucdavis.edu. Often times will be the same as the supervisor.', true, false); -INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Finance Head', 'The head of the Library Finance department', false, false); -INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Requester', '', true, true); -INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Application Admin', '', true, true); - --- use the approver_type_employee table to link approver types to employees --- but i dont want to include kerb ids in a public repo --- INSERT INTO approver_type_employee(approver_type_id, employee_kerberos) VALUES (3, 'financeheadkerb'); - -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (1, 1, 0); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (1, 2, 1); - -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (2, 1, 0); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (2, 2, 1); - -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (3, 1, 0); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (3, 2, 1); - -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (4, 1, 0); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (4, 2, 1); - -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 1, 0); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 2, 1); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 3, 2); - -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 1, 0); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 2, 1); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 3, 2); - -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 1, 0); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 2, 1); -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 3, 2); - -INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (8, 1, 0); - -INSERT INTO settings(key, value, label, description, input_type, categories) VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-request-form"}'); diff --git a/deploy/db-entrypoint/003-funding.sql b/deploy/db-entrypoint/003-funding.sql new file mode 100644 index 0000000..fd004f5 --- /dev/null +++ b/deploy/db-entrypoint/003-funding.sql @@ -0,0 +1,44 @@ +INSERT INTO funding_source(label, has_cap, cap_default, form_order) VALUES ('Represented Librarian Professional Development', true, 2000, 0); +INSERT INTO funding_source(label, form_order) VALUES ('LAUC-D or Statewide LAUC', 1); +INSERT INTO funding_source(label, form_order, require_description) VALUES ('Grant', 2, true); +INSERT INTO funding_source(label, form_order) VALUES ('Department Funding', 3); +INSERT INTO funding_source(label, form_order) VALUES ('Development Related', 4); +INSERT INTO funding_source(label, form_order) VALUES ('Administrative Funding', 5); +INSERT INTO funding_source(label, form_order, require_description) VALUES ('Other Funding', 6, true); +INSERT INTO funding_source(label, form_order, hide_from_form) VALUES ('No funding/program time only', 7, true); + +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Supervisor', 'The current direct supervisor of the requester from iam.staff.library.ucdavis.edu.', true, false); +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Department Head', 'The current department head of the requester from iam.staff.library.ucdavis.edu. Often times will be the same as the supervisor.', true, false); +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Finance Head', 'The head of the Library Finance department', false, false); +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Requester', '', true, true); +INSERT INTO approver_type(label, description, system_generated, hide_from_fund_assignment) VALUES ('Application Admin', '', true, true); + +-- use the approver_type_employee table to link approver types to employees +-- but i dont want to include kerb ids in a public repo +-- INSERT INTO approver_type_employee(approver_type_id, employee_kerberos) VALUES (3, 'financeheadkerb'); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (1, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (1, 2, 1); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (2, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (2, 2, 1); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (3, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (3, 2, 1); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (4, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (4, 2, 1); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 2, 1); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (5, 3, 2); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 2, 1); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (6, 3, 2); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 1, 0); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 2, 1); +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (7, 3, 2); + +INSERT INTO funding_source_approver(funding_source_id, approver_type_id, approval_order) VALUES (8, 1, 0); diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql new file mode 100644 index 0000000..7f56cc3 --- /dev/null +++ b/deploy/db-entrypoint/004-settings.sql @@ -0,0 +1 @@ +INSERT INTO settings(key, value, label, description, input_type, categories) VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-request-form", "admin-settings"}'); diff --git a/src/api/admin/index.js b/src/api/admin/index.js new file mode 100644 index 0000000..1fca78d --- /dev/null +++ b/src/api/admin/index.js @@ -0,0 +1,26 @@ +import express from 'express'; +import settings from './settings.js'; + + +const router = express.Router(); + + +// The entire /api/admin section is protected by the hasAdminAccess AccessToken method +router.use((req, res, next) => { + const auth = req.auth; + if ( !auth?.token || !auth.token.hasAdminAccess ) { + res.status(403).json({ + error: true, + message: 'Not authorized to access this resource.' + }); + return; + } + next(); +}); + +// admin api routes +settings(router); + +export default (app) => { + app.use('/admin', router); +} diff --git a/src/api/admin/settings.js b/src/api/admin/settings.js new file mode 100644 index 0000000..88ece8b --- /dev/null +++ b/src/api/admin/settings.js @@ -0,0 +1,20 @@ +import settings from "../../lib/db-models/settings.js"; +export default (api) => { + + api.get('/settings', async (req, res) => { + + //const test = await settings.getByCategory('admin-settings'); + const settingsObj = { + settingsId: 1, + key: 'mileage_rate', + value: '0.58' + }; + const settingsObj2 = { + settingsId: 1, + key: 'mileage_rate', + useDefaultValue: true + } + const test = await settings.updateSettings([settingsObj, settingsObj2]); + res.json({test}); + }); +}; diff --git a/src/api/index.js b/src/api/index.js index fcc025a..399ae11 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -2,6 +2,7 @@ import express from 'express'; import config from '../lib/serverConfig.js'; import auth from './auth.js'; +import admin from './admin/index.js'; import department from './department.js'; import employee from './employee.js'; @@ -12,6 +13,7 @@ if ( config.auth.requireAuth ) { } // routes +admin(router); department(router); employee(router); diff --git a/src/lib/db-models/settings.js b/src/lib/db-models/settings.js new file mode 100644 index 0000000..10729fb --- /dev/null +++ b/src/lib/db-models/settings.js @@ -0,0 +1,94 @@ +import pg from "./pg.js"; +import EntityFields from "../utils/EntityFields.js"; + +class Settings { + + constructor(){ + this.entityFields = new EntityFields([ + {dbName: 'settings_id', jsonName: 'settingsId'}, + {dbName: 'key', jsonName: 'key'}, + {dbName: 'value', jsonName: 'value', userEditable: true}, + {dbName: 'label', jsonName: 'label'}, + {dbName: 'description', jsonName: 'description'}, + {dbName: 'default_value', jsonName: 'defaultValue'}, + {dbName: 'use_default_value', jsonName: 'useDefaultValue', userEditable: true}, + {dbName: 'keywords', jsonName: 'keywords'}, + {dbName: 'settings_page_order', jsonName: 'settingsPageOrder'}, + {dbName: 'input_type', jsonName: 'inputType'}, + {dbName: 'categories', jsonName: 'categories'}, + {dbName: 'can_be_html', jsonName: 'canBeHtml'} + ]); + } + + /** + * @description Get settings object by key + * @param {String} key - key of the setting + * @param {Boolean} single - return single object or array + * @returns {Object|Array} + */ + async getByKey(key, single=true){ + const res = await pg.query(`SELECT * FROM settings WHERE key = $1`, [key]); + if( res.error ) return res; + const data = this.entityFields.toJsonArray(res.res.rows); + if( single ) { + return data[0] || null; + } + return data; + } + + /** + * @description Get settings objects by category + * @param {String} categories - category of the setting + * if multiple categories are provided, settings with any of the categories will be returned + * @returns {Array} + */ + async getByCategory(...categories){ + const res = await pg.query(`SELECT * FROM settings WHERE categories && $1`, [categories]); + if( res.error ) return res; + return this.entityFields.toJsonArray(res.res.rows); + } + + /** + * @description Update an array of settings as a single transaction + * @param {Array} settings - array of settings objects. only userEditable fields will be updated + * @returns {Object} {error: false} + */ + async updateSettings(settings){ + if ( settings && !Array.isArray(settings) ) settings = [settings]; + if ( !settings || !settings.length ) return pg.returnError('No settings provided'); + + const out = {error: false}; + const client = await pg.pool.connect(); + try { + await client.query('BEGIN'); + for( const setting of settings ){ + let sql = 'UPDATE settings SET '; + const valueMap = {} + for( const field of this.entityFields.fields ){ + if ( !field.userEditable ) continue; + if ( setting.hasOwnProperty(field.jsonName) ) { + valueMap[field.dbName] = setting[field.jsonName]; + } + } + if ( Object.keys(valueMap).length === 0 ) { + // no user editable fields provided, skip this setting + continue; + } + const updateClause = pg.toUpdateClause(valueMap); + sql += `${updateClause.sql} WHERE settings_id = $${updateClause.values.length + 1}`; + const values = [...updateClause.values, setting.settingsId]; + await client.query(sql, values); + } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + out.error = error; + } finally { + client.release(); + } + return out; + + } +} + +export default new Settings(); diff --git a/src/lib/utils/EntityFields.js b/src/lib/utils/EntityFields.js new file mode 100644 index 0000000..5e397c1 --- /dev/null +++ b/src/lib/utils/EntityFields.js @@ -0,0 +1,61 @@ +/** + * @class EntityFields + * @description Used to define fields of an entity (usually a database table) + * @param {Array} fields - array of field objects with the following properties: + * - dbName {String} - name of the field in the database (should be snake_case) + * - jsonName {String} - name of the field in JSON responses (should be camelCase) + */ +export default class EntityFields { + constructor(fields = []){ + this.fields = fields; + } + + /** + * @description Convert an object with database field names to an object with JSON field names + * @param {Object} obj - object with database field names + * @returns {Object} + */ + toJsonObj(obj={}) { + const out = {}; + + this.fields.forEach(field => { + if ( !field.jsonName || !field.dbName || !obj.hasOwnProperty(field.dbName) ) return; + out[field.jsonName] = obj[field.dbName]; + }); + + return out; + } + + /** + * @description Convert an array of objects with database field names to an array of objects with JSON field names + * @param {Array} arr - array of objects with database field names + * @returns {Array} + */ + toJsonArray(arr=[]) { + return arr.map(obj => this.toJsonObj(obj)); + } + + /** + * @description Convert an object with JSON field names to an object with database field names + * @param {Object} obj - object with JSON field names + * @returns {Object} + */ + toDbObj(obj={}) { + const out = {}; + + this.fields.forEach(field => { + if ( !field.jsonName || !field.dbName || !obj.hasOwnProperty(field.jsonName) ) return; + out[field.dbName] = obj[field.jsonName]; + }); + + return out; + } + + /** + * @description Convert an array of objects with json field names to an array of objects with database field names + * @param {Array} arr - array of objects with JSON field names + */ + toDbArray(arr=[]) { + return arr.map(obj => this.toDbObj(obj)); + } +} From 1c429463379d0247b3072047b655f61bb9dfad7f Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Fri, 26 Apr 2024 11:10:14 -0400 Subject: [PATCH 010/274] #26 --- src/api/admin/index.js | 13 --- src/api/admin/settings.js | 44 ++++++--- src/client/js/app-main.js | 1 + .../js/pages/admin/app-page-admin-settings.js | 2 +- src/lib/cork/models/SettingsModel.js | 90 +++++++++++++++++++ src/lib/cork/services/SettingsService.js | 39 ++++++++ src/lib/cork/stores/SettingsStore.js | 73 +++++++++++++++ src/lib/db-models/settings.js | 4 + src/lib/utils/apiUtils.js | 8 ++ 9 files changed, 247 insertions(+), 27 deletions(-) create mode 100644 src/lib/cork/models/SettingsModel.js create mode 100644 src/lib/cork/services/SettingsService.js create mode 100644 src/lib/cork/stores/SettingsStore.js diff --git a/src/api/admin/index.js b/src/api/admin/index.js index 1fca78d..89b9e9b 100644 --- a/src/api/admin/index.js +++ b/src/api/admin/index.js @@ -5,19 +5,6 @@ import settings from './settings.js'; const router = express.Router(); -// The entire /api/admin section is protected by the hasAdminAccess AccessToken method -router.use((req, res, next) => { - const auth = req.auth; - if ( !auth?.token || !auth.token.hasAdminAccess ) { - res.status(403).json({ - error: true, - message: 'Not authorized to access this resource.' - }); - return; - } - next(); -}); - // admin api routes settings(router); diff --git a/src/api/admin/settings.js b/src/api/admin/settings.js index 88ece8b..6d11541 100644 --- a/src/api/admin/settings.js +++ b/src/api/admin/settings.js @@ -1,20 +1,38 @@ import settings from "../../lib/db-models/settings.js"; +import apiUtils from "../../lib/utils/apiUtils.js"; +import protect from "../../lib/protect.js"; + export default (api) => { - api.get('/settings', async (req, res) => { + /** + * @description Get array of settings by category + */ + api.get('/settings/category/:category', protect('hasBasicAccess'), async (req, res) => { + const category = req.params.category; + + if ( !category ) return res.status(400).json({error: true, message: 'Category is required.'}); + + const data = await settings.getByCategory(category); + if ( data.error ) { + console.error('Error in /settings/:category', data.error); + return res.status(500).json({error: true, message: 'Error getting settings.'}); + } + res.json(data); + }); - //const test = await settings.getByCategory('admin-settings'); - const settingsObj = { - settingsId: 1, - key: 'mileage_rate', - value: '0.58' - }; - const settingsObj2 = { - settingsId: 1, - key: 'mileage_rate', - useDefaultValue: true + /** + * @description Update an array of settings + */ + api.put('/settings', protect('hasAdminAccess'), async (req, res) => { + const settingsData = req.body; + if ( apiUtils.isArrayOfObjects(settingsData) ){ + return res.status(400).json({error: true, message: 'Settings data must be an array of objects.'}); + } + const data = await settings.updateSettings(settingsData); + if ( data.error ) { + console.error('Error in PUT /settings', data.error); + return res.status(500).json({error: true, message: 'Error updating settings.'}); } - const test = await settings.updateSettings([settingsObj, settingsObj2]); - res.json({test}); + res.json({error: false}); }); }; diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 1662430..92421cd 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -25,6 +25,7 @@ AppStateModel.init(appConfig.routes); // import cork models import "../../lib/cork/models/DepartmentModel.js"; import "../../lib/cork/models/EmployeeModel.js"; +import "../../lib/cork/models/SettingsModel.js"; // auth import Keycloak from 'keycloak-js'; diff --git a/src/client/js/pages/admin/app-page-admin-settings.js b/src/client/js/pages/admin/app-page-admin-settings.js index 9cd30fa..c34d0b3 100644 --- a/src/client/js/pages/admin/app-page-admin-settings.js +++ b/src/client/js/pages/admin/app-page-admin-settings.js @@ -16,7 +16,7 @@ export default class AppPageAdminSettings extends Mixin(LitElement) super(); this.render = render.bind(this); - this._injectModel('AppStateModel'); + this._injectModel('AppStateModel', 'SettingsModel'); } /** diff --git a/src/lib/cork/models/SettingsModel.js b/src/lib/cork/models/SettingsModel.js new file mode 100644 index 0000000..97122a3 --- /dev/null +++ b/src/lib/cork/models/SettingsModel.js @@ -0,0 +1,90 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import SettingsService from '../services/SettingsService.js'; +import SettingsStore from '../stores/SettingsStore.js'; + +class SettingsModel extends BaseModel { + + constructor() { + super(); + + this.store = SettingsStore; + this.service = SettingsService; + + this.register('SettingsModel'); + } + + /** + * @description Get settings by category + * @param {String} category - slug of category to get settings for + * can be found in 'categories' column of settings table + */ + async getByCategory(category) { + let state = this.store.data.byCategory[category]; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.getByCategory(category); + } + } catch(e) {} + return this.store.data.byCategory[category]; + } + + /** + * @description Clear browser cache for all categories or a specific category + * @param {String} category - category to clear cache for, if not provided all categories will be cleared + * @returns {Boolean} true if cache was cleared, false if category cache did not exist + */ + clearCategoryCache(category) { + if ( !category ) { + this.store.data.byCategory = {}; + return true; + } + if ( this.store.data.byCategory[category] ) { + delete this.store.data.byCategory[category]; + return true; + } + return false; + } + + /** + * @description Update settings + * @param {Array} payload - array of settings objects to update + */ + async updateSettings(payload) { + payload = Array.isArray(payload) ? payload : [payload]; + let state = this.store.data.lastUpdate; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.updateSettings(payload); + } + } catch(e) {} + + const out = this.store.data.lastUpdate; + + // clear cache for categories that were updated + // reload categories that were updated and previously loaded + if ( this.store.data.lastUpdate.state === 'loaded' ) { + const categories = new Set(); + payload.forEach(setting => { + (setting.categories || []).forEach(category => { + categories.add(category); + }); + }); + categories.forEach(category => { + const hadCache = this.clearCategoryCache(category); + if ( hadCache ) { + this.getByCategory(category); + } + }); + } + + return out; + } + +} + +const model = new SettingsModel(); +export default model; diff --git a/src/lib/cork/services/SettingsService.js b/src/lib/cork/services/SettingsService.js new file mode 100644 index 0000000..ec78d6d --- /dev/null +++ b/src/lib/cork/services/SettingsService.js @@ -0,0 +1,39 @@ +import BaseService from './BaseService.js'; +import SettingsStore from '../stores/SettingsStore.js'; + +class SettingsService extends BaseService { + + constructor() { + super(); + this.store = SettingsStore; + } + + getByCategory(category){ + return this.request({ + url : `/api/admin/settings/category/${category}`, + checkCached: () => this.store.data.byCategory[category], + onLoading : request => this.store.byCategoryLoading(request, category), + onLoad : result => this.store.byCategoryLoaded(result.body, category), + onError : e => this.store.byCategoryError(e, category) + }); + } + + updateSettings(payload) { + return this.request({ + url : '/api/admin/settings', + fetchOptions : { + method : 'PUT', + body : payload + }, + json: true, + onLoading : request => this.store.updateLoading(request), + onLoad : result => this.store.updateLoaded(result.body), + onError : e => this.store.updateError(e) + }); + + } + +} + +const service = new SettingsService(); +export default service; diff --git a/src/lib/cork/stores/SettingsStore.js b/src/lib/cork/stores/SettingsStore.js new file mode 100644 index 0000000..d3ea7ec --- /dev/null +++ b/src/lib/cork/stores/SettingsStore.js @@ -0,0 +1,73 @@ +import {BaseStore} from '@ucd-lib/cork-app-utils'; + +class SettingsStore extends BaseStore { + + constructor() { + super(); + + this.data = { + byCategory: {}, + lastUpdate: {} + }; + this.events = { + CATEGORY_FETCHED: 'settings-category-fetched', + SETTINGS_UPDATED: 'settings-updated' + }; + } + + byCategoryLoading(request, category) { + this._setByCategoryState({ + state : this.STATE.LOADING, + request + }, category); + } + + byCategoryLoaded(payload, category) { + this._setByCategoryState({ + state : this.STATE.LOADED, + payload + }, category); + } + + byCategoryError(error, category) { + this._setByCategoryState({ + state : this.STATE.ERROR, + error + }, category); + } + + _setByCategoryState(state, category) { + this.data.byCategory[category] = state; + this.emit(this.events.CATEGORY_FETCHED, state); + } + + updateLoading(request) { + this._setUpdateState({ + state : this.STATE.LOADING, + request + }); + } + + updateLoaded(payload) { + this._setUpdateState({ + state : this.STATE.LOADED, + payload + }); + } + + updateError(error) { + this._setUpdateState({ + state : this.STATE.ERROR, + error + }); + } + + _setUpdateState(state) { + this.data.lastUpdate = state; + this.emit(this.events.SETTINGS_UPDATED, state); + } + +} + +const store = new SettingsStore(); +export default store; diff --git a/src/lib/db-models/settings.js b/src/lib/db-models/settings.js index 10729fb..b4eb5f2 100644 --- a/src/lib/db-models/settings.js +++ b/src/lib/db-models/settings.js @@ -1,6 +1,10 @@ import pg from "./pg.js"; import EntityFields from "../utils/EntityFields.js"; +/** + * @class Settings + * @description Model for settings table where application settings are stored such as custom html to display on a page + */ class Settings { constructor(){ diff --git a/src/lib/utils/apiUtils.js b/src/lib/utils/apiUtils.js index a4ec4e1..c822960 100644 --- a/src/lib/utils/apiUtils.js +++ b/src/lib/utils/apiUtils.js @@ -9,6 +9,14 @@ class ApiUtils { const page = parseInt(req.query.page); return isNaN(page) ? 1 : page; } + + /** + * @description Check if value is an array of objects + */ + isArrayOfObjects(arr){ + if ( !Array.isArray(arr) ) return false; + return arr.every(item => typeof item === 'object'); + } } export default new ApiUtils(); From de5c44046a1a11c5503d72c971ed1d963946f3ea Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Fri, 26 Apr 2024 14:56:53 -0400 Subject: [PATCH 011/274] #27 --- .../js/pages/admin/app-page-admin-settings.js | 66 ++++++++++++++++++- .../admin/app-page-admin-settings.tpl.js | 51 +++++++++++++- src/client/scss/{custom.scss => global.scss} | 0 src/client/scss/pages/admin/settings.scss | 20 ++++++ src/client/scss/style.scss | 3 +- src/lib/cork/models/AppStateModel.js | 4 +- src/lib/cork/models/SettingsModel.js | 22 +++++++ src/lib/cork/stores/SettingsStore.js | 19 +++--- 8 files changed, 172 insertions(+), 13 deletions(-) rename src/client/scss/{custom.scss => global.scss} (100%) create mode 100644 src/client/scss/pages/admin/settings.scss diff --git a/src/client/js/pages/admin/app-page-admin-settings.js b/src/client/js/pages/admin/app-page-admin-settings.js index c34d0b3..a37d7ab 100644 --- a/src/client/js/pages/admin/app-page-admin-settings.js +++ b/src/client/js/pages/admin/app-page-admin-settings.js @@ -8,7 +8,8 @@ export default class AppPageAdminSettings extends Mixin(LitElement) static get properties() { return { - + settings: {type: Array}, + searchString: {type: String} } } @@ -16,6 +17,10 @@ export default class AppPageAdminSettings extends Mixin(LitElement) super(); this.render = render.bind(this); + this.settingsCategory = 'admin-settings'; + this.settings = []; + this.searchString = ''; + this._injectModel('AppStateModel', 'SettingsModel'); } @@ -25,6 +30,7 @@ export default class AppPageAdminSettings extends Mixin(LitElement) */ async _onAppStateUpdate(state) { if ( this.id !== state.page ) return; + this.AppStateModel.showLoading(); this.AppStateModel.setTitle('General Settings'); @@ -34,6 +40,64 @@ export default class AppPageAdminSettings extends Mixin(LitElement) this.AppStateModel.store.breadcrumbs[this.id] ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); + + this.SettingsModel.getByCategory(this.settingsCategory); + } + + /** + * @description bound to SettingsModel settings-category-fetched event + * @param {Object} e - cork-app-utils state where payload is an array of settings objects + */ + _onSettingsCategoryFetched(e) { + if ( e.category !== this.settingsCategory ) return; + if ( e.state === 'loaded' ) { + this.searchString = ''; + this._setSettingsProperty(e.payload); + this.AppStateModel.showLoaded(this.id) + } else if ( e.state === 'error' ) { + this.AppStateModel.showError(e, 'Unable to load settings.'); + } + } + + /** + * @description sets the element's settings property + * which is used to render the inputs for the settings form on the page + */ + _setSettingsProperty(settings) { + if ( !settings || !Array.isArray(settings) ) { + this.settings = []; + return; + } + settings = settings.map(setting => { + setting = {...setting}; + setting.hidden = false; + setting.updated = false; + return setting; + }).sort((a, b) => { + if ( a.settingsPageOrder === b.settingsPageOrder ) return 0; + return a.settingsPageOrder < b.settingsPageOrder ? -1 : 1; + }); + + this.settings = settings; + console.log('settings', settings); + } + + _onSettingDefaultToggle(settingsId){ + let setting = this.settings.find(s => s.settingsId === settingsId); + if ( !setting ) return; + setting.useDefaultValue = !setting.useDefaultValue; + setting.updated = true; + this.requestUpdate(); + console.log('setting', setting); + } + + _onSettingValueChange(settingsId, value){ + let setting = this.settings.find(s => s.settingsId === settingsId); + if ( !setting ) return; + setting.value = value; + setting.updated = true; + this.requestUpdate(); + console.log('setting', setting); } } diff --git a/src/client/js/pages/admin/app-page-admin-settings.tpl.js b/src/client/js/pages/admin/app-page-admin-settings.tpl.js index 6b4d94e..57aa796 100644 --- a/src/client/js/pages/admin/app-page-admin-settings.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-settings.tpl.js @@ -1,7 +1,54 @@ import { html } from 'lit'; -export function render() { +export function render() { return html` +
+
+ ${this.settings.map(setting => renderSetting.call(this, setting))} +
+
+`;} +function renderSetting(setting){ + if ( !setting || !setting.settingsId ) return html``; + const inputId = `setting-${setting.settingsId}`; + const defaultValueId = `${inputId}-default-value`; + const value = setting.useDefaultValue ? setting.defaultValue : setting.value; -`;} \ No newline at end of file + let input = html` + this._onSettingValueChange(setting.settingsId, e.target.value)} + /> + `; + if ( setting.inputType == 'textarea' ) { + input = html` + + `; + } + + return html` +
+
+ +
+ this._onSettingDefaultToggle(setting.settingsId)} + .checked=${setting.useDefaultValue} /> + +
+
+ ${input} +
${setting.description}
+
+ `; +} diff --git a/src/client/scss/custom.scss b/src/client/scss/global.scss similarity index 100% rename from src/client/scss/custom.scss rename to src/client/scss/global.scss diff --git a/src/client/scss/pages/admin/settings.scss b/src/client/scss/pages/admin/settings.scss new file mode 100644 index 0000000..37b1a18 --- /dev/null +++ b/src/client/scss/pages/admin/settings.scss @@ -0,0 +1,20 @@ +app-page-admin-settings { + .setting-header { + display: flex; + justify-content: space-between; + align-items: center; + } + .setting-header label { + flex-grow: 1; + } + .setting-description { + font-size: .875rem; + color: #4C4C4C; + } + @media (max-width: 500px) { + .l-container--narrow { + width: 100%; + } + } + +} diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index c977ce8..5028111 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -1,3 +1,4 @@ @use '@ucd-lib/theme-sass/style-ucdlib.scss' as style; @use './fonts.scss' as fonts; -@use './custom.scss' as custom; +@use './global.scss' as global; +@use './pages/admin/settings.scss' as adminSettings; diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index 8bbcc00..a0d0c6d 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -192,7 +192,7 @@ class AppStateModelImpl extends AppStateModel { * @description Show the app's error page * @param {String|Object} msg Error message to show or cork-app-utils response object */ - showError(msg=''){ + showError(msg='', fallbackMessage=''){ let errorMessage = '' if ( typeof msg === 'object' ) { console.error(msg); @@ -204,6 +204,8 @@ class AppStateModelImpl extends AppStateModel { errorMessage = 'You are not authorized to view this page'; }else if ( msg?.error?.message ) { errorMessage = msg?.error?.message; + } else { + errorMessage = fallbackMessage; } } else { errorMessage = msg; diff --git a/src/lib/cork/models/SettingsModel.js b/src/lib/cork/models/SettingsModel.js index 97122a3..52810ed 100644 --- a/src/lib/cork/models/SettingsModel.js +++ b/src/lib/cork/models/SettingsModel.js @@ -30,6 +30,28 @@ class SettingsModel extends BaseModel { return this.store.data.byCategory[category]; } + /** + * @description Get a settings value by key from the settings store + * It must have aleady been fetched as part of a getByCategory call + * @param {String} key - key of setting to get + * @param {String} defaultValue - default value to return if setting does not exist + * @returns {String} setting value or default value if setting does not exist + */ + getByKey(key, defaultValue=''){ + for( let category in this.store.data.byCategory ) { + let settings = this.store.data.byCategory[category].payload; + if( settings && Array.isArray(settings) ) { + for( let setting of settings ) { + if( setting.key === key ) { + return setting.useDefaultValue ? setting.defaultValue : setting.value; + } + } + } + } + return defaultValue; + + } + /** * @description Clear browser cache for all categories or a specific category * @param {String} category - category to clear cache for, if not provided all categories will be cleared diff --git a/src/lib/cork/stores/SettingsStore.js b/src/lib/cork/stores/SettingsStore.js index d3ea7ec..9d017ec 100644 --- a/src/lib/cork/stores/SettingsStore.js +++ b/src/lib/cork/stores/SettingsStore.js @@ -18,26 +18,29 @@ class SettingsStore extends BaseStore { byCategoryLoading(request, category) { this._setByCategoryState({ state : this.STATE.LOADING, - request - }, category); + request, + category + }); } byCategoryLoaded(payload, category) { this._setByCategoryState({ state : this.STATE.LOADED, - payload - }, category); + payload, + category + }); } byCategoryError(error, category) { this._setByCategoryState({ state : this.STATE.ERROR, - error - }, category); + error, + category + }); } - _setByCategoryState(state, category) { - this.data.byCategory[category] = state; + _setByCategoryState(state) { + this.data.byCategory[state.category] = state; this.emit(this.events.CATEGORY_FETCHED, state); } From 8c11ee838e29a778cc88287d7e082fbdfd0f3031 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Fri, 26 Apr 2024 20:18:16 -0700 Subject: [PATCH 012/274] initial toast component --- src/client/js/app-main.js | 4 + src/client/js/app-main.tpl.js | 3 +- src/client/js/components/travel-toast.js | 80 ++++++++++++++++++++ src/client/js/components/travel-toast.tpl.js | 13 ++++ src/client/js/pages/app-page-home.js | 28 ++++++- src/client/js/pages/app-page-home.tpl.js | 11 +++ src/client/scss/custom.scss | 63 +++++++++++++++ src/lib/cork/models/AppStateModel.js | 42 +++++++--- src/lib/cork/stores/AppStateStore.js | 2 +- 9 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 src/client/js/components/travel-toast.js create mode 100644 src/client/js/components/travel-toast.tpl.js diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index feae5f5..9fd9246 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -32,9 +32,13 @@ Registry.ready(); // registry of app page bundles - pages are dynamically loaded on appStateUpdate import bundles from "./pages/bundles/index.js"; + import "./pages/app-page-alt-state.js"; import "./pages/app-page-home.js"; +// global components +import "./components/travel-toast.js" + /** * @class AppMain * @description The main app web component, which controls routing and other app-level functionality. diff --git a/src/client/js/app-main.tpl.js b/src/client/js/app-main.tpl.js index 8c8ca94..d08b600 100644 --- a/src/client/js/app-main.tpl.js +++ b/src/client/js/app-main.tpl.js @@ -28,7 +28,7 @@ return html` Line Items - +

${this.pageTitle}

@@ -40,7 +40,6 @@ return html` `)} - diff --git a/src/client/js/components/travel-toast.js b/src/client/js/components/travel-toast.js new file mode 100644 index 0000000..127b0d9 --- /dev/null +++ b/src/client/js/components/travel-toast.js @@ -0,0 +1,80 @@ +import { LitElement } from 'lit'; +import {render} from "./travel-toast.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + + +/** + * @description Component for handling sitewide toast + * + + this.AppStateModel.showToast(object); + */ +export default class TravelToast extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + item: {type: Object, attribute: 'item'}, + text: {type: String, attribute: 'text'}, + type: {type: String, attribute: 'type'}, + nopopup:{type: Boolean, attribute: 'nopopup'}, + amount: {type: Number, attribute: 'amount'}, + hidden: {type: Boolean}, + + } + } + + constructor() { + super(); + this.render = render.bind(this); + this.hidden=true; + this.popup=false; + this.queue = []; + // this.item = {}; + this._injectModel('AppStateModel'); + + } + + /** + * @method _onAppStateUpdate + * @description bound to AppStateModel app-state-update event + * + * @param {Object} e + */ + async _onAppStateUpdate() { + // this.hidden = true; + + + } + + /** + * @description Attached to AppStateModel toast-update event + * @param {Object} options + */ + _onToastUpdate(items){ + this.hidden = false; + + for (let i in items){ + + setTimeout(() => { + document.querySelector(".toast").classList.add("movein"); + let item = items.shift(); + this.item = Object.assign({}, this.item, item); + if ( !this.item.message ) return; + + this.text = this.item.message; + this.type = this.item.type || 'information'; + + this.AppStateModel.queueAmount--; + if(this.AppStateModel.queueAmount == 0) { + setTimeout(() => { + document.querySelector(".toast").classList.add("moveout"); + }, 5000 ); + } + }, 5000 * i ); + } + } +} + +customElements.define('travel-toast', TravelToast); \ No newline at end of file diff --git a/src/client/js/components/travel-toast.tpl.js b/src/client/js/components/travel-toast.tpl.js new file mode 100644 index 0000000..925ec32 --- /dev/null +++ b/src/client/js/components/travel-toast.tpl.js @@ -0,0 +1,13 @@ +import { html } from 'lit'; + +export function render() { +return html` + +${!this.nopopup ? html` +
+

${this.text}

+
+`:html``} + + +`;} \ No newline at end of file diff --git a/src/client/js/pages/app-page-home.js b/src/client/js/pages/app-page-home.js index 7f88060..54c083e 100644 --- a/src/client/js/pages/app-page-home.js +++ b/src/client/js/pages/app-page-home.js @@ -2,12 +2,13 @@ import { LitElement } from 'lit'; import { render } from "./app-page-home.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - + export default class AppPageHome extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { static get properties() { return { + toastActive: {type: Boolean, attribute: 'toastActive'}, } } @@ -15,10 +16,13 @@ export default class AppPageHome extends Mixin(LitElement) constructor() { super(); this.render = render.bind(this); - + this.toastActive = false; this._injectModel('AppStateModel'); + + } + /** * @description bound to AppStateModel app-state-update event * @param {Object} state - AppStateModel state @@ -49,6 +53,26 @@ export default class AppPageHome extends Mixin(LitElement) return resolvedPromises; } + + /** + * @description For testing toast component + */ + async _makeToastActive(){ + console.log("Check Home Page Component to make changes") + /* Pushing in object with multiple messages */ + // let practice = [{"message": "Samplessss", "type": "information"}, + // {"message": "Samplessss2", "type": "information"}, + // {"message": "Samplessss3", "type": "information"} + // ]; + + /* Pushing in object with single message */ + // let practice = {"message": "Samplessss", "type": "information"}; + + /* Trigger for toast */ + this.AppStateModel.showToast(practice); + } + + } customElements.define('app-page-home', AppPageHome); diff --git a/src/client/js/pages/app-page-home.tpl.js b/src/client/js/pages/app-page-home.tpl.js index 542ca62..726292f 100644 --- a/src/client/js/pages/app-page-home.tpl.js +++ b/src/client/js/pages/app-page-home.tpl.js @@ -6,6 +6,17 @@ return html`

Home Page

+ + + + + +
diff --git a/src/client/scss/custom.scss b/src/client/scss/custom.scss index 3dc9a28..2b52344 100644 --- a/src/client/scss/custom.scss +++ b/src/client/scss/custom.scss @@ -1,3 +1,66 @@ [hidden] { display: none !important; } + +.toast { + width: 200px; + height: Hug(64px); + display:block; + padding: 20px 38px 20px 38px; + border-radius: 15px; + background: #FFFFFF; + box-shadow: 0px 4px 20px 0px #00000033; + text-align:center; + position:relative; + z-index:3; + +} + +.movein { + -webkit-animation: cssInAnimation 1s forwards; + animation: cssInAnimation 1s forwards; +} + +@keyframes cssInAnimation { + 0% {opacity: 0; display:block;} + 33% {opacity: 0.25;} + 66% {opacity: 0.75;} + 100% {opacity: 1;} + +} +@-webkit-keyframes cssInAnimation { + 0% {opacity: 0; display:block;} + 33% {opacity: 0.25;} + 66% {opacity: 0.75;} + 100% {opacity: 1;} +} + + + +.moveout { + -webkit-animation: cssOutAnimation 1s forwards; + animation: cssOutAnimation 1s forwards; +} + +.moveout { + -webkit-animation: cssOutAnimation 1s forwards; + animation: cssOutAnimation 1s forwards; +} + +@keyframes cssOutAnimation { + 0% {opacity: 1;} + 33% {opacity: 0.75;} + 66% {opacity: 0.25;} + 100% {opacity: 0; display:none;} + +} +@-webkit-keyframes cssOutAnimation { + 0% {opacity: 1;} + 33% {opacity: 0.75;} + 66% {opacity: 0.25;} + 100% {opacity: 0; display:none;} +} + +.toast-hidden { + display:none; +} diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index 40b8892..80829b8 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -12,6 +12,8 @@ class AppStateModelImpl extends AppStateModel { this.store = AppStateStore; + this.queuedToast = []; + this.queueAmount = 0; if ( appConfig.auth?.requireAuth ) { this.inject('AuthModel'); } @@ -166,19 +168,39 @@ class AppStateModelImpl extends AppStateModel { this.store.emit('breadcrumb-update', b); } + + /** + * @description Show dismissable toast banner in popup. Will disappear on next app-state-update event + * @param {Object|Array} options Toast message if object, multiple if Array: + * this.queuedToast = [{"message": "Samplessss", "type": "success"}, + * {"message": "Samplessss2", "type": "success"}, + * {"message": "Samplessss3", "type": "success"} + * ]; + * + * let option = {"message": "Samplessss", "type": "success"}; + */ + showToast(option){ + if ( typeof option === 'object' ){ + if(Array.isArray(option)) { + this.queuedToast = option; + } + else { + this.queuedToast.push(option); + } + } else return; + + this.queueAmount = this.queuedToast.length; + this.store.emit('toast-update', this.queuedToast); + + } + /** - * @description Show dismissable alert banner at top of page. Will disappear on next app-state-update event - * @param {Object|String} options Alert message if string, config if object: - * {message: 'alert!' - * brandColor: 'double-decker' - * } + * @description Dismissing all toasts in the queue */ - showAlertBanner(options){ - if ( typeof options === 'string' ){ - options = {message: options}; - } - this.store.emit('alert-banner-update', options); + dismissToast(){ + this.queuedToast= []; + console.log("Queue Has Been Emptied."); } /** diff --git a/src/lib/cork/stores/AppStateStore.js b/src/lib/cork/stores/AppStateStore.js index 60955d4..81f492e 100644 --- a/src/lib/cork/stores/AppStateStore.js +++ b/src/lib/cork/stores/AppStateStore.js @@ -27,7 +27,7 @@ class AppStateStoreImpl extends AppStateStore { this.events.PAGE_STATE_UPDATE = 'page-state-update'; this.events.PAGE_TITLE_UPDATE = 'page-title-update'; this.events.BREADCRUMB_UPDATE = 'breadcrumb-update'; - this.events.ALERT_BANNER_UPDATE = 'alert-banner-update'; + this.events.TOAST_UPDATE = 'toast-update'; } } From 5dcff435ea04cb850bda43647af521f436262db9 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Fri, 26 Apr 2024 20:23:13 -0700 Subject: [PATCH 013/274] initial toast component adding comments --- src/client/js/pages/app-page-home.tpl.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/js/pages/app-page-home.tpl.js b/src/client/js/pages/app-page-home.tpl.js index 726292f..f5eaae5 100644 --- a/src/client/js/pages/app-page-home.tpl.js +++ b/src/client/js/pages/app-page-home.tpl.js @@ -8,7 +8,8 @@ return html`

Home Page

- + + `;} @@ -21,7 +38,7 @@ function renderSetting(setting){ type=${setting.inputType} .value=${value} ?disabled=${setting.useDefaultValue} - @input=${(e) => this._onSettingValueChange(setting.settingsId, e.target.value)} + @input=${(e) => this._onSettingValueInput(setting.settingsId, e.target.value)} /> `; if ( setting.inputType == 'textarea' ) { @@ -30,7 +47,7 @@ function renderSetting(setting){ id="${inputId}" .value=${value} ?disabled=${setting.useDefaultValue} - @input=${(e) => this._onSettingValueChange(setting.settingsId, e.target.value)} + @input=${(e) => this._onSettingValueInput(setting.settingsId, e.target.value)} ?hidden=${setting.useDefaultValue}> `; } diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 3dc9a28..2099b1b 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -1,3 +1,6 @@ [hidden] { display: none !important; } +.pointer { + cursor: pointer; +} diff --git a/src/client/scss/pages/admin/settings.scss b/src/client/scss/pages/admin/settings.scss index 37b1a18..4606214 100644 --- a/src/client/scss/pages/admin/settings.scss +++ b/src/client/scss/pages/admin/settings.scss @@ -11,10 +11,25 @@ app-page-admin-settings { font-size: .875rem; color: #4C4C4C; } + .sticky-update-bar { + position: sticky; + bottom: 0; + background: white; + padding: 1rem; + border-top: 1px solid #E0E0E0; + z-index: 100; + display: flex; + justify-content: center; + box-shadow: 0px -2px 4px rgba(0, 0, 0, 0.1); + } @media (max-width: 500px) { .l-container--narrow { width: 100%; } + .setting-header { + flex-direction: column; + align-items: flex-start; + } } } From 10e4eeb934f61907d0173fabe3902cc5f8557c6a Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Mon, 29 Apr 2024 09:36:41 -0700 Subject: [PATCH 017/274] initial change --- src/api/approver-type.js | 0 src/lib/cork/models/ApproverTypeModel.js | 115 +++++++++++++++++++ src/lib/cork/services/ApproverTypeService.js | 0 src/lib/cork/stores/ApproverTypeStore.js | 0 src/lib/db-models/approver-type.js | 104 +++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 src/api/approver-type.js create mode 100644 src/lib/cork/models/ApproverTypeModel.js create mode 100644 src/lib/cork/services/ApproverTypeService.js create mode 100644 src/lib/cork/stores/ApproverTypeStore.js create mode 100644 src/lib/db-models/approver-type.js diff --git a/src/api/approver-type.js b/src/api/approver-type.js new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/cork/models/ApproverTypeModel.js b/src/lib/cork/models/ApproverTypeModel.js new file mode 100644 index 0000000..5ba28e5 --- /dev/null +++ b/src/lib/cork/models/ApproverTypeModel.js @@ -0,0 +1,115 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import ApproverService from '../services/ApproverService.js'; +import ApproverStore from '../stores/ApproverStore.js'; + +class ApproverModel extends BaseModel { + + constructor() { + super(); + + this.store = ApproverStore; + this.service = ApproverService; + + this.register('ApproverModel'); + } + + /** + * @description Query approvers + * @param {String} args - an object with possible properties + * id(s) single or array of ids + * archived - archive approvers + * active - active approvers + * + */ + async query(args) { + let state = this.store.data.query[args];; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.query(args); + } + } catch(e) {} + return this.store.data.query[args]; + } + + /** + * @description Create data of approvers + * @param {String} data - data to create a new approvers + */ + + create(data) { + payload = Array.isArray(payload) ? payload : [payload]; + let state = this.store.data.lastUpdate; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.updateSettings(payload); + } + } catch(e) {} + + const out = this.store.data.lastUpdate; + + // clear cache for categories that were updated + // reload categories that were updated and previously loaded + if ( this.store.data.lastUpdate.state === 'loaded' ) { + const categories = new Set(); + payload.forEach(setting => { + (setting.categories || []).forEach(category => { + categories.add(category); + }); + }); + categories.forEach(category => { + const hadCache = this.clearCategoryCache(category); + if ( hadCache ) { + this.getByCategory(category); + } + }); + } + + return out; + } + + /** + * @description Update data of approvers + * @param {String} data - data to update for approvers + */ + async update(data) { + payload = Array.isArray(payload) ? payload : [payload]; + let state = this.store.data.lastUpdate; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.updateSettings(payload); + } + } catch(e) {} + + const out = this.store.data.lastUpdate; + + // clear cache for categories that were updated + // reload categories that were updated and previously loaded + if ( this.store.data.lastUpdate.state === 'loaded' ) { + const categories = new Set(); + payload.forEach(setting => { + (setting.categories || []).forEach(category => { + categories.add(category); + }); + }); + categories.forEach(category => { + const hadCache = this.clearCategoryCache(category); + if ( hadCache ) { + this.getByCategory(category); + } + }); + } + + return out; + } + + +} + +const model = new ApproverModel(); +export default model; diff --git a/src/lib/cork/services/ApproverTypeService.js b/src/lib/cork/services/ApproverTypeService.js new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/cork/stores/ApproverTypeStore.js b/src/lib/cork/stores/ApproverTypeStore.js new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/db-models/approver-type.js b/src/lib/db-models/approver-type.js new file mode 100644 index 0000000..ed3a854 --- /dev/null +++ b/src/lib/db-models/approver-type.js @@ -0,0 +1,104 @@ +import cache from "./cache.js"; +import pg from "./pg.js"; + +/** + * @class Approver Type + * @description Class for querying data about approver information + */ +class ApproverType { + + + constructor(){ + this.entityFields = new EntityFields([ + {dbName: 'approver_type_id', jsonName: 'approverTypeId'}, + {dbName: 'label', jsonName: 'label'}, + {dbName: 'description', jsonName: 'description', userEditable: true}, + {dbName: 'system_generated', jsonName: 'systemGenerated'}, + {dbName: 'description', jsonName: 'description'}, + {dbName: 'hide_from_fund_assignment', jsonName: 'hideFromFundAssignment'}, + {dbName: 'archived', jsonName: 'archived', userEditable: true}, + ]); + } + /** + * @description Query the approver type + * @param {Object} args - object of ids, archive, active + * @returns {Object|Array} + * + * all props in approver_type camelcased + * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee + */ + async query(args={}){ + let res; + + if(Array.isArray(args.id)) { + let v = pg.valuesArray(args.id); + res = await pg.query(`SELECT * FROM settings WHERE approver_type_id in $1 AND archived=$2`, [v, args.archived]); + + } else { + res = await pg.query(`SELECT * FROM settings WHERE approver_type_id in $1 AND archived=$2`, [args.id, args.archived]); + } + + if( res.error ) return res; + const data = this.entityFields.toJsonArray(res.res.rows); + if( single ) { + return data[0] || null; + } + return data; + } + + /** + * @description Create the approver type table + * @param {Object} data - approverType object including list of employees + * @returns {Object} {error: false} + */ + async create(data){ + const res = await pg.query(`SELECT * FROM settings WHERE categories && $1`, [categories]); + if( res.error ) return res; + return this.entityFields.toJsonArray(res.res.rows); + } + + /** + * @description Update the approver type table + * @param {Object} data - approverType object including list of employees + * use a transaction if changes are needed to the employee list + * @returns {Object} {error: false} + */ + async update(data){ + if ( settings && !Array.isArray(settings) ) settings = [settings]; + if ( !settings || !settings.length ) return pg.returnError('No settings provided'); + + const out = {error: false}; + const client = await pg.pool.connect(); + try { + await client.query('BEGIN'); + for( const setting of settings ){ + let sql = 'UPDATE settings SET '; + const valueMap = {} + for( const field of this.entityFields.fields ){ + if ( !field.userEditable ) continue; + if ( setting.hasOwnProperty(field.jsonName) ) { + valueMap[field.dbName] = setting[field.jsonName]; + } + } + if ( Object.keys(valueMap).length === 0 ) { + // no user editable fields provided, skip this setting + continue; + } + const updateClause = pg.toUpdateClause(valueMap); + sql += `${updateClause.sql} WHERE settings_id = $${updateClause.values.length + 1}`; + const values = [...updateClause.values, setting.settingsId]; + await client.query(sql, values); + } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + out.error = error; + } finally { + client.release(); + } + return out; + + } + } + +export default new ApproverType(); From 108070c7708b4128777c78848e669b97998be5e9 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Mon, 29 Apr 2024 15:33:15 -0400 Subject: [PATCH 018/274] fixes to #27 --- deploy/db-entrypoint/004-settings.sql | 4 +++ .../pages/admin/app-page-admin-line-items.js | 25 ++++++++++++++++++- .../admin/app-page-admin-line-items.tpl.js | 8 ++++-- .../js/pages/admin/app-page-admin-settings.js | 6 ++--- .../admin/app-page-admin-settings.tpl.js | 3 ++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index 7f56cc3..e2e8e70 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -1 +1,5 @@ INSERT INTO settings(key, value, label, description, input_type, categories) VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-request-form", "admin-settings"}'); + +-- admin line items page +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index 7b77ab2..c878d88 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -15,8 +15,9 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) constructor() { super(); this.render = render.bind(this); + this.settingsCategory = 'admin-line-items'; - this._injectModel('AppStateModel'); + this._injectModel('AppStateModel', 'SettingsModel'); } /** @@ -25,6 +26,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) */ async _onAppStateUpdate(state) { if ( this.id !== state.page ) return; + this.AppStateModel.showLoading(); this.AppStateModel.setTitle('Line Items'); @@ -34,8 +36,29 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) this.AppStateModel.store.breadcrumbs[this.id] ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); + + try { + const d = await this.getPageData(); + const hasError = d.some(e => e.state === 'error'); + if ( !hasError ) this.AppStateModel.showLoaded(this.id); + this.requestUpdate(); + } catch(e) { + this.AppStateModel.showError(this.id); + } + } + /** + * @description Get any data required for rendering this page + */ + async getPageData(){ + const promises = []; + promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); + const resolvedPromises = await Promise.all(promises); + return resolvedPromises; + } + + } customElements.define('app-page-admin-line-items', AppPageAdminLineItems); diff --git a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js index 6b4d94e..39e4bd1 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js @@ -1,7 +1,11 @@ import { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -export function render() { +export function render() { return html` +
+

${unsafeHTML(this.SettingsModel.getByKey('admin_line_items_description'))}

+
-`;} \ No newline at end of file +`;} diff --git a/src/client/js/pages/admin/app-page-admin-settings.js b/src/client/js/pages/admin/app-page-admin-settings.js index 9a97b35..5f6a74d 100644 --- a/src/client/js/pages/admin/app-page-admin-settings.js +++ b/src/client/js/pages/admin/app-page-admin-settings.js @@ -42,7 +42,7 @@ export default class AppPageAdminSettings extends Mixin(LitElement) */ async _onAppStateUpdate(state) { if ( this.id !== state.page ) return; - this.AppStateModel.showLoading(); + this.SettingsModel.getByCategory(this.settingsCategory); this.AppStateModel.setTitle('General Settings'); @@ -52,8 +52,6 @@ export default class AppPageAdminSettings extends Mixin(LitElement) this.AppStateModel.store.breadcrumbs[this.id] ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); - - this.SettingsModel.getByCategory(this.settingsCategory); } /** @@ -68,6 +66,8 @@ export default class AppPageAdminSettings extends Mixin(LitElement) this.AppStateModel.showLoaded(this.id) } else if ( e.state === 'error' ) { this.AppStateModel.showError(e, 'Unable to load settings.'); + } else if ( e.state === 'loading' ) { + this.AppStateModel.showLoading(); } } diff --git a/src/client/js/pages/admin/app-page-admin-settings.tpl.js b/src/client/js/pages/admin/app-page-admin-settings.tpl.js index f7baac9..49cfbd9 100644 --- a/src/client/js/pages/admin/app-page-admin-settings.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-settings.tpl.js @@ -48,7 +48,8 @@ function renderSetting(setting){ .value=${value} ?disabled=${setting.useDefaultValue} @input=${(e) => this._onSettingValueInput(setting.settingsId, e.target.value)} - ?hidden=${setting.useDefaultValue}> + rows="5" + > `; } From 503ba223455f20927db20021239735bdf4147a5c Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Mon, 29 Apr 2024 15:14:39 -0700 Subject: [PATCH 019/274] requested changes #15 --- src/client/js/app-main.js | 2 +- src/client/js/app-main.tpl.js | 1 + .../{travel-toast.js => app-toast.js} | 49 ++++---- src/client/js/components/app-toast.tpl.js | 119 ++++++++++++++++++ src/client/js/components/travel-toast.tpl.js | 13 -- src/client/js/pages/app-page-home.js | 25 ---- src/client/js/pages/app-page-home.tpl.js | 12 -- src/client/scss/global.scss | 62 --------- src/lib/cork/models/AppStateModel.js | 19 +-- 9 files changed, 151 insertions(+), 151 deletions(-) rename src/client/js/components/{travel-toast.js => app-toast.js} (63%) create mode 100644 src/client/js/components/app-toast.tpl.js delete mode 100644 src/client/js/components/travel-toast.tpl.js diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 811e5d2..158d291 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -40,7 +40,7 @@ import "./pages/app-page-alt-state.js"; import "./pages/app-page-home.js"; // global components -import "./components/travel-toast.js" +import "./components/app-toast.js" /** * @class AppMain diff --git a/src/client/js/app-main.tpl.js b/src/client/js/app-main.tpl.js index d08b600..bc07d25 100644 --- a/src/client/js/app-main.tpl.js +++ b/src/client/js/app-main.tpl.js @@ -58,5 +58,6 @@ return html`
+ `;} diff --git a/src/client/js/components/travel-toast.js b/src/client/js/components/app-toast.js similarity index 63% rename from src/client/js/components/travel-toast.js rename to src/client/js/components/app-toast.js index 127b0d9..7434d64 100644 --- a/src/client/js/components/travel-toast.js +++ b/src/client/js/components/app-toast.js @@ -1,5 +1,5 @@ import { LitElement } from 'lit'; -import {render} from "./travel-toast.tpl.js"; +import {render, styles} from "./app-toast.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; @@ -10,8 +10,8 @@ import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-el this.AppStateModel.showToast(object); */ -export default class TravelToast extends Mixin(LitElement) -.with(LitCorkUtils, MainDomElement) { +export default class AppToast extends Mixin(LitElement) +.with(LitCorkUtils) { static get properties() { return { @@ -21,31 +21,27 @@ export default class TravelToast extends Mixin(LitElement) nopopup:{type: Boolean, attribute: 'nopopup'}, amount: {type: Number, attribute: 'amount'}, hidden: {type: Boolean}, + animation: {type: Boolean}, + time: {type: Number, attribute: 'time'} } } + static get styles() { + return styles(); + } + + constructor() { super(); this.render = render.bind(this); this.hidden=true; this.popup=false; this.queue = []; - // this.item = {}; + this.animations; + this.time = 5000; this._injectModel('AppStateModel'); - } - - /** - * @method _onAppStateUpdate - * @description bound to AppStateModel app-state-update event - * - * @param {Object} e - */ - async _onAppStateUpdate() { - // this.hidden = true; - - } /** @@ -55,26 +51,29 @@ export default class TravelToast extends Mixin(LitElement) _onToastUpdate(items){ this.hidden = false; - for (let i in items){ + this.queue.push(items) + this.queueAmount = this.queue.length; + + for (let i in this.queue){ setTimeout(() => { - document.querySelector(".toast").classList.add("movein"); - let item = items.shift(); + this.animation = true; + let item = this.queue.shift(); this.item = Object.assign({}, this.item, item); if ( !this.item.message ) return; this.text = this.item.message; this.type = this.item.type || 'information'; - this.AppStateModel.queueAmount--; - if(this.AppStateModel.queueAmount == 0) { + this.queueAmount--; + if(this.queueAmount == 0) { setTimeout(() => { - document.querySelector(".toast").classList.add("moveout"); - }, 5000 ); + this.animation = false; + }, this.time ); } - }, 5000 * i ); + }, this.time * i ); } } } -customElements.define('travel-toast', TravelToast); \ No newline at end of file +customElements.define('app-toast', AppToast); \ No newline at end of file diff --git a/src/client/js/components/app-toast.tpl.js b/src/client/js/components/app-toast.tpl.js new file mode 100644 index 0000000..50b97b9 --- /dev/null +++ b/src/client/js/components/app-toast.tpl.js @@ -0,0 +1,119 @@ +import { html, css } from 'lit'; +import {classMap} from 'lit/directives/class-map.js'; + +export function styles() { + const elementStyles = css` + :host { + display: block; + } + + .toast { + width: 200px; + display:block; + padding: 20px 38px 20px 38px; + border-radius: 15px; + background: #FFFFFF; + box-shadow: 0px 4px 20px 0px #00000033; + text-align:center; + position:relative; + z-index:3; + + } + + .movein { + -webkit-animation: cssInAnimation 1s forwards; + animation: cssInAnimation 1s forwards; + } + + @keyframes cssInAnimation { + 0% {opacity: 0; display:block;} + 33% {opacity: 0.25;} + 66% {opacity: 0.75;} + 100% {opacity: 1;} + + } + @-webkit-keyframes cssInAnimation { + 0% {opacity: 0; display:block;} + 33% {opacity: 0.25;} + 66% {opacity: 0.75;} + 100% {opacity: 1;} + } + + + + .moveout { + -webkit-animation: cssOutAnimation 1s forwards; + animation: cssOutAnimation 1s forwards; + } + + .moveout { + -webkit-animation: cssOutAnimation 1s forwards; + animation: cssOutAnimation 1s forwards; + } + + @keyframes cssOutAnimation { + 0% {opacity: 1;} + 33% {opacity: 0.75;} + 66% {opacity: 0.25;} + 100% {opacity: 0; display:none;} + + } + @-webkit-keyframes cssOutAnimation { + 0% {opacity: 1;} + 33% {opacity: 0.75;} + 66% {opacity: 0.25;} + 100% {opacity: 0; display:none;} + } + + .toast-hidden { + display:none; + } + + .icon { + font-size: 20px; + float: left; + } + + .icon-success { + color:green; + } + .icon-info { + color:black; + } + .icon-error { + color:red; + } + + `; + + return [elementStyles]; +} + +export function render() { + const classes = { + "toast": !this.hidden, + "toast-hidden": this.hidden, + "movein": this.animation, + "moveout": !this.animation, + "icon-success": this.type == "success", + "icon-info": this.type == "info", + "icon-error": this.type == "error", + + }; +return html` + +${!this.nopopup ? html` +
+ + + ${this.type == "success" ? html``: + this.type == "error" ? html``: + html``} + + ${this.text} + +
+`:html``} + + +`;} \ No newline at end of file diff --git a/src/client/js/components/travel-toast.tpl.js b/src/client/js/components/travel-toast.tpl.js deleted file mode 100644 index 925ec32..0000000 --- a/src/client/js/components/travel-toast.tpl.js +++ /dev/null @@ -1,13 +0,0 @@ -import { html } from 'lit'; - -export function render() { -return html` - -${!this.nopopup ? html` -
-

${this.text}

-
-`:html``} - - -`;} \ No newline at end of file diff --git a/src/client/js/pages/app-page-home.js b/src/client/js/pages/app-page-home.js index b437f5e..3ea6273 100644 --- a/src/client/js/pages/app-page-home.js +++ b/src/client/js/pages/app-page-home.js @@ -8,15 +8,12 @@ export default class AppPageHome extends Mixin(LitElement) static get properties() { return { - toastActive: {type: Boolean, attribute: 'toastActive'}, - } } constructor() { super(); this.render = render.bind(this); - this.toastActive = false; this._injectModel('AppStateModel'); @@ -54,28 +51,6 @@ export default class AppPageHome extends Mixin(LitElement) } - /** - * @description For testing toast component - */ - async _makeToastActive(){ - console.log("Check Home Page Component to make changes") - /* Pushing in object with multiple messages */ - // let practice = [{"message": "Samplessss", "type": "information"}, - // {"message": "Samplessss2", "type": "information"}, - // {"message": "Samplessss3", "type": "information"} - // ]; - - /* Pushing in object with single message */ - let practice = {"message": "Samplessss", "type": "information"}; - - /* Trigger for toast */ - this.AppStateModel.showToast(practice); - document.querySelector("#toastButton").disabled = true; - - - } - - } customElements.define('app-page-home', AppPageHome); diff --git a/src/client/js/pages/app-page-home.tpl.js b/src/client/js/pages/app-page-home.tpl.js index f5eaae5..542ca62 100644 --- a/src/client/js/pages/app-page-home.tpl.js +++ b/src/client/js/pages/app-page-home.tpl.js @@ -6,18 +6,6 @@ return html`

Home Page

- - - - - -
diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 2b52344..1376bb6 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -2,65 +2,3 @@ display: none !important; } -.toast { - width: 200px; - height: Hug(64px); - display:block; - padding: 20px 38px 20px 38px; - border-radius: 15px; - background: #FFFFFF; - box-shadow: 0px 4px 20px 0px #00000033; - text-align:center; - position:relative; - z-index:3; - -} - -.movein { - -webkit-animation: cssInAnimation 1s forwards; - animation: cssInAnimation 1s forwards; -} - -@keyframes cssInAnimation { - 0% {opacity: 0; display:block;} - 33% {opacity: 0.25;} - 66% {opacity: 0.75;} - 100% {opacity: 1;} - -} -@-webkit-keyframes cssInAnimation { - 0% {opacity: 0; display:block;} - 33% {opacity: 0.25;} - 66% {opacity: 0.75;} - 100% {opacity: 1;} -} - - - -.moveout { - -webkit-animation: cssOutAnimation 1s forwards; - animation: cssOutAnimation 1s forwards; -} - -.moveout { - -webkit-animation: cssOutAnimation 1s forwards; - animation: cssOutAnimation 1s forwards; -} - -@keyframes cssOutAnimation { - 0% {opacity: 1;} - 33% {opacity: 0.75;} - 66% {opacity: 0.25;} - 100% {opacity: 0; display:none;} - -} -@-webkit-keyframes cssOutAnimation { - 0% {opacity: 1;} - 33% {opacity: 0.75;} - 66% {opacity: 0.25;} - 100% {opacity: 0; display:none;} -} - -.toast-hidden { - display:none; -} diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index 1970c05..aaceb9b 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -180,17 +180,10 @@ class AppStateModelImpl extends AppStateModel { * let option = {"message": "Samplessss", "type": "success"}; */ showToast(option){ - if ( typeof option === 'object' ){ - if(Array.isArray(option)) { - this.queuedToast = option; - } - else { - this.queuedToast.push(option); - } - } else return; - - this.queueAmount = this.queuedToast.length; - this.store.emit('toast-update', this.queuedToast); + if ( Array.isArray(option) ) return; + + if( typeof option === 'object' ) + this.store.emit('toast-update', option); } @@ -198,8 +191,8 @@ class AppStateModelImpl extends AppStateModel { /** * @description Dismissing all toasts in the queue */ - dismissToast(){ - this.queuedToast= []; + dismissToast(queue){ + queue = []; console.log("Queue Has Been Emptied."); } From f6ea7089ca49f2ba17463585091971279bb314d0 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 30 Apr 2024 12:29:08 -0400 Subject: [PATCH 020/274] #30 --- deploy/db-entrypoint/001-tables.sql | 2 +- src/api/admin/index.js | 3 + src/api/admin/line-items.js | 54 ++++++++++++++ src/api/admin/settings.js | 4 +- src/lib/db-models/expenditureOptions.js | 93 +++++++++++++++++++++++++ src/lib/db-models/pg.js | 19 +++++ src/lib/utils/EntityFields.js | 81 ++++++++++++++++++++- 7 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 src/api/admin/line-items.js create mode 100644 src/lib/db-models/expenditureOptions.js diff --git a/deploy/db-entrypoint/001-tables.sql b/deploy/db-entrypoint/001-tables.sql index 4babc25..40da493 100644 --- a/deploy/db-entrypoint/001-tables.sql +++ b/deploy/db-entrypoint/001-tables.sql @@ -135,7 +135,7 @@ COMMENT ON COLUMN approval_request_funding_source.accounting_code IS 'The accoun CREATE TABLE expenditure_option ( expenditure_option_id SERIAL PRIMARY KEY, - label VARCHAR(200) NOT NULL, + label VARCHAR(50) NOT NULL, description TEXT, form_order INTEGER NOT NULL DEFAULT 0, archived BOOLEAN DEFAULT FALSE diff --git a/src/api/admin/index.js b/src/api/admin/index.js index 89b9e9b..ff8b5ed 100644 --- a/src/api/admin/index.js +++ b/src/api/admin/index.js @@ -1,4 +1,6 @@ import express from 'express'; + +import lineItems from './line-items.js'; import settings from './settings.js'; @@ -6,6 +8,7 @@ const router = express.Router(); // admin api routes +lineItems(router); settings(router); export default (app) => { diff --git a/src/api/admin/line-items.js b/src/api/admin/line-items.js new file mode 100644 index 0000000..2d19eee --- /dev/null +++ b/src/api/admin/line-items.js @@ -0,0 +1,54 @@ +import expenditureOptions from "../../lib/db-models/expenditureOptions.js"; +import apiUtils from "../../lib/utils/apiUtils.js"; +import protect from "../../lib/protect.js"; + +export default (api) => { + + api.get('/line-items', protect('hasBasicAccess'), async (req, res) => { + const data = await expenditureOptions.get({active: true}); + if( data.error ) { + console.error('Error in GET /line-items', data.error); + return res.status(500).json({error: true, message: 'Error getting line items.'}); + } + + return res.json(data); + }); + + api.post('/line-items', protect('hasAdminAccess'), async (req, res) => { + const test = { + label: 'Test Label', + description: 'Test Description', + formOrder: 1 + }; + const data = await expenditureOptions.create(test); + + if ( data.error && data.is400 ) { + return res.status(400).json(data); + } + if ( data.error ) { + console.error('Error in POST /line-items', data.error); + return res.status(500).json({error: true, message: 'Error creating line item.'}); + } + + return res.json(data); + }); + + api.put('/line-items', protect('hasAdminAccess'), async (req, res) => { + const test = { + expenditureOptionId: 10, + label: 'Updated label', + archived: true + }; + const data = await expenditureOptions.update(test); + + if ( data.error && data.is400 ) { + return res.status(400).json(data); + } + if ( data.error ) { + console.error('Error in PUT /line-items', data.error); + return res.status(500).json({error: true, message: 'Error updating line item.'}); + } + + return res.json(data); + }); +}; diff --git a/src/api/admin/settings.js b/src/api/admin/settings.js index 5bd28d0..23c167b 100644 --- a/src/api/admin/settings.js +++ b/src/api/admin/settings.js @@ -17,7 +17,7 @@ export default (api) => { console.error('Error in /settings/:category', data.error); return res.status(500).json({error: true, message: 'Error getting settings.'}); } - res.json(data); + return res.json(data); }); /** @@ -33,6 +33,6 @@ export default (api) => { console.error('Error in PUT /settings', data.error); return res.status(500).json({error: true, message: 'Error updating settings.'}); } - res.json({error: false}); + return res.json({error: false}); }); }; diff --git a/src/lib/db-models/expenditureOptions.js b/src/lib/db-models/expenditureOptions.js new file mode 100644 index 0000000..cf71558 --- /dev/null +++ b/src/lib/db-models/expenditureOptions.js @@ -0,0 +1,93 @@ +import pg from "./pg.js"; +import EntityFields from "../utils/EntityFields.js"; + +/** + * @class ExpenditureOptions + * @description Model for expenditure_option table where options for line item expenditures are stored + */ +class ExpenditureOptions { + + constructor(){ + this.entityFields = new EntityFields([ + {dbName: 'expenditure_option_id', jsonName: 'expenditureOptionId', required: true}, + { + dbName: 'label', + jsonName: 'label', + label: 'Label', + required: true, + charLimit: 50 + }, + {dbName: 'description', jsonName: 'description'}, + {dbName: 'form_order', jsonName: 'formOrder'}, + {dbName: 'archived', jsonName: 'archived'} + ]); + } + + /** + * @description Get all expenditure options + * @param {Object} kwargs - optional arguments including: + * - active: boolean - if true, return only active (non-archived) options + * - archived: boolean - if true, return only archived options + * @returns {Object|Array} - array of expenditure option objects or error object + */ + async get(kwargs={}){ + let query = `SELECT * FROM expenditure_option WHERE 1=1`; + if( kwargs.active ) { + query += ` AND archived = false`; + } else if( kwargs.archived ) { + query += ` AND archived = true`; + } + query += ` ORDER BY form_order`; + const res = await pg.query(query); + if( res.error ) return res; + return this.entityFields.toJsonArray(res.res.rows); + } + + /** + * @description Create a new expenditure option + * @param {Object} data - new expenditure option data - camelCase keys + * @returns {Object} - new expenditure option object or error object + */ + async create(data){ + data = this.entityFields.toDbObj(data); + const validation = this.entityFields.validate(data, ['expenditure_option_id']); + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + delete data.expenditure_option_id; + data = pg.prepareObjectForInsert(data); + const sql = `INSERT INTO expenditure_option (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING *`; + const res = await pg.query(sql, data.values); + if( res.error ) return res; + return this.entityFields.toJsonObj(res.res.rows[0]); + } + + /** + * @description Update an expenditure option + * @param {Object} data - expenditure option data - camelCase keys + * @returns {Object} - updated expenditure option object or error object + */ + async update(data){ + data = this.entityFields.toDbObj(data); + const validation = this.entityFields.validate(data); + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + const id = data.expenditure_option_id; + delete data.expenditure_option_id; + + const updateClause = pg.toUpdateClause(data); + const sql = ` + UPDATE expenditure_option + SET ${updateClause.sql} + WHERE expenditure_option_id = $${updateClause.values.length + 1} + RETURNING * + `; + const res = await pg.query(sql, [...updateClause.values, id]); + if( res.error ) return res; + return this.entityFields.toJsonObj(res.res.rows[0]); + } + +} + +export default new ExpenditureOptions(); diff --git a/src/lib/db-models/pg.js b/src/lib/db-models/pg.js index 272a595..5115888 100644 --- a/src/lib/db-models/pg.js +++ b/src/lib/db-models/pg.js @@ -70,6 +70,25 @@ class Pg { return this._toEqualsClause(queryObject, ', '); } + /** + * @description Converts an object to parameters of an INSERT clause + * @param {Object} obj - key value pairs for clause + * @returns {Object} {keys: ['foo', 'bar'], values: ['fooValue', 'barValue'], placeholders: ['$1', '$2']} + */ + prepareObjectForInsert(obj){ + const out = {keys: [], values: [], placeholders: []}; + for (const k in obj) { + out.keys.push(k); + out.values.push(obj[k]); + out.placeholders.push(`$${out.values.length}`); + } + + out.keysString = out.keys.join(', '); + out.valuesString = out.values.join(', '); + out.placeholdersString = out.placeholders.join(', '); + return out; + } + _toEqualsClause(queryObject, sep=' AND '){ let sql = ''; const values = []; diff --git a/src/lib/utils/EntityFields.js b/src/lib/utils/EntityFields.js index 5e397c1..09cbb50 100644 --- a/src/lib/utils/EntityFields.js +++ b/src/lib/utils/EntityFields.js @@ -2,8 +2,11 @@ * @class EntityFields * @description Used to define fields of an entity (usually a database table) * @param {Array} fields - array of field objects with the following properties: - * - dbName {String} - name of the field in the database (should be snake_case) - * - jsonName {String} - name of the field in JSON responses (should be camelCase) + * - dbName {String} REQUIRED - name of the field in the database (should be snake_case) + * - jsonName {String} REQUIRED - name of the field in JSON responses (should be camelCase) + * - label {String} OPTIONAL - human readable label for the field + * - required {Boolean} OPTIONAL - if the field is required + * - charLimit {Number} OPTIONAL - maximum number of characters allowed */ export default class EntityFields { constructor(fields = []){ @@ -16,6 +19,7 @@ export default class EntityFields { * @returns {Object} */ toJsonObj(obj={}) { + if ( typeof obj !== 'object' || Array.isArray(obj) ) return {}; const out = {}; this.fields.forEach(field => { @@ -41,6 +45,8 @@ export default class EntityFields { * @returns {Object} */ toDbObj(obj={}) { + if ( typeof obj !== 'object' || Array.isArray(obj) ) return {}; + const out = {}; this.fields.forEach(field => { @@ -58,4 +64,75 @@ export default class EntityFields { toDbArray(arr=[]) { return arr.map(obj => this.toDbObj(obj)); } + + /** + * @description Validate a record against the fields defined in this class + * @param {Object} record - object to validate + * @param {String} namingScheme - Schema of record keys: 'dbName' or 'jsonName' + * @param {Array} skipFields - array of field names to skip validation on + * @returns {Object} - {valid: Boolean, fieldsWithErrors: Array} + * Where fieldsWithErrors is an array of objects with the following properties: + * - All properties of the field object + * - errors {Array} - array of error objects with the following properties: + * - errorType {String} - type of error + * - message {String} - human readable error message for printing on a form + */ + validate(record, skipFields=[], namingScheme='dbName') { + const out = {valid: true, fieldsWithErrors: []}; + + for (const field of this.fields) { + if ( skipFields.includes(field[namingScheme]) ) continue; + const value = record[field[namingScheme]]; + this._validateRequired(field, value, out); + this._validateCharLimit(field, value, out); + } + + return out; + } + + /** + * @description Validate that a field is not empty + * @param {Object} field - field object + * @param {Any} value - value to validate + * @param {Object} out - output object from validate method + */ + _validateRequired(field, value, out) { + if ( !field.required ) return; + const error = {errorType: 'required', message: 'This field is required'}; + if (!value) { + out.valid = false; + this._pushError(out, field, error); + } + } + + /** + * @description Validate that a field does not exceed the character limit + * @param {Object} field - field object + * @param {Any} value - value to validate + * @param {Object} out - output object from validate method + */ + _validateCharLimit(field, value, out) { + if ( !field.charLimit ) return; + value = value ? value.toString() : ''; + const error = {errorType: 'charLimit', message: `This field must be less than ${field.charLimit} characters`}; + if (value && value.length > field.charLimit) { + out.valid = false; + this._pushError(out, field, error); + } + } + + /** + * @description Add an error to the out array for given field + * @param {Object} out - output object from validate method + * @param {Object} field - field object + * @param {Object} error - error object with errorType and message properties + */ + _pushError(out, field, error) { + const fieldInOutput = out.fieldsWithErrors.find(f => f.jsonName === field.jsonName); + if ( fieldInOutput ) { + fieldInOutput.errors.push(error); + } else { + out.fieldsWithErrors.push({...field, errors: [error]}); + } + } } From 3015d19a80fb82f2f0f91c6cccb638fdb7c6bd80 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Tue, 30 Apr 2024 09:39:44 -0700 Subject: [PATCH 021/274] stash --- src/lib/db-models/approver-type.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/lib/db-models/approver-type.js b/src/lib/db-models/approver-type.js index ed3a854..78cac25 100644 --- a/src/lib/db-models/approver-type.js +++ b/src/lib/db-models/approver-type.js @@ -29,13 +29,16 @@ class ApproverType { */ async query(args={}){ let res; + let archived = "f"; + + if(args.archived == true) archived = "t"; if(Array.isArray(args.id)) { let v = pg.valuesArray(args.id); - res = await pg.query(`SELECT * FROM settings WHERE approver_type_id in $1 AND archived=$2`, [v, args.archived]); + res = await pg.query(`SELECT * FROM approver_type WHERE approver_type_id in $1 AND archived=$2`, [v, archived]); } else { - res = await pg.query(`SELECT * FROM settings WHERE approver_type_id in $1 AND archived=$2`, [args.id, args.archived]); + res = await pg.query(`SELECT * FROM approver_type WHERE approver_type_id in $1 AND archived=$2`, [args.id, archived]); } if( res.error ) return res; @@ -52,6 +55,12 @@ class ApproverType { * @returns {Object} {error: false} */ async create(data){ + let text = ` + INSERT INTO approver_type (type, query, data) + VALUES ($1, $2, $3) + ON CONFLICT (type, query) DO UPDATE SET data = $3, created = NOW() + `; + const res = await pg.query(`SELECT * FROM settings WHERE categories && $1`, [categories]); if( res.error ) return res; return this.entityFields.toJsonArray(res.res.rows); @@ -64,14 +73,20 @@ class ApproverType { * @returns {Object} {error: false} */ async update(data){ - if ( settings && !Array.isArray(settings) ) settings = [settings]; - if ( !settings || !settings.length ) return pg.returnError('No settings provided'); + + { + + employees:{} + } + + if ( data && !Array.isArray(data) ) data = [data]; + if ( !data || !data.length ) return pg.returnError('No data provided'); const out = {error: false}; const client = await pg.pool.connect(); try { await client.query('BEGIN'); - for( const setting of settings ){ + for( const setting of data ){ let sql = 'UPDATE settings SET '; const valueMap = {} for( const field of this.entityFields.fields ){ From d36f5f14416f482ba287c3eceff583376ddc7c66 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 30 Apr 2024 15:37:05 -0400 Subject: [PATCH 022/274] #31 --- src/api/admin/line-items.js | 28 ++--- src/client/js/app-main.js | 1 + .../pages/admin/app-page-admin-line-items.js | 2 +- .../app-page-approval-request-new.tpl.js | 5 +- src/lib/cork/models/LineItemsModel.js | 67 ++++++++++++ src/lib/cork/services/LineItemsService.js | 52 +++++++++ src/lib/cork/stores/LineItemsStore.js | 101 ++++++++++++++++++ 7 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 src/lib/cork/models/LineItemsModel.js create mode 100644 src/lib/cork/services/LineItemsService.js create mode 100644 src/lib/cork/stores/LineItemsStore.js diff --git a/src/api/admin/line-items.js b/src/api/admin/line-items.js index 2d19eee..20ffb33 100644 --- a/src/api/admin/line-items.js +++ b/src/api/admin/line-items.js @@ -4,6 +4,9 @@ import protect from "../../lib/protect.js"; export default (api) => { + /** + * @description Get array of active (non-archived) line items + */ api.get('/line-items', protect('hasBasicAccess'), async (req, res) => { const data = await expenditureOptions.get({active: true}); if( data.error ) { @@ -14,13 +17,13 @@ export default (api) => { return res.json(data); }); + /** + * @description Create a new line item + * @param {Object} req.body - new line item data + */ api.post('/line-items', protect('hasAdminAccess'), async (req, res) => { - const test = { - label: 'Test Label', - description: 'Test Description', - formOrder: 1 - }; - const data = await expenditureOptions.create(test); + const payload = (typeof req.body === 'object') && !Array.isArray(req.body) ? req.body : {}; + const data = await expenditureOptions.create(payload); if ( data.error && data.is400 ) { return res.status(400).json(data); @@ -33,13 +36,14 @@ export default (api) => { return res.json(data); }); + /** + * @description Update an array of line items + * @param {Object} req.body - A line item object + */ api.put('/line-items', protect('hasAdminAccess'), async (req, res) => { - const test = { - expenditureOptionId: 10, - label: 'Updated label', - archived: true - }; - const data = await expenditureOptions.update(test); + + const payload = (typeof req.body === 'object') && !Array.isArray(req.body) ? req.body : {}; + const data = await expenditureOptions.update(payload); if ( data.error && data.is400 ) { return res.status(400).json(data); diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 92421cd..0e38d22 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -26,6 +26,7 @@ AppStateModel.init(appConfig.routes); import "../../lib/cork/models/DepartmentModel.js"; import "../../lib/cork/models/EmployeeModel.js"; import "../../lib/cork/models/SettingsModel.js"; +import "../../lib/cork/models/LineItemsModel.js"; // auth import Keycloak from 'keycloak-js'; diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index c878d88..346e4f8 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -17,7 +17,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) this.render = render.bind(this); this.settingsCategory = 'admin-line-items'; - this._injectModel('AppStateModel', 'SettingsModel'); + this._injectModel('AppStateModel', 'SettingsModel', 'LineItemsModel'); } /** diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index 6b4d94e..ae6a6b9 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -1,7 +1,6 @@ import { html } from 'lit'; -export function render() { +export function render() { return html` - -`;} \ No newline at end of file +`;} diff --git a/src/lib/cork/models/LineItemsModel.js b/src/lib/cork/models/LineItemsModel.js new file mode 100644 index 0000000..a7ed0e6 --- /dev/null +++ b/src/lib/cork/models/LineItemsModel.js @@ -0,0 +1,67 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import LineItemsService from '../services/LineItemsService.js'; +import LineItemsStore from '../stores/LineItemsStore.js'; + +class LineItemsModel extends BaseModel { + + constructor() { + super(); + + this.store = LineItemsStore; + this.service = LineItemsService; + + this.register('LineItemsModel'); + } + + /** + * @description Get all active (non-archived) line items + */ + async getActiveLineItems(){ + let state = this.store.data.activeLineItems; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.getActiveLineItems(); + } + } catch(e) {} + return this.store.data.activeLineItems; + } + + /** + * @description Create a new line item + * @param {Object} payload - line item data - see db-models/expenditureOptions.js + */ + async createLineItem(payload) { + let timestamp = Date.now(); + try { + await this.service.createLineItem(payload, timestamp); + } catch(e) {} + const state = this.store.data.lineItemsCreated[timestamp]; + if ( state && state.state === 'loaded' ) { + this.store.data.activeLineItems = {}; + } + return state; + } + + /** + * @description Update a line item + * @param {Object} payload - line item data - see db-models/expenditureOptions.js + */ + async updateLineItem(payload) { + let timestamp = Date.now(); + try { + await this.service.updateLineItem(payload, timestamp); + } catch(e) {} + const state = this.store.data.lineItemsUpdated[timestamp]; + if ( state && state.state === 'loaded' ) { + this.store.data.activeLineItems = {}; + } + return state; + + } + +} + +const model = new LineItemsModel(); +export default model; diff --git a/src/lib/cork/services/LineItemsService.js b/src/lib/cork/services/LineItemsService.js new file mode 100644 index 0000000..b3d359b --- /dev/null +++ b/src/lib/cork/services/LineItemsService.js @@ -0,0 +1,52 @@ +import BaseService from './BaseService.js'; +import LineItemsStore from '../stores/LineItemsStore.js'; + +class LineItemsService extends BaseService { + + constructor() { + super(); + this.store = LineItemsStore; + } + + getActiveLineItems(){ + return this.request({ + url : `/api/admin/line-items`, + checkCached: () => this.store.data.activeLineItems, + onLoading : request => this.store.activeLineItemsLoading(request), + onLoad : result => this.store.activeLineItemsLoaded(result.body), + onError : e => this.store.activeLineItemsError(e) + }); + } + + createLineItem(payload, timestamp) { + return this.request({ + url : '/api/admin/line-items', + fetchOptions : { + method : 'POST', + body : payload + }, + json: true, + onLoading : request => this.store.lineItemsCreatedLoading(request, timestamp), + onLoad : result => this.store.lineItemsCreatedLoaded(result.body, timestamp), + onError : e => this.store.lineItemsCreatedError(e, timestamp) + }); + } + + updateLineItem(payload, timestamp) { + return this.request({ + url : `/api/admin/line-items`, + fetchOptions : { + method : 'PUT', + body : payload + }, + json: true, + onLoading : request => this.store.lineItemsUpdatedLoading(request, timestamp), + onLoad : result => this.store.lineItemsUpdatedLoaded(result.body, timestamp), + onError : e => this.store.lineItemsUpdatedError(e, timestamp) + }); + } + +} + +const service = new LineItemsService(); +export default service; diff --git a/src/lib/cork/stores/LineItemsStore.js b/src/lib/cork/stores/LineItemsStore.js new file mode 100644 index 0000000..e56b739 --- /dev/null +++ b/src/lib/cork/stores/LineItemsStore.js @@ -0,0 +1,101 @@ +import {BaseStore} from '@ucd-lib/cork-app-utils'; + +class LineItemsStore extends BaseStore { + + constructor() { + super(); + + this.data = { + activeLineItems: {}, + lineItemsCreated: {}, + lineItemsUpdated: {} + }; + this.events = { + ACTIVE_LINE_ITEMS_FETCHED: 'active-line-items-fetched', + LINE_ITEM_CREATED: 'line-item-created', + LINE_ITEM_UPDATED: 'line-item-updated' + }; + } + + activeLineItemsLoading(request) { + this._setActiveLineItemsState({ + state : this.STATE.LOADING, + request + }); + } + + activeLineItemsLoaded(payload) { + this._setActiveLineItemsState({ + state : this.STATE.LOADED, + payload + }); + } + + activeLineItemsError(error) { + this._setActiveLineItemsState({ + state : this.STATE.ERROR, + error + }); + } + + _setActiveLineItemsState(state) { + this.data.activeLineItems = state; + this.emit(this.events.ACTIVE_LINE_ITEMS_FETCHED, state); + } + + lineItemsCreatedLoading(request, timestamp) { + this._setLineItemsCreatedState({ + state : this.STATE.LOADING, + request + }, timestamp); + } + + lineItemsCreatedLoaded(payload, timestamp) { + this._setLineItemsCreatedState({ + state : this.STATE.LOADED, + payload + }, timestamp); + } + + lineItemsCreatedError(error, timestamp) { + this._setLineItemsCreatedState({ + state : this.STATE.ERROR, + error + }, timestamp); + } + + _setLineItemsCreatedState(state, timestamp) { + this.data.lineItemsCreated[timestamp] = state; + this.emit(this.events.LINE_ITEM_CREATED, state); + } + + lineItemsUpdatedLoading(request, timestamp) { + this._setLineItemsUpdatedState({ + state : this.STATE.LOADING, + request + }, timestamp); + } + + lineItemsUpdatedLoaded(payload, timestamp) { + this._setLineItemsUpdatedState({ + state : this.STATE.LOADED, + payload + }, timestamp); + } + + lineItemsUpdatedError(error, timestamp) { + this._setLineItemsUpdatedState({ + state : this.STATE.ERROR, + error + }, timestamp); + } + + _setLineItemsUpdatedState(state, timestamp) { + this.data.lineItemsUpdated[timestamp] = state; + this.emit(this.events.LINE_ITEM_UPDATED, state); + } + +} + +const store = new LineItemsStore(); +export default store; From da019f290b5a57ae82ee1f5598ea70ef68559526 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 30 Apr 2024 16:42:04 -0400 Subject: [PATCH 023/274] #32 --- .../pages/admin/app-page-admin-line-items.js | 20 ++++++++++++++-- .../admin/app-page-admin-line-items.tpl.js | 23 ++++++++++++++++++- src/client/scss/global.scss | 8 +++++++ src/client/scss/pages/admin/line-items.scss | 16 +++++++++++++ src/client/scss/style.scss | 3 +++ 5 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/client/scss/pages/admin/line-items.scss diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index 346e4f8..6d38ce5 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -8,7 +8,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) static get properties() { return { - + lineItems : {type: Array} } } @@ -16,6 +16,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) super(); this.render = render.bind(this); this.settingsCategory = 'admin-line-items'; + this.lineItems = []; this._injectModel('AppStateModel', 'SettingsModel', 'LineItemsModel'); } @@ -49,11 +50,26 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } /** - * @description Get any data required for rendering this page + * @description bound to LineItemsModel ACTIVE_LINE_ITEMS_FETCHED event + * fires when active line items are fetched from the server + */ + _onActiveLineItemsFetched(e){ + if ( e.state !== 'loaded' ) return; + this.lineItems = e.payload.map(item => { + item = {...item}; + item.editing = false; + return item; + }); + console.log(this.lineItems); + } + + /** + * @description Get all data required for rendering this page */ async getPageData(){ const promises = []; promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); + promises.push(this.LineItemsModel.getActiveLineItems()); const resolvedPromises = await Promise.all(promises); return resolvedPromises; } diff --git a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js index 39e4bd1..492fa6f 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js @@ -5,7 +5,28 @@ export function render() { return html`

${unsafeHTML(this.SettingsModel.getByKey('admin_line_items_description'))}

+
+ ${this.lineItems.map(item => { + if ( item.editing ) return renderLineItemForm.call(this, item); + return renderLineItem.call(this, item); + })} +
+`;} +function renderLineItem(item) { + return html` +
+
+

${item.label}

+ + +
+
+ ` +} -`;} +function renderLineItemForm(item) { + return html`

i am a line item form

+ ` +} diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 2099b1b..219ba70 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -4,3 +4,11 @@ .pointer { cursor: pointer; } +.icon-link { + text-decoration: none; + cursor: pointer; +} +.icon-link:hover { + color: var(--category-brand, #13639E); + filter: brightness(1.4); +} diff --git a/src/client/scss/pages/admin/line-items.scss b/src/client/scss/pages/admin/line-items.scss new file mode 100644 index 0000000..7e189d9 --- /dev/null +++ b/src/client/scss/pages/admin/line-items.scss @@ -0,0 +1,16 @@ +app-page-admin-line-items { + .line-item__header { + display: flex; + align-items: center; + + h3 { + margin-right: 1rem; + margin-bottom: 0; + } + + a { + margin-right: .5rem; + } + + } +} diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index 5028111..21b1bcd 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -1,4 +1,7 @@ @use '@ucd-lib/theme-sass/style-ucdlib.scss' as style; @use './fonts.scss' as fonts; @use './global.scss' as global; + +// admin pages @use './pages/admin/settings.scss' as adminSettings; +@use './pages//admin/line-items.scss' as adminLineItems; From 2f78dab1eba0a444eacdd7365fa71c7b2a7b2839 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Tue, 30 Apr 2024 15:50:30 -0700 Subject: [PATCH 024/274] requests added --- src/client/js/components/app-toast.js | 43 +++++++++++++++++++++-- src/client/js/components/app-toast.tpl.js | 40 +++++++++------------ src/client/js/pages/app-page-home.js | 1 + src/lib/cork/models/AppStateModel.js | 19 ++++------ src/lib/cork/stores/AppStateStore.js | 2 ++ 5 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/client/js/components/app-toast.js b/src/client/js/components/app-toast.js index 7434d64..758f4e9 100644 --- a/src/client/js/components/app-toast.js +++ b/src/client/js/components/app-toast.js @@ -1,11 +1,19 @@ -import { LitElement } from 'lit'; +import { html, LitElement } from 'lit'; import {render, styles} from "./app-toast.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; -import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; /** * @description Component for handling sitewide toast + * + * item: the object that holds the display text and status + * text: the text that is displayed + * type: the status of the toast message + * nopopup: have the toast not pop up + * amount: the amount of objects in the queue of items + * hidden: the modal is hidden + * animation: queues up the animation + * time the amount of time the modal stays up * this.AppStateModel.showToast(object); @@ -40,10 +48,13 @@ export default class AppToast extends Mixin(LitElement) this.queue = []; this.animations; this.time = 5000; + this.icons; + this.queueAmount; this._injectModel('AppStateModel'); } + /** * @description Attached to AppStateModel toast-update event * @param {Object} options @@ -65,6 +76,9 @@ export default class AppToast extends Mixin(LitElement) this.text = this.item.message; this.type = this.item.type || 'information'; + if(this.type == "success") this.icon = html`✓`; + else if(this.type == "error") this.icon = html`✕`; + this.queueAmount--; if(this.queueAmount == 0) { setTimeout(() => { @@ -74,6 +88,31 @@ export default class AppToast extends Mixin(LitElement) }, this.time * i ); } } + + + /** + * @description Attached to AppStateModel toast-dismiss event + * @param {Object} message + */ + _onToastDismiss(message){ + + this.hidden = true; + + this.queue = []; + this.queueAmount = 0; + this.text = ""; + this.type = ""; + this.animation = false; + this.item = {}; + + let toast = this.shadowRoot.querySelector(".toast"); + toast.style.display = "none"; + console.log(message.message); + + + this.requestUpdate(); + + } } customElements.define('app-toast', AppToast); \ No newline at end of file diff --git a/src/client/js/components/app-toast.tpl.js b/src/client/js/components/app-toast.tpl.js index 50b97b9..fd94833 100644 --- a/src/client/js/components/app-toast.tpl.js +++ b/src/client/js/components/app-toast.tpl.js @@ -4,18 +4,19 @@ import {classMap} from 'lit/directives/class-map.js'; export function styles() { const elementStyles = css` :host { - display: block; + position: fixed; + bottom: 10px; + left:10px; } .toast { width: 200px; - display:block; + display: flex; padding: 20px 38px 20px 38px; border-radius: 15px; background: #FFFFFF; box-shadow: 0px 4px 20px 0px #00000033; text-align:center; - position:relative; z-index:3; } @@ -40,12 +41,6 @@ export function styles() { } - - .moveout { - -webkit-animation: cssOutAnimation 1s forwards; - animation: cssOutAnimation 1s forwards; - } - .moveout { -webkit-animation: cssOutAnimation 1s forwards; animation: cssOutAnimation 1s forwards; @@ -71,17 +66,22 @@ export function styles() { .icon { font-size: 20px; - float: left; + width:25%; + } + + .text { + font-size: 20px; + width:75%; } .icon-success { - color:green; + color:#3dae2b; } .icon-info { color:black; } .icon-error { - color:red; + color: #c10230; } `; @@ -103,17 +103,11 @@ export function render() { return html` ${!this.nopopup ? html` -
- - - ${this.type == "success" ? html``: - this.type == "error" ? html``: - html``} - - ${this.text} - -
-`:html``} +
+ ${this.icon} + ${this.text} +
+`: html``} `;} \ No newline at end of file diff --git a/src/client/js/pages/app-page-home.js b/src/client/js/pages/app-page-home.js index 3ea6273..18738d4 100644 --- a/src/client/js/pages/app-page-home.js +++ b/src/client/js/pages/app-page-home.js @@ -8,6 +8,7 @@ export default class AppPageHome extends Mixin(LitElement) static get properties() { return { + } } diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index aaceb9b..a4302de 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -12,8 +12,6 @@ class AppStateModelImpl extends AppStateModel { this.store = AppStateStore; - this.queuedToast = []; - this.queueAmount = 0; if ( appConfig.auth?.requireAuth ) { this.inject('AuthModel'); } @@ -171,17 +169,11 @@ class AppStateModelImpl extends AppStateModel { /** * @description Show dismissable toast banner in popup. Will disappear on next app-state-update event - * @param {Object|Array} options Toast message if object, multiple if Array: - * this.queuedToast = [{"message": "Samplessss", "type": "success"}, - * {"message": "Samplessss2", "type": "success"}, - * {"message": "Samplessss3", "type": "success"} - * ]; - * - * let option = {"message": "Samplessss", "type": "success"}; + * @param {Object} options Toast message if object, does not except multiple: */ showToast(option){ if ( Array.isArray(option) ) return; - + if( typeof option === 'object' ) this.store.emit('toast-update', option); @@ -191,9 +183,10 @@ class AppStateModelImpl extends AppStateModel { /** * @description Dismissing all toasts in the queue */ - dismissToast(queue){ - queue = []; - console.log("Queue Has Been Emptied."); + dismissToast(){ + let dismissMessage = "Toast Dismissed"; + + this.store.emit('toast-dismiss', {message: dismissMessage}); } /** diff --git a/src/lib/cork/stores/AppStateStore.js b/src/lib/cork/stores/AppStateStore.js index 81f492e..3f586f9 100644 --- a/src/lib/cork/stores/AppStateStore.js +++ b/src/lib/cork/stores/AppStateStore.js @@ -28,6 +28,8 @@ class AppStateStoreImpl extends AppStateStore { this.events.PAGE_TITLE_UPDATE = 'page-title-update'; this.events.BREADCRUMB_UPDATE = 'breadcrumb-update'; this.events.TOAST_UPDATE = 'toast-update'; + this.events.TOAST_DISMISS = 'toast-dismiss'; + } } From 5c9b0bde16d3cd781b7cf81cdded429bf425080d Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 1 May 2024 16:40:31 -0400 Subject: [PATCH 025/274] #32 --- deploy/db-entrypoint/004-settings.sql | 2 + .../pages/admin/app-page-admin-line-items.js | 134 +++++++++++++++++- .../admin/app-page-admin-line-items.tpl.js | 59 +++++++- src/client/js/utils/ValidationHandler.js | 49 +++++++ src/client/scss/global.scss | 27 ++++ src/client/scss/pages/admin/line-items.scss | 21 +++ src/lib/cork/services/BaseService.js | 11 +- src/lib/cork/services/LineItemsService.js | 2 +- src/lib/cork/stores/LineItemsStore.js | 5 +- src/lib/db-models/expenditureOptions.js | 2 +- src/lib/utils/EntityFields.js | 16 +++ 11 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 src/client/js/utils/ValidationHandler.js diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index e2e8e70..1ad79b9 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -3,3 +3,5 @@ INSERT INTO settings(key, value, label, description, input_type, categories) VAL -- admin line items page INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('admin_line_items_form_order_help', '', 'Admin - Line Items Order Help Text', NULL, 'Changes the order in which the line item is displayed on the approval request form.', '1', NULL, '100', 'text', '{admin-line-items,admin-settings}', '0'); diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index 6d38ce5..246ed6c 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -2,13 +2,21 @@ import { LitElement } from 'lit'; import {render} from "./app-page-admin-line-items.tpl.js"; import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +import { WaitController } from "@ucd-lib/theme-elements/utils/controllers/wait.js"; +import ValidationHandler from "../../utils/ValidationHandler.js"; +/** + * @description Admin page for managing expense line item options + * aka what the user can select when submitting an approval request + * @param {Array} lineItems - local copy of active line item objects from LineItemsModel + */ export default class AppPageAdminLineItems extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { static get properties() { return { - lineItems : {type: Array} + lineItems : {type: Array}, + newLineItem : {type: Object}, } } @@ -17,10 +25,22 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) this.render = render.bind(this); this.settingsCategory = 'admin-line-items'; this.lineItems = []; + this.newLineItem = {}; + + this.waitController = new WaitController(this); this._injectModel('AppStateModel', 'SettingsModel', 'LineItemsModel'); } + /** + * @description lit lifecycle method + */ + willUpdate(changedProps) { + if ( changedProps.has('newLineItem') ) { + this.showNewLineItemForm = this.newLineItem && Object.keys(this.newLineItem).length > 0; + } + } + /** * @description bound to AppStateModel app-state-update event * @param {Object} state - AppStateModel state @@ -58,9 +78,119 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) this.lineItems = e.payload.map(item => { item = {...item}; item.editing = false; + item.validationHandler = new ValidationHandler(); return item; }); - console.log(this.lineItems); + } + + /** + * @description bound to edit button for each line item + */ + _onEditClick(lineItem){ + lineItem.editing = !lineItem.editing; + this.requestUpdate(); + } + + /** + * @description bound to add new line item option button + * Shows the new line item form at the bottom of the list + */ + async _onNewClick(){ + this.newLineItem = { + label : '', + description : '', + formOrder : 0, + editing : true, + validationHandler : new ValidationHandler() + }; + await this.waitController.waitForUpdate(); + const form = this.renderRoot.querySelector('.new-line-item-form'); + if ( form ) form.scrollIntoView({ behavior: "smooth", block: "end", inline: "nearest" }); + } + + /** + * @description bound to cancel button on line item form + */ + async _onEditCancelClick(lineItem){ + if ( !lineItem.expenditureOptionId ) { + this.newLineItem = {}; + return; + } + lineItem.editing = false; + lineItem.validationHandler = new ValidationHandler(); + + // toss out any changes + const lineItems = await this.LineItemsModel.getActiveLineItems(); + if ( lineItems.state === 'loaded' ) { + const ogLineItem = lineItems.payload.find(item => item.expenditureOptionId == lineItem.expenditureOptionId); + for( let prop in ogLineItem ) { + lineItem[prop] = ogLineItem[prop]; + } + } + + this.requestUpdate(); + } + + /** + * @description bound to input fields in line item form + */ + _onFormInput(prop, value, lineItem){ + lineItem[prop] = value; + this.requestUpdate(); + } + + /** + * @description Returns a line item from the element's lineItems array by expenditureOptionId + */ + getLineItemById(id){ + return this.lineItems.find(item => item.expenditureOptionId == id); + } + + /** + * @description bound to LineItemsModel LINE_ITEM_UPDATED event + */ + async _onLineItemUpdated(e){ + if ( e.state === 'error' ) { + if ( e.error?.payload?.is400 ) { + const expenditureOptionId = e?.payload?.expenditureOptionId; + const lineItem = this.getLineItemById(expenditureOptionId); + lineItem.validationHandler = new ValidationHandler(e); + this.AppStateModel.showLoaded(this.id) + this.requestUpdate(); + // TODO: show error toast + + } else { + // TODO: show error toast + this.AppStateModel.showLoaded(this.id) + } + await this.waitController.waitForFrames(3); + window.scrollTo(0, this.lastScrollPosition); + } else if ( e.state === 'loading' ) { + this.AppStateModel.showLoading(); + + } else if ( e.state === 'loaded' ) { + this.AppStateModel.refresh(); + // TODO: show success toast + } + } + + /** + * @description bound to line item form submit event (new or edit line item) + */ + _onFormSubmit(e){ + e.preventDefault(); + this.lastScrollPosition = window.scrollY; + const lineItemId = e.target.getAttribute('line-item-id'); + + if ( lineItemId ) { + const lineItem = this.lineItems.find(item => item.expenditureOptionId == lineItemId); + if ( !lineItem ) { + console.error('Could not find line item with id', lineItemId); + // TODO: show error toast + return; + } + this.LineItemsModel.updateLineItem(lineItem); + } } /** diff --git a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js index 492fa6f..6346d8a 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js @@ -3,7 +3,7 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js'; export function render() { return html` -
+

${unsafeHTML(this.SettingsModel.getByKey('admin_line_items_description'))}

${this.lineItems.map(item => { @@ -11,6 +11,15 @@ return html` return renderLineItem.call(this, item); })}
+
+
+ ${renderLineItemForm.call(this, this.newLineItem)} +
+ +
`;} @@ -19,14 +28,56 @@ function renderLineItem(item) {
+
+
Description
+
${item.description ? unsafeHTML(item.description) : 'None'}
+
` } function renderLineItemForm(item) { - return html`

i am a line item form

- ` + if ( !item || Object.keys(item).length === 0 ) return html``; + const itemId = item.expenditureOptionId || 'new'; + const inputIdLabel = `line-item-label-${itemId}`; + const inputIdDescription = `line-item-description-${itemId}`; + const inputIdOrder = `line-item-order-${itemId}`; + + return html` +
+ ${itemId === 'new' ? html`

New Line Item

` : html`

Edit Line Item

`} +
+ + this._onFormInput('label', e.target.value, item)}> + ${item.validationHandler.renderErrorMessages('label')} +
+
+ + this._onFormInput('formOrder', e.target.value, item)}> +
${this.SettingsModel.getByKey('admin_line_items_form_order_help')}
+ ${item.validationHandler.renderErrorMessages('formOrder')} +
+
+ + +
+ +
+ + +
+ +
+ ` } diff --git a/src/client/js/utils/ValidationHandler.js b/src/client/js/utils/ValidationHandler.js new file mode 100644 index 0000000..df9e5cc --- /dev/null +++ b/src/client/js/utils/ValidationHandler.js @@ -0,0 +1,49 @@ +import { html } from 'lit'; + +/** + * @class ValidationHandler + * @description Class to handle validation error objects from the server + * @param {Object} corkError - Error object from a cork-app-utils event + */ +export default class ValidationHandler { + + constructor(corkError={}) { + this.init(corkError); + this._errorClass = 'field-error'; + this._errorMessageClass = 'field-error-message'; + } + + /** + * @description Initialize the ValidationHandler with a corkError object + * @param {Object} corkError - Error object from a cork-app-utils event + */ + init(corkError={}){ + this.corkError = corkError; + this.errorsByField = (corkError?.error?.payload?.fieldsWithErrors || []).reduce((acc, error) => { + acc[error.jsonName] = error.errors || []; + return acc; + } + , {}); + + } + + /** + * @description Get the error class (if any) for a field + */ + errorClass(field){ + return this.errorsByField[field] ? this._errorClass : ''; + } + + /** + * @description Render error messages for a field (if any) + */ + renderErrorMessages(field){ + const messages = this.errorsByField[field] || []; + return html` +
+ ${messages.map(err => html`
${err.message}
`)} +
+ ` + } + +} diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 219ba70..fefee6a 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -12,3 +12,30 @@ color: var(--category-brand, #13639E); filter: brightness(1.4); } +.bold { + font-weight: 700; +} + + +.field-error { + input { + border: 1px solid #c10230; + } + textarea { + border: 1px solid #c10230; + } + .field-error-message { + color: #c10230; + display: block; + } + label { + color: #c10230; + } +} +.field-error-message { + display: none; +} +.field-help { + font-size: .875rem; + color: #13639e; +} diff --git a/src/client/scss/pages/admin/line-items.scss b/src/client/scss/pages/admin/line-items.scss index 7e189d9..90d7f45 100644 --- a/src/client/scss/pages/admin/line-items.scss +++ b/src/client/scss/pages/admin/line-items.scss @@ -1,7 +1,13 @@ app-page-admin-line-items { + .line-item { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 2px solid #DBEAF7; + } .line-item__header { display: flex; align-items: center; + margin-bottom: .25rem; h3 { margin-right: 1rem; @@ -11,6 +17,21 @@ app-page-admin-line-items { a { margin-right: .5rem; } + } + + .line-item-form { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 2px solid #DBEAF7; + .buttons { + display: flex; + align-items: center; + margin-top: 1rem; + + button { + margin-left: .5rem; + } + } } } diff --git a/src/lib/cork/services/BaseService.js b/src/lib/cork/services/BaseService.js index bcb9264..2f9d18c 100644 --- a/src/lib/cork/services/BaseService.js +++ b/src/lib/cork/services/BaseService.js @@ -26,6 +26,15 @@ export default class BaseServiceImp extends BaseService { options.fetchOptions.headers.Authorization = `Bearer ${kc.token}` } catch (error) {} } - return await super.request(options); + + if( options.json && + options.fetchOptions && + options.fetchOptions.body && + typeof options.fetchOptions.body === 'object') { + options.fetchOptions.body = {...options.fetchOptions.body}; + delete options.fetchOptions.body.validationHandler + } + //return await super.request(options); + return super.request(options); } } diff --git a/src/lib/cork/services/LineItemsService.js b/src/lib/cork/services/LineItemsService.js index b3d359b..5ecbf18 100644 --- a/src/lib/cork/services/LineItemsService.js +++ b/src/lib/cork/services/LineItemsService.js @@ -42,7 +42,7 @@ class LineItemsService extends BaseService { json: true, onLoading : request => this.store.lineItemsUpdatedLoading(request, timestamp), onLoad : result => this.store.lineItemsUpdatedLoaded(result.body, timestamp), - onError : e => this.store.lineItemsUpdatedError(e, timestamp) + onError : e => this.store.lineItemsUpdatedError(e, timestamp, payload) }); } diff --git a/src/lib/cork/stores/LineItemsStore.js b/src/lib/cork/stores/LineItemsStore.js index e56b739..2c65664 100644 --- a/src/lib/cork/stores/LineItemsStore.js +++ b/src/lib/cork/stores/LineItemsStore.js @@ -83,10 +83,11 @@ class LineItemsStore extends BaseStore { }, timestamp); } - lineItemsUpdatedError(error, timestamp) { + lineItemsUpdatedError(error, timestamp, payload) { this._setLineItemsUpdatedState({ state : this.STATE.ERROR, - error + error, + payload }, timestamp); } diff --git a/src/lib/db-models/expenditureOptions.js b/src/lib/db-models/expenditureOptions.js index cf71558..ebed3a4 100644 --- a/src/lib/db-models/expenditureOptions.js +++ b/src/lib/db-models/expenditureOptions.js @@ -18,7 +18,7 @@ class ExpenditureOptions { charLimit: 50 }, {dbName: 'description', jsonName: 'description'}, - {dbName: 'form_order', jsonName: 'formOrder'}, + {dbName: 'form_order', jsonName: 'formOrder', validateType: 'integer'}, {dbName: 'archived', jsonName: 'archived'} ]); } diff --git a/src/lib/utils/EntityFields.js b/src/lib/utils/EntityFields.js index 09cbb50..ab3023a 100644 --- a/src/lib/utils/EntityFields.js +++ b/src/lib/utils/EntityFields.js @@ -85,6 +85,7 @@ export default class EntityFields { const value = record[field[namingScheme]]; this._validateRequired(field, value, out); this._validateCharLimit(field, value, out); + this._validateType(field, value, out); } return out; @@ -121,6 +122,21 @@ export default class EntityFields { } } + /** + * @description Validate that a field can be cast to a certain type + */ + _validateType(field, value, out) { + if ( !field.validateType ) return; + const error = {errorType: 'validateType', message: `This field must be of type: ${field.validateType}`}; + if (field.validateType == 'integer' ) { + value = value || value === '0' || value === 0 ? value : NaN; + if ( !Number.isInteger(Number(value)) ) { + out.valid = false; + this._pushError(out, field, error); + } + } + } + /** * @description Add an error to the out array for given field * @param {Object} out - output object from validate method From 8216b10007eb25de02a0ea43eccba2704eb84422 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 2 May 2024 09:23:09 -0400 Subject: [PATCH 026/274] #32 --- .../pages/admin/app-page-admin-line-items.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index 246ed6c..2f25b48 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -9,6 +9,7 @@ import ValidationHandler from "../../utils/ValidationHandler.js"; * @description Admin page for managing expense line item options * aka what the user can select when submitting an approval request * @param {Array} lineItems - local copy of active line item objects from LineItemsModel + * @param {Object} newLineItem - new line item object being created */ export default class AppPageAdminLineItems extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -174,6 +175,31 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } } + /** + * @description bound to LineItemsModel LINE_ITEM_CREATED event + */ + async _onLineItemCreated(e){ + if ( e.state === 'error' ) { + if ( e.error?.payload?.is400 ) { + this.newLineItem.validationHandler = new ValidationHandler(e); + this.AppStateModel.showLoaded(this.id) + this.requestUpdate(); + // TODO: show error toast + } else { + // TODO: show error toast + this.AppStateModel.showLoaded(this.id) + } + await this.waitController.waitForFrames(3); + window.scrollTo(0, this.lastScrollPosition); + } else if ( e.state === 'loading' ) { + this.AppStateModel.showLoading(); + } else if ( e.state === 'loaded' ) { + this.newLineItem = {}; + this.AppStateModel.refresh(); + // TODO: show success toast + } + } + /** * @description bound to line item form submit event (new or edit line item) */ @@ -190,6 +216,8 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) return; } this.LineItemsModel.updateLineItem(lineItem); + } else { + this.LineItemsModel.createLineItem(this.newLineItem); } } From 4b79304abf7177d9f5dfc31e05ff6efd32070001 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 2 May 2024 09:55:23 -0400 Subject: [PATCH 027/274] #15 --- src/client/js/components/app-toast.tpl.js | 41 ++++++++++--------- .../pages/admin/app-page-admin-line-items.js | 4 +- src/lib/cork/models/AppStateModel.js | 4 +- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/client/js/components/app-toast.tpl.js b/src/client/js/components/app-toast.tpl.js index fd94833..50b66a4 100644 --- a/src/client/js/components/app-toast.tpl.js +++ b/src/client/js/components/app-toast.tpl.js @@ -5,20 +5,26 @@ export function styles() { const elementStyles = css` :host { position: fixed; - bottom: 10px; - left:10px; + bottom: 2rem; + left: 2rem; + width: 95%; } .toast { - width: 200px; - display: flex; - padding: 20px 38px 20px 38px; + max-width: 90%; + display: inline-flex; + padding: 1rem 1.5rem; border-radius: 15px; background: #FFFFFF; box-shadow: 0px 4px 20px 0px #00000033; text-align:center; z-index:3; - + } + + @media (min-width: 768px) { + .toast { + max-width: 60%; + } } .movein { @@ -65,23 +71,20 @@ export function styles() { } .icon { - font-size: 20px; - width:25%; - } - - .text { - font-size: 20px; - width:75%; + margin-right: 1rem; } - .icon-success { + .type--success { color:#3dae2b; + border: 1px solid #3dae2b; } - .icon-info { + .type--info { color:black; + border: 1px solid black; } - .icon-error { + .type--error { color: #c10230; + border: 1px solid #c10230; } `; @@ -95,9 +98,9 @@ export function render() { "toast-hidden": this.hidden, "movein": this.animation, "moveout": !this.animation, - "icon-success": this.type == "success", - "icon-info": this.type == "info", - "icon-error": this.type == "error", + "type--success": this.type == "success", + "type--info": this.type == "info", + "type--error": this.type == "error", }; return html` diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index 2f25b48..c8e5c06 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -158,7 +158,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) lineItem.validationHandler = new ValidationHandler(e); this.AppStateModel.showLoaded(this.id) this.requestUpdate(); - // TODO: show error toast + this.AppStateModel.showToast({message: 'Error when updating the line item. Form data needs fixing.', type: 'error'}) } else { // TODO: show error toast @@ -171,7 +171,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } else if ( e.state === 'loaded' ) { this.AppStateModel.refresh(); - // TODO: show success toast + this.AppStateModel.showToast({message: 'Line item updated successfully', type: 'success'}); } } diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index a4302de..796d038 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -169,7 +169,9 @@ class AppStateModelImpl extends AppStateModel { /** * @description Show dismissable toast banner in popup. Will disappear on next app-state-update event - * @param {Object} options Toast message if object, does not except multiple: + * @param {Object} options Toast object with the following properties: + * - message {String} - The message to display + * - type {String} - The type of toast. Options: 'information', 'error', 'success' */ showToast(option){ if ( Array.isArray(option) ) return; From fa63935af9b8c203d3c2e658c96daed8175ab31a Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 2 May 2024 10:11:13 -0400 Subject: [PATCH 028/274] add toasts --- src/client/js/components/app-toast.tpl.js | 2 +- src/client/js/pages/admin/app-page-admin-line-items.js | 10 +++++----- src/client/js/pages/admin/app-page-admin-settings.js | 7 +++++-- src/lib/cork/services/BaseService.js | 3 ++- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/client/js/components/app-toast.tpl.js b/src/client/js/components/app-toast.tpl.js index 50b66a4..72e56d4 100644 --- a/src/client/js/components/app-toast.tpl.js +++ b/src/client/js/components/app-toast.tpl.js @@ -8,6 +8,7 @@ export function styles() { bottom: 2rem; left: 2rem; width: 95%; + z-index: 1000; } .toast { @@ -18,7 +19,6 @@ export function styles() { background: #FFFFFF; box-shadow: 0px 4px 20px 0px #00000033; text-align:center; - z-index:3; } @media (min-width: 768px) { diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index c8e5c06..4d617e7 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -161,7 +161,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) this.AppStateModel.showToast({message: 'Error when updating the line item. Form data needs fixing.', type: 'error'}) } else { - // TODO: show error toast + this.AppStateModel.showToast({message: 'An unknown error ocurred when updating the line item', type: 'error'}) this.AppStateModel.showLoaded(this.id) } await this.waitController.waitForFrames(3); @@ -184,9 +184,9 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) this.newLineItem.validationHandler = new ValidationHandler(e); this.AppStateModel.showLoaded(this.id) this.requestUpdate(); - // TODO: show error toast + this.AppStateModel.showToast({message: 'Error when creating the line item. Form data needs fixing.', type: 'error'}) } else { - // TODO: show error toast + this.AppStateModel.showToast({message: 'An unknown error ocurred when creating the line item', type: 'error'}) this.AppStateModel.showLoaded(this.id) } await this.waitController.waitForFrames(3); @@ -196,7 +196,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } else if ( e.state === 'loaded' ) { this.newLineItem = {}; this.AppStateModel.refresh(); - // TODO: show success toast + this.AppStateModel.showToast({message: 'Line item created successfully', type: 'success'}); } } @@ -212,7 +212,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) const lineItem = this.lineItems.find(item => item.expenditureOptionId == lineItemId); if ( !lineItem ) { console.error('Could not find line item with id', lineItemId); - // TODO: show error toast + this.AppStateModel.showToast({message: 'An unknown error ocurred', type: 'error'}); return; } this.LineItemsModel.updateLineItem(lineItem); diff --git a/src/client/js/pages/admin/app-page-admin-settings.js b/src/client/js/pages/admin/app-page-admin-settings.js index 5f6a74d..08c8222 100644 --- a/src/client/js/pages/admin/app-page-admin-settings.js +++ b/src/client/js/pages/admin/app-page-admin-settings.js @@ -76,8 +76,11 @@ export default class AppPageAdminSettings extends Mixin(LitElement) * Caches are automatically cleared as part of the 'SettingsModel.updateSettings' method */ _onSettingsUpdated(e) { - console.log('settings updated', e); - // TODO: show toast + if ( e.state === 'loaded' ){ + this.AppStateModel.showToast({message: 'Settings updated', type: 'success'}); + } else if ( e.state === 'error' ) { + this.AppStateModel.showToast({message: 'Settings update failed', type: 'error'}); + } } /** diff --git a/src/lib/cork/services/BaseService.js b/src/lib/cork/services/BaseService.js index 2f9d18c..1e36d26 100644 --- a/src/lib/cork/services/BaseService.js +++ b/src/lib/cork/services/BaseService.js @@ -30,7 +30,8 @@ export default class BaseServiceImp extends BaseService { if( options.json && options.fetchOptions && options.fetchOptions.body && - typeof options.fetchOptions.body === 'object') { + typeof options.fetchOptions.body === 'object' && + !Array.isArray(options.fetchOptions.body) ){ options.fetchOptions.body = {...options.fetchOptions.body}; delete options.fetchOptions.body.validationHandler } From 4265369df11d0f6884056fee3404acb4fadbe059 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 2 May 2024 12:38:32 -0400 Subject: [PATCH 029/274] #29 --- deploy/db-entrypoint/004-settings.sql | 2 +- src/client/js/app-main.js | 3 +- src/client/js/app-main.tpl.js | 1 + src/client/js/components/app-dialog-modal.js | 64 +++++++++++++++++++ .../js/components/app-dialog-modal.tpl.js | 25 ++++++++ .../pages/admin/app-page-admin-line-items.js | 25 +++++++- .../admin/app-page-admin-line-items.tpl.js | 6 +- src/client/scss/components/dialog-modal.scss | 16 +++++ src/client/scss/style.scss | 3 + src/lib/cork/models/AppStateModel.js | 31 +++++++++ src/lib/cork/stores/AppStateStore.js | 2 + 11 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 src/client/js/components/app-dialog-modal.js create mode 100644 src/client/js/components/app-dialog-modal.tpl.js create mode 100644 src/client/scss/components/dialog-modal.scss diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index 1ad79b9..821d188 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -4,4 +4,4 @@ INSERT INTO settings(key, value, label, description, input_type, categories) VAL INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") -VALUES ('admin_line_items_form_order_help', '', 'Admin - Line Items Order Help Text', NULL, 'Changes the order in which the line item is displayed on the approval request form.', '1', NULL, '100', 'text', '{admin-line-items,admin-settings}', '0'); +VALUES ('admin_line_items_form_order_help', '', 'Admin - Line Items Order Help Text', NULL, 'Changes the order in which the line item is displayed on the approval request form.', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '0'); diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 9a1bb5a..0a217ed 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -41,7 +41,8 @@ import "./pages/app-page-alt-state.js"; import "./pages/app-page-home.js"; // global components -import "./components/app-toast.js" +import "./components/app-toast.js"; +import "./components/app-dialog-modal.js"; /** * @class AppMain diff --git a/src/client/js/app-main.tpl.js b/src/client/js/app-main.tpl.js index bc07d25..62f534e 100644 --- a/src/client/js/app-main.tpl.js +++ b/src/client/js/app-main.tpl.js @@ -59,5 +59,6 @@ return html` + `;} diff --git a/src/client/js/components/app-dialog-modal.js b/src/client/js/components/app-dialog-modal.js new file mode 100644 index 0000000..d3ae93b --- /dev/null +++ b/src/client/js/components/app-dialog-modal.js @@ -0,0 +1,64 @@ +import { LitElement } from 'lit'; +import { render } from "./app-dialog-modal.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import { createRef } from 'lit/directives/ref.js'; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +/** + * @description Generic dialog modal for app-wide use + * See AppStateModel.showDialogModal() for usage and accepted parameters + */ +export default class AppDialogModal extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + modalTitle: {type: String}, + modalContent: {type: String}, + actions: {type: Array}, + data: {type: Object} + } + } + + + constructor() { + super(); + this.render = render.bind(this); + + this.modalTitle = ''; + this.modalContent = ''; + this.actions = []; + this.data = {}; + + this.dialogRef = createRef(); + + this._injectModel('AppStateModel'); + } + + /** + * @description Bound to AppStateModel dialog-open event + * Will open the dialog modal with the provided title, content, and actions + */ + _onDialogOpen(e){ + this.modalTitle = e.title; + this.modalContent = e.content; + this.actions = e.actions; + this.data = e.data; + + this.dialogRef.value.showModal(); + } + + /** + * @description Bound to dialog button(s) click event + * Will emit a dialog-action AppStateModel event with the action value and data + * @param {String} action - The action value to emit + */ + _onButtonClick(action){ + this.dialogRef.value.close(); + this.AppStateModel.emit('dialog-action', {action, data: this.data}); + + } + +} + +customElements.define('app-dialog-modal', AppDialogModal); \ No newline at end of file diff --git a/src/client/js/components/app-dialog-modal.tpl.js b/src/client/js/components/app-dialog-modal.tpl.js new file mode 100644 index 0000000..5afadfb --- /dev/null +++ b/src/client/js/components/app-dialog-modal.tpl.js @@ -0,0 +1,25 @@ +import { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { ref } from 'lit/directives/ref.js'; + +export function render() { +return html` + +
+

${this.modalTitle}

+
+
+ ${unsafeHTML(this.modalContent)} +
+
+ ${this.actions.map(action => html` +
+ +
+ `)} +
+
+`;} \ No newline at end of file diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index 4d617e7..aaa5a23 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -171,7 +171,11 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } else if ( e.state === 'loaded' ) { this.AppStateModel.refresh(); - this.AppStateModel.showToast({message: 'Line item updated successfully', type: 'success'}); + if ( e.payload?.archived ) { + this.AppStateModel.showToast({message: 'Line item deleted successfully', type: 'success'}); + } else { + this.AppStateModel.showToast({message: 'Line item updated successfully', type: 'success'}); + } } } @@ -221,6 +225,25 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } } + _onDeleteClick(lineItem){ + this.AppStateModel.showDialogModal({ + title : 'Delete Line Item', + content : 'Are you sure you want to delete this line item option?', + actions : [ + {text: 'Delete', value: 'delete-line-item', color: 'double-decker'}, + {text: 'Cancel', value: 'cancel', invert: true, color: 'primary'} + ], + data : {lineItem} + }); + } + + _onDialogAction(e){ + if ( e.action !== 'delete-line-item' ) return; + const lineItem = e.data.lineItem; + lineItem.archived = true; + this.LineItemsModel.updateLineItem(lineItem); + } + /** * @description Get all data required for rendering this page */ diff --git a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js index 6346d8a..097b476 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js @@ -32,7 +32,11 @@ function renderLineItem(item) { title='Edit line item' @click=${e => this._onEditClick(item)} class='icon-link'> - + this._onDeleteClick(item)} + class='icon-link double-decker'> +
Description
diff --git a/src/client/scss/components/dialog-modal.scss b/src/client/scss/components/dialog-modal.scss new file mode 100644 index 0000000..98d918a --- /dev/null +++ b/src/client/scss/components/dialog-modal.scss @@ -0,0 +1,16 @@ +app-dialog-modal { + dialog::backdrop { + background-color: black; + opacity: 0.5; + } + dialog { + border: none; + padding: 2rem; + } + .alignable-promo__buttons { + display: flex; + align-items: center; + margin-top: 1rem; + flex-wrap: wrap; + } +} \ No newline at end of file diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index 21b1bcd..aabba61 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -5,3 +5,6 @@ // admin pages @use './pages/admin/settings.scss' as adminSettings; @use './pages//admin/line-items.scss' as adminLineItems; + +// components +@use './components/dialog-modal.scss' as dialogModal; diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index 796d038..6511daa 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -181,6 +181,37 @@ class AppStateModelImpl extends AppStateModel { } + /** + * @description Show a modal dialog box. + * To listen for the action event, add the _onDialogAction method to your element and then filter on e.action + * @param {Object} options Dialog object with the following properties: + * - title {String} - The title of the dialog (optional) + * - content {String} - The html content of the dialog (optional, but should probably be included) + * - actions {Array} - Array of objects with the following properties: + * - text {String} - The text of the button + * - value {String} - The action slug that is emitted when button is clicked + * - invert {Boolean} - Invert the button color (optional) + * - color {String} - The brand color string of the button (optional) + * - data {Object} - Any data to pass along in the action event (optional) + * + * If the actions array is empty, a 'Dismiss' button will be added automatically + */ + showDialogModal(options={}){ + if ( !options.actions ) { + options.actions = [{text: 'Dismiss', action: 'dismiss'}]; + } + if ( !options.data ) { + options.data = {}; + } + if ( !options.title ) { + options.title = ''; + } + if ( !options.content ) { + options.content = ''; + } + this.store.emit('dialog-open', options); + } + /** * @description Dismissing all toasts in the queue diff --git a/src/lib/cork/stores/AppStateStore.js b/src/lib/cork/stores/AppStateStore.js index 3f586f9..1e98d5f 100644 --- a/src/lib/cork/stores/AppStateStore.js +++ b/src/lib/cork/stores/AppStateStore.js @@ -29,6 +29,8 @@ class AppStateStoreImpl extends AppStateStore { this.events.BREADCRUMB_UPDATE = 'breadcrumb-update'; this.events.TOAST_UPDATE = 'toast-update'; this.events.TOAST_DISMISS = 'toast-dismiss'; + this.events.DIALOG_OPEN = 'dialog-open'; + this.events.DIALOG_ACTION = 'dialog-action'; } } From 791d3089af42b5025dad5be176a995bf8755df28 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Thu, 2 May 2024 13:57:40 -0700 Subject: [PATCH 030/274] finished #22 #23 --- src/api/admin/approverType.js | 51 ++++ src/api/admin/index.js | 2 + src/api/admin/settings.js | 1 + src/api/approver-type.js | 0 src/client/js/app-main.js | 1 + src/client/js/pages/app-page-home.js | 31 ++- src/client/package-lock.json | 219 ++++++++++++++- src/client/package.json | 1 + src/lib/cork/models/AdminApproverTypeModel.js | 93 +++++++ src/lib/cork/models/ApproverTypeModel.js | 115 -------- .../cork/services/AdminApproverTypeService.js | 58 ++++ src/lib/cork/services/ApproverTypeService.js | 0 src/lib/cork/services/BaseService.js | 1 + src/lib/cork/services/EmployeeService.js | 1 + src/lib/cork/services/SettingsService.js | 1 + src/lib/cork/stores/AdminApproverTypeStore.js | 102 +++++++ src/lib/cork/stores/ApproverTypeStore.js | 0 src/lib/db-models/approver-type.js | 119 -------- src/lib/db-models/approverType.js | 260 ++++++++++++++++++ src/lib/db-models/pg.js | 3 +- 20 files changed, 816 insertions(+), 243 deletions(-) create mode 100644 src/api/admin/approverType.js delete mode 100644 src/api/approver-type.js create mode 100644 src/lib/cork/models/AdminApproverTypeModel.js delete mode 100644 src/lib/cork/models/ApproverTypeModel.js create mode 100644 src/lib/cork/services/AdminApproverTypeService.js delete mode 100644 src/lib/cork/services/ApproverTypeService.js create mode 100644 src/lib/cork/stores/AdminApproverTypeStore.js delete mode 100644 src/lib/cork/stores/ApproverTypeStore.js delete mode 100644 src/lib/db-models/approver-type.js create mode 100644 src/lib/db-models/approverType.js diff --git a/src/api/admin/approverType.js b/src/api/admin/approverType.js new file mode 100644 index 0000000..edebfec --- /dev/null +++ b/src/api/admin/approverType.js @@ -0,0 +1,51 @@ +import approverType from "../../lib/db-models/approverType.js"; +import protect from "../../lib/protect.js"; + +export default (api) => { + + /** + * @description Query an approver-type + */ + api.get('/approver-type', protect('hasBasicAccess'), async (req, res) => { + const query = JSON.parse(req.query.data); + + if ( query.length == 0) return res.status(400).json({error: true, message: 'Query is required.'}); + + const data = await approverType.query(query[0]); + if ( data.error ) { + console.error('Error in GET /approver-type', data.error); + return res.status(500).json({error: true, message: 'Error getting approver-type.'}); + } + res.json(data); + }); + + /** + * @description Create an approver-type + */ + api.post('/approver-type', protect('hasAdminAccess'), async (req, res) => { + + const approverTypeData = req.body; + + const data = await approverType.create(approverTypeData); + if ( data.error ) { + console.error('Error in POST /approver-type', data.error); + return res.status(500).json({error: true, message: 'Error creating approver-type.'}); + } + res.json({data: data, error: false}); + }); + + + /** + * @description Update an approver-type + */ + api.put('/approver-type', protect('hasAdminAccess'), async (req, res) => { + const approverTypeData = req.body; + + const data = await approverType.update(approverTypeData); + if ( data.error ) { + console.error('Error in PUT /approver-type', data.error); + return res.status(500).json({error: true, message: 'Error updating approver-type.'}); + } + res.json({data: data, error: false}); + }); +}; diff --git a/src/api/admin/index.js b/src/api/admin/index.js index 89b9e9b..dbdf1ec 100644 --- a/src/api/admin/index.js +++ b/src/api/admin/index.js @@ -1,5 +1,6 @@ import express from 'express'; import settings from './settings.js'; +import approverType from './approverType.js'; const router = express.Router(); @@ -7,6 +8,7 @@ const router = express.Router(); // admin api routes settings(router); +approverType(router); export default (app) => { app.use('/admin', router); diff --git a/src/api/admin/settings.js b/src/api/admin/settings.js index 6d11541..56d6b51 100644 --- a/src/api/admin/settings.js +++ b/src/api/admin/settings.js @@ -8,6 +8,7 @@ export default (api) => { * @description Get array of settings by category */ api.get('/settings/category/:category', protect('hasBasicAccess'), async (req, res) => { + const category = req.params.category; if ( !category ) return res.status(400).json({error: true, message: 'Category is required.'}); diff --git a/src/api/approver-type.js b/src/api/approver-type.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 92421cd..b0d856f 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -26,6 +26,7 @@ AppStateModel.init(appConfig.routes); import "../../lib/cork/models/DepartmentModel.js"; import "../../lib/cork/models/EmployeeModel.js"; import "../../lib/cork/models/SettingsModel.js"; +import "../../lib/cork/models/AdminApproverTypeModel.js"; // auth import Keycloak from 'keycloak-js'; diff --git a/src/client/js/pages/app-page-home.js b/src/client/js/pages/app-page-home.js index 7f88060..bfaaa73 100644 --- a/src/client/js/pages/app-page-home.js +++ b/src/client/js/pages/app-page-home.js @@ -2,7 +2,6 @@ import { LitElement } from 'lit'; import { render } from "./app-page-home.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - export default class AppPageHome extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -16,7 +15,7 @@ export default class AppPageHome extends Mixin(LitElement) super(); this.render = render.bind(this); - this._injectModel('AppStateModel'); + this._injectModel('AppStateModel', 'AdminApproverTypeModel'); } /** @@ -27,13 +26,39 @@ export default class AppPageHome extends Mixin(LitElement) if ( this.id !== state.page ) return; // this.AppStateModel.showLoading(); - this.AppStateModel.setTitle('Home Page'); + const breadcrumbs = [ this.AppStateModel.store.breadcrumbs.home ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); + + + /* Create */ + // let data = { + // "label": "xlabel", + // "description": "xdescripton", + // "systemGenerated": false, + // "hideFromFundAssignment": false, + // "archived": false, + // "employees": [] + // }; + + + let data = { + "label": "realfinallabel", + "description": "finaldescripton", + "systemGenerated": true, + "hideFromFundAssignment": false, + "archived": false, + "employees":[{ + "employeeKerberos":"sbagg", + "approvalOrder": 77 + }] + }; + let sample = await this.AdminApproverTypeModel.create(data); + console.log(sample); // const d = await this.getPageData(); // const hasError = d.some(e => e.state === 'error'); // if ( !hasError ) this.AppStateModel.showLoaded(this.id); diff --git a/src/client/package-lock.json b/src/client/package-lock.json index 4520e90..1304674 100644 --- a/src/client/package-lock.json +++ b/src/client/package-lock.json @@ -18,6 +18,7 @@ "keycloak-js": "^22.0.0", "lit": "^3.1.3", "normalize-scss": "^7.0.1", + "pg": "^8.11.5", "sass-burger": "^1.3.1" }, "devDependencies": { @@ -8390,6 +8391,87 @@ "node": ">=0.12" } }, + "node_modules/pg": { + "version": "8.11.5", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.5.tgz", + "integrity": "sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==", + "dependencies": { + "pg-connection-string": "^2.6.4", + "pg-pool": "^3.6.2", + "pg-protocol": "^1.6.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", + "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", + "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/photoswipe": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-4.1.3.tgz", @@ -11473,6 +11555,41 @@ "node": ">=0.8.0" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12642,6 +12759,14 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", @@ -14006,8 +14131,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "peer": true, "engines": { "node": ">=0.4" } @@ -20675,6 +20798,66 @@ "sha.js": "^2.4.8" } }, + "pg": { + "version": "8.11.5", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.5.tgz", + "integrity": "sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==", + "requires": { + "pg-cloudflare": "^1.1.1", + "pg-connection-string": "^2.6.4", + "pg-pool": "^3.6.2", + "pg-protocol": "^1.6.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + } + }, + "pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "pg-connection-string": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + }, + "pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-pool": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", + "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "requires": {} + }, + "pg-protocol": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", + "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + }, + "pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "requires": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + } + }, + "pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "requires": { + "split2": "^4.1.0" + } + }, "photoswipe": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/photoswipe/-/photoswipe-4.1.3.tgz", @@ -23105,6 +23288,29 @@ } } }, + "postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==" + }, + "postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" + }, + "postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "requires": { + "xtend": "^4.0.0" + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -24023,6 +24229,11 @@ "extend-shallow": "^3.0.0" } }, + "split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" + }, "sprintf-js": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", @@ -25098,9 +25309,7 @@ "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "peer": true + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, "y18n": { "version": "4.0.3", diff --git a/src/client/package.json b/src/client/package.json index 66f858d..9f66a21 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -22,6 +22,7 @@ "keycloak-js": "^22.0.0", "lit": "^3.1.3", "normalize-scss": "^7.0.1", + "pg": "^8.11.5", "sass-burger": "^1.3.1" }, "devDependencies": { diff --git a/src/lib/cork/models/AdminApproverTypeModel.js b/src/lib/cork/models/AdminApproverTypeModel.js new file mode 100644 index 0000000..bd4b9de --- /dev/null +++ b/src/lib/cork/models/AdminApproverTypeModel.js @@ -0,0 +1,93 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import AdminApproverTypeService from '../services/AdminApproverTypeService.js'; +import AdminApproverTypeStore from '../stores/AdminApproverTypeStore.js'; + +class AdminApproverTypeModel extends BaseModel { + + constructor() { + super(); + + this.store = AdminApproverTypeStore; + this.service = AdminApproverTypeService; + + this.register('AdminApproverTypeModel'); + } + + /** + * @description Query approvers + * @param {String} args - an object with possible properties + * id(s) single or array of ids + * archived - archive approvers + * active - active approvers + * + */ + async query(args = {}) { + let state = this.store.data.query[args];; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.query(args); + } + } catch(e) {} + return this.store.data.query[args]; + } + + /** + * @description Create data of approvers + * @param {String} data - data to create a new approvers + */ + + async create(data) { + // payload = Array.isArray(payload) ? payload : [payload]; + // let state = this.store.data.create; + + if(data.systemGenerated && data.archived) { + console.error("System Generated ApproverTypes can not be archived. Set Archive to false."); + return; + } + if(data.systemGenerated && data["employees"].length != 0) { + console.error("System Generated ApproverTypes can not have employees. Set employees to an empty array."); + return; + } + + try { + let state = this.store.data.create[data];; + + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.create(data); + } + } catch(e) {} + + const out = this.store.data.create; + + return out; + } + + /** + * @description Update data of approvers + * @param {String} data - data to update for approvers + */ + async update(data) { + // payload = Array.isArray(payload) ? payload : [payload]; + let state = this.store.data.update[data]; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.update(data); + } + } catch(e) {} + + const out = this.store.data.update; + + return out; + } + + +} + +const model = new AdminApproverTypeModel(); +export default model; diff --git a/src/lib/cork/models/ApproverTypeModel.js b/src/lib/cork/models/ApproverTypeModel.js deleted file mode 100644 index 5ba28e5..0000000 --- a/src/lib/cork/models/ApproverTypeModel.js +++ /dev/null @@ -1,115 +0,0 @@ -import {BaseModel} from '@ucd-lib/cork-app-utils'; -import ApproverService from '../services/ApproverService.js'; -import ApproverStore from '../stores/ApproverStore.js'; - -class ApproverModel extends BaseModel { - - constructor() { - super(); - - this.store = ApproverStore; - this.service = ApproverService; - - this.register('ApproverModel'); - } - - /** - * @description Query approvers - * @param {String} args - an object with possible properties - * id(s) single or array of ids - * archived - archive approvers - * active - active approvers - * - */ - async query(args) { - let state = this.store.data.query[args];; - try { - if( state && state.state === 'loading' ) { - await state.request; - } else { - await this.service.query(args); - } - } catch(e) {} - return this.store.data.query[args]; - } - - /** - * @description Create data of approvers - * @param {String} data - data to create a new approvers - */ - - create(data) { - payload = Array.isArray(payload) ? payload : [payload]; - let state = this.store.data.lastUpdate; - try { - if( state && state.state === 'loading' ) { - await state.request; - } else { - await this.service.updateSettings(payload); - } - } catch(e) {} - - const out = this.store.data.lastUpdate; - - // clear cache for categories that were updated - // reload categories that were updated and previously loaded - if ( this.store.data.lastUpdate.state === 'loaded' ) { - const categories = new Set(); - payload.forEach(setting => { - (setting.categories || []).forEach(category => { - categories.add(category); - }); - }); - categories.forEach(category => { - const hadCache = this.clearCategoryCache(category); - if ( hadCache ) { - this.getByCategory(category); - } - }); - } - - return out; - } - - /** - * @description Update data of approvers - * @param {String} data - data to update for approvers - */ - async update(data) { - payload = Array.isArray(payload) ? payload : [payload]; - let state = this.store.data.lastUpdate; - try { - if( state && state.state === 'loading' ) { - await state.request; - } else { - await this.service.updateSettings(payload); - } - } catch(e) {} - - const out = this.store.data.lastUpdate; - - // clear cache for categories that were updated - // reload categories that were updated and previously loaded - if ( this.store.data.lastUpdate.state === 'loaded' ) { - const categories = new Set(); - payload.forEach(setting => { - (setting.categories || []).forEach(category => { - categories.add(category); - }); - }); - categories.forEach(category => { - const hadCache = this.clearCategoryCache(category); - if ( hadCache ) { - this.getByCategory(category); - } - }); - } - - return out; - } - - -} - -const model = new ApproverModel(); -export default model; diff --git a/src/lib/cork/services/AdminApproverTypeService.js b/src/lib/cork/services/AdminApproverTypeService.js new file mode 100644 index 0000000..88ac9ef --- /dev/null +++ b/src/lib/cork/services/AdminApproverTypeService.js @@ -0,0 +1,58 @@ +import BaseService from './BaseService.js'; +import AdminApproverTypeStore from '../stores/AdminApproverTypeStore.js'; + +class AdminApproverTypeService extends BaseService { + + constructor() { + super(); + this.store = AdminApproverTypeStore; + + } + + query(data){ + let arrStr = encodeURIComponent(JSON.stringify(data)); + return this.request({ + url : `/api/admin/approver-type?data=${arrStr}`, + + checkCached: () => this.store.data.query[data], + onLoading : request => this.store.queryLoading(request, data), + onLoad : result => this.store.queryLoaded(result.body, data), + onError : e => this.store.queryError(e, data) + }); + } + + create(data){ + return this.request({ + url : `/api/admin/approver-type`, + fetchOptions : { + method : 'POST', + body : data + }, + json: true, + checkCached: () => this.store.data.create[data], + onLoading : request => this.store.createLoading(request, data), + onLoad : result => this.store.createLoaded(result.body, data), + onError : e => this.store.createError(e, data) + }); + } + + update(data) { + return this.request({ + url : `/api/admin/approver-type`, + fetchOptions : { + method : 'PUT', + body : data + }, + json: true, + checkCached: () => this.store.data.update[data], + onLoading : request => this.store.updateLoading(request), + onLoad : result => this.store.updateLoaded(result.body), + onError : e => this.store.updateError(e) + }); + + } + +} + +const service = new AdminApproverTypeService(); +export default service; diff --git a/src/lib/cork/services/ApproverTypeService.js b/src/lib/cork/services/ApproverTypeService.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/cork/services/BaseService.js b/src/lib/cork/services/BaseService.js index bcb9264..5d8da9d 100644 --- a/src/lib/cork/services/BaseService.js +++ b/src/lib/cork/services/BaseService.js @@ -26,6 +26,7 @@ export default class BaseServiceImp extends BaseService { options.fetchOptions.headers.Authorization = `Bearer ${kc.token}` } catch (error) {} } + return await super.request(options); } } diff --git a/src/lib/cork/services/EmployeeService.js b/src/lib/cork/services/EmployeeService.js index 2421a48..2e94f80 100644 --- a/src/lib/cork/services/EmployeeService.js +++ b/src/lib/cork/services/EmployeeService.js @@ -6,6 +6,7 @@ class EmployeeService extends BaseService { constructor() { super(); this.store = EmployeeStore; + } getActiveTitles(){ diff --git a/src/lib/cork/services/SettingsService.js b/src/lib/cork/services/SettingsService.js index ec78d6d..99bf101 100644 --- a/src/lib/cork/services/SettingsService.js +++ b/src/lib/cork/services/SettingsService.js @@ -6,6 +6,7 @@ class SettingsService extends BaseService { constructor() { super(); this.store = SettingsStore; + } getByCategory(category){ diff --git a/src/lib/cork/stores/AdminApproverTypeStore.js b/src/lib/cork/stores/AdminApproverTypeStore.js new file mode 100644 index 0000000..52cb18e --- /dev/null +++ b/src/lib/cork/stores/AdminApproverTypeStore.js @@ -0,0 +1,102 @@ +import {BaseStore} from '@ucd-lib/cork-app-utils'; + +class AdminApproverTypeStore extends BaseStore { + + constructor() { + super(); + + this.data = { + query: {}, + create: {}, + update: {} + }; + this.events = { + APPROVERTYPE_QUERIED: 'approverType-queried', + APPROVERTYPE_CREATED: 'approverType-created', + APPROVERTYPE_UPDATED: 'approverType-updated' + }; + } + + queryLoading(request, data) { + this._setQueryState({ + state : this.STATE.LOADING, + request + }, data); + } + + queryLoaded(payload, data) { + this._setQueryState({ + state : this.STATE.LOADED, + payload + }, data); + } + + queryError(error, data) { + this._setQueryState({ + state : this.STATE.ERROR, + error + }, data); + } + + _setQueryState(state, data) { + this.data.query[data] = state; + this.emit(this.events.APPROVERTYPE_QUERIED, state); + } + + +///YOU ARE HERE + createLoading(request, data) { + this._setCreateState({ + state : this.STATE.LOADING, + request + }, data); + } + + createLoaded(payload, data) { + this._setCreateState({ + state : this.STATE.LOADED, + payload + }, data); + } + + createError(error, data) { + this._setCreateState({ + state : this.STATE.ERROR, + error + }, data); + } + + _setCreateState(state, data) { + this.data.create[data] = state; + this.emit(this.events.APPROVERTYPE_CREATED, state); + } + + updateLoading(request) { + this._setUpdateState({ + state : this.STATE.LOADING, + request + }); + } + + updateLoaded(payload, id, data) { + this._setUpdateState({ + state : this.STATE.LOADED, + payload + }); + } + + updateError(error, id, data) { + this._setUpdateState({ + state : this.STATE.ERROR, + error + }); + } + + _setUpdateState(state) { + this.data.update = state; + this.emit(this.events.APPROVERTYPE_UPDATED, state); + } +} + +const store = new AdminApproverTypeStore(); +export default store; diff --git a/src/lib/cork/stores/ApproverTypeStore.js b/src/lib/cork/stores/ApproverTypeStore.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/db-models/approver-type.js b/src/lib/db-models/approver-type.js deleted file mode 100644 index 78cac25..0000000 --- a/src/lib/db-models/approver-type.js +++ /dev/null @@ -1,119 +0,0 @@ -import cache from "./cache.js"; -import pg from "./pg.js"; - -/** - * @class Approver Type - * @description Class for querying data about approver information - */ -class ApproverType { - - - constructor(){ - this.entityFields = new EntityFields([ - {dbName: 'approver_type_id', jsonName: 'approverTypeId'}, - {dbName: 'label', jsonName: 'label'}, - {dbName: 'description', jsonName: 'description', userEditable: true}, - {dbName: 'system_generated', jsonName: 'systemGenerated'}, - {dbName: 'description', jsonName: 'description'}, - {dbName: 'hide_from_fund_assignment', jsonName: 'hideFromFundAssignment'}, - {dbName: 'archived', jsonName: 'archived', userEditable: true}, - ]); - } - /** - * @description Query the approver type - * @param {Object} args - object of ids, archive, active - * @returns {Object|Array} - * - * all props in approver_type camelcased - * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee - */ - async query(args={}){ - let res; - let archived = "f"; - - if(args.archived == true) archived = "t"; - - if(Array.isArray(args.id)) { - let v = pg.valuesArray(args.id); - res = await pg.query(`SELECT * FROM approver_type WHERE approver_type_id in $1 AND archived=$2`, [v, archived]); - - } else { - res = await pg.query(`SELECT * FROM approver_type WHERE approver_type_id in $1 AND archived=$2`, [args.id, archived]); - } - - if( res.error ) return res; - const data = this.entityFields.toJsonArray(res.res.rows); - if( single ) { - return data[0] || null; - } - return data; - } - - /** - * @description Create the approver type table - * @param {Object} data - approverType object including list of employees - * @returns {Object} {error: false} - */ - async create(data){ - let text = ` - INSERT INTO approver_type (type, query, data) - VALUES ($1, $2, $3) - ON CONFLICT (type, query) DO UPDATE SET data = $3, created = NOW() - `; - - const res = await pg.query(`SELECT * FROM settings WHERE categories && $1`, [categories]); - if( res.error ) return res; - return this.entityFields.toJsonArray(res.res.rows); - } - - /** - * @description Update the approver type table - * @param {Object} data - approverType object including list of employees - * use a transaction if changes are needed to the employee list - * @returns {Object} {error: false} - */ - async update(data){ - - { - - employees:{} - } - - if ( data && !Array.isArray(data) ) data = [data]; - if ( !data || !data.length ) return pg.returnError('No data provided'); - - const out = {error: false}; - const client = await pg.pool.connect(); - try { - await client.query('BEGIN'); - for( const setting of data ){ - let sql = 'UPDATE settings SET '; - const valueMap = {} - for( const field of this.entityFields.fields ){ - if ( !field.userEditable ) continue; - if ( setting.hasOwnProperty(field.jsonName) ) { - valueMap[field.dbName] = setting[field.jsonName]; - } - } - if ( Object.keys(valueMap).length === 0 ) { - // no user editable fields provided, skip this setting - continue; - } - const updateClause = pg.toUpdateClause(valueMap); - sql += `${updateClause.sql} WHERE settings_id = $${updateClause.values.length + 1}`; - const values = [...updateClause.values, setting.settingsId]; - await client.query(sql, values); - } - await client.query('COMMIT'); - } catch (error) { - await client.query('ROLLBACK'); - out.error = error; - } finally { - client.release(); - } - return out; - - } - } - -export default new ApproverType(); diff --git a/src/lib/db-models/approverType.js b/src/lib/db-models/approverType.js new file mode 100644 index 0000000..ccda0f2 --- /dev/null +++ b/src/lib/db-models/approverType.js @@ -0,0 +1,260 @@ +import pg from "./pg.js"; +import EntityFields from "../utils/EntityFields.js"; + +/** + * @class Admin Approver Type + * @description Class for querying data about admin approver information + */ +class AdminApproverType { + + constructor(){ + this.entityFields = new EntityFields([ + {dbName: 'approver_type_id', jsonName: 'approverTypeId'}, + {dbName: 'label', jsonName: 'label'}, + {dbName: 'description', jsonName: 'description', userEditable: true}, + {dbName: 'system_generated', jsonName: 'systemGenerated'}, + {dbName: 'hide_from_fund_assignment', jsonName: 'hideFromFundAssignment'}, + {dbName: 'archived', jsonName: 'archived', userEditable: true}, + ]); + } + /** + * @description Query the approver type + * @param {Object} args - object of ids, archive, active + * @returns {Object|Array} + * + * all props in approver_type camelcased + * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee + */ + async query(args){ + let res; + let archive = "false" + + if(args["status"] == "archived") archive = "true"; + + console.log(args); + + if(Array.isArray(args["id"])) { + let v = pg.valuesArray(args.id); + + let text = `SELECT * FROM approver_type WHERE approver_type_id in ${v} AND archived IS ` + archive; + + res = await pg.query(text, args.id); + + } else { + let text = `SELECT * FROM approver_type WHERE approver_type_id = $1 AND archived IS ` + archive; + + res = await pg.query(text, [args.id]); + + } + + if( res.error ) return res; + const data = this.entityFields.toJsonArray(res.res.rows); + + + return data; + } + + + /** + * @description Converts camelCase to underscores (snakecase) for column names + */ + underscore(s){ + return s.split(/\.?(?=[A-Z])/).join('_').toLowerCase(); + } + + + /** + * @description Create the admin approver type table + * @param {Object} data - admin approverType object including list of employees + * + * + * { + "label": "label", + "description": "descripton", + "systemGenerated": false, + "hideFromFundAssignment": false, + "archived": false, + "employees":{ + "employeeKerberos":"kerberos", + "approvalOrder": 3 + } + } + * + * + * @returns {Object} {error: false} + */ + async create(data){ + let text = 'INSERT INTO approver_type ('; + let props = [ + 'label', 'description', 'systemGenerated', 'hideFromFundAssignment', + 'archived' + ]; + console.log(data); + let approverEmployee = data.employees; + console.log(approverEmployee); + + const values = []; + let first = true; + for (const prop of props) { + if ( data.hasOwnProperty(prop) ){ + if ( first ) { + text += this.underscore(prop); + first = false; + } else { + text += `, ${this.underscore(prop)}`; + } + values.push(data[prop]); + } + } + text += `) VALUES ${pg.valuesArray(values)} RETURNING *`; + if ( Object.keys(approverEmployee).length === 0 ) return await pg.query(text, values); + + + const client = await pg.pool.connect(); + const out = {res: [], err: false}; + try{ + await client.query('BEGIN'); + const approverType = await client.query(text, values); + out.res.push(approverType); + console.log("firstOut:", out); + + const approverTypeId = approverType.rows[0].approver_type_id; + console.log("approverTypeId:", approverTypeId); + + for (const a of approverEmployee) { + console.log("A:", a); + console.log("ID:", approverTypeId); + const approverText = ` + INSERT INTO approver_type_employee (approver_type_id, employee_kerberos, approval_order) + VALUES ($1, $2, $3) + RETURNING * + `; + const approverParams = [approverTypeId, a.employeeKerberos, a.approvalOrder]; + const r = await client.query(approverText, approverParams); + out.res.push(r); + } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + out.err = error; + } finally { + client.release(); + } + + console.log("DB:",out); + + return out; + + + } + + /** + * @description Update the admin approver type table + * @param {Object} data - approverType object including list of employees + * use a transaction if changes are needed to the employee list + * + * + * { + "label": "label", + "description": "descripton", + "systemGenerated": false, + "hideFromFundAssignment": false, + "archived": false, + "employees":{ + "employee_kerberos":"kerberos", + "approval_order": 3 + } + } + * + * + * @returns {Object} {error: false} + */ + async update(data){ + + let id = data.approver_type_id; + + if ( data && Array.isArray(data) ) return pg.returnError('This takes only an ApproverType Object'); + if ( !data || Object.keys(data).length === 0 ) return pg.returnError('No data provided'); + + if ( !id ) { + return pg.returnError('id is required when updating approverType'); + } + + let approverEmployee = data.employees; + + const toUpdate = {}; + if ( data.label ) { + toUpdate['label'] = data.label; + } + if ( data.description ) { + toUpdate['description'] = data.description; + } + if ( data.systemGenerated ){ + toUpdate['system_generated'] = data.systemGenerated; + } + if ( data.hideFromFundAssignment ){ + toUpdate['hide_from_fund_assignment'] = data.hideFromFundAssignment; + } + if ( data.archived ){ + toUpdate['archived'] = data.archived; + } + if ( !Object.keys(toUpdate).length ){ + return pg.returnError('no valid fields to update'); + } + + const updateClause = pg.toUpdateClause(toUpdate); + const text = ` + UPDATE approver_type SET ${updateClause.sql} + WHERE approver_type_id = $${updateClause.values.length + 1} + RETURNING * + `; + + if ( Object.keys(approverEmployee).length === 0 ) return await pg.query(text, [...updateClause.values, id]); + + const client = await pg.pool.connect(); + const out = {res: [], err: false}; + try{ + await client.query('BEGIN'); + const approverType = await client.query(text, [...updateClause.values, id]); + + out.res.push(approverType); + const approverTypeId = approverType.rows[0].approver_type_id; + for (const a of approverEmployee) { + const toEmployeeUpdate = {}; + if ( approverTypeId ) { + toEmployeeUpdate['approver_type_id'] = approverTypeId; + } + if ( a.employeeKerberos ) { + toEmployeeUpdate['employee_kerberos'] = a.employeeKerberos; + } + if ( a.approvalOrder ){ + toEmployeeUpdate['approval_order'] = a.approvalOrder; + } + if ( !Object.keys(toEmployeeUpdate).length ){ + return pg.returnError('no valid fields to update'); + } + + + const updateEmployeeClause = pg.toUpdateClause(toEmployeeUpdate); + + const approverEmployeeText = ` + UPDATE approver_type_employee SET ${updateEmployeeClause.sql} + WHERE approver_type_id = $${updateEmployeeClause.values.length + 1} + RETURNING * + `; + + const r = await client.query(approverEmployeeText, [...updateEmployeeClause.values, id]); + out.res.push(r); + } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + out.err = error; + } finally { + client.release(); + } + return out; + + } +} +export default new AdminApproverType(); diff --git a/src/lib/db-models/pg.js b/src/lib/db-models/pg.js index 272a595..fdd16d2 100644 --- a/src/lib/db-models/pg.js +++ b/src/lib/db-models/pg.js @@ -1,5 +1,6 @@ import pg from 'pg'; const pool = new pg.Pool(); +console.log(pool); /** * @description Utility Wrapper around pg library @@ -19,7 +20,7 @@ class Pg { * @param {Array} values - Hydration values * @returns {Object} {res, err} */ - async query(text, values){ + async query(text, values){ const out = this.output; try { out.res = await pool.query(text, values); From 5bb2cb9d13b3b13c6770e704b95165c5e4c2db21 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Thu, 2 May 2024 14:06:31 -0700 Subject: [PATCH 031/274] cleanup code --- src/client/js/pages/app-page-home.js | 28 +--------------------------- src/lib/db-models/approverType.js | 12 +----------- src/lib/db-models/pg.js | 1 - 3 files changed, 2 insertions(+), 39 deletions(-) diff --git a/src/client/js/pages/app-page-home.js b/src/client/js/pages/app-page-home.js index 90e4726..56b31e6 100644 --- a/src/client/js/pages/app-page-home.js +++ b/src/client/js/pages/app-page-home.js @@ -15,7 +15,7 @@ export default class AppPageHome extends Mixin(LitElement) super(); this.render = render.bind(this); - this._injectModel('AppStateModel', 'AdminApproverTypeModel'); + this._injectModel('AppStateModel'); } @@ -34,32 +34,6 @@ export default class AppPageHome extends Mixin(LitElement) ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); - - - /* Create */ - // let data = { - // "label": "xlabel", - // "description": "xdescripton", - // "systemGenerated": false, - // "hideFromFundAssignment": false, - // "archived": false, - // "employees": [] - // }; - - - let data = { - "label": "realfinallabel", - "description": "finaldescripton", - "systemGenerated": true, - "hideFromFundAssignment": false, - "archived": false, - "employees":[{ - "employeeKerberos":"sbagg", - "approvalOrder": 77 - }] - }; - let sample = await this.AdminApproverTypeModel.create(data); - console.log(sample); // const d = await this.getPageData(); // const hasError = d.some(e => e.state === 'error'); // if ( !hasError ) this.AppStateModel.showLoaded(this.id); diff --git a/src/lib/db-models/approverType.js b/src/lib/db-models/approverType.js index ccda0f2..548b379 100644 --- a/src/lib/db-models/approverType.js +++ b/src/lib/db-models/approverType.js @@ -31,8 +31,6 @@ class AdminApproverType { if(args["status"] == "archived") archive = "true"; - console.log(args); - if(Array.isArray(args["id"])) { let v = pg.valuesArray(args.id); @@ -89,9 +87,7 @@ class AdminApproverType { 'label', 'description', 'systemGenerated', 'hideFromFundAssignment', 'archived' ]; - console.log(data); let approverEmployee = data.employees; - console.log(approverEmployee); const values = []; let first = true; @@ -116,14 +112,10 @@ class AdminApproverType { await client.query('BEGIN'); const approverType = await client.query(text, values); out.res.push(approverType); - console.log("firstOut:", out); const approverTypeId = approverType.rows[0].approver_type_id; - console.log("approverTypeId:", approverTypeId); for (const a of approverEmployee) { - console.log("A:", a); - console.log("ID:", approverTypeId); const approverText = ` INSERT INTO approver_type_employee (approver_type_id, employee_kerberos, approval_order) VALUES ($1, $2, $3) @@ -141,8 +133,6 @@ class AdminApproverType { client.release(); } - console.log("DB:",out); - return out; @@ -242,7 +232,7 @@ class AdminApproverType { WHERE approver_type_id = $${updateEmployeeClause.values.length + 1} RETURNING * `; - + const r = await client.query(approverEmployeeText, [...updateEmployeeClause.values, id]); out.res.push(r); } diff --git a/src/lib/db-models/pg.js b/src/lib/db-models/pg.js index 1386701..4973f86 100644 --- a/src/lib/db-models/pg.js +++ b/src/lib/db-models/pg.js @@ -1,6 +1,5 @@ import pg from 'pg'; const pool = new pg.Pool(); -console.log(pool); /** * @description Utility Wrapper around pg library From 1c1eaa971da03d3253b2e6f7d195e63afc1587dd Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Fri, 3 May 2024 11:12:25 -0700 Subject: [PATCH 032/274] stash --- src/client/js/components/app-approver-type.js | 61 +++++++++++++++ .../js/components/app-approver-type.tpl.js | 78 +++++++++++++++++++ .../ucdlib-employee-search-basic.tpl.js | 1 + .../pages/admin/app-page-admin-approvers.js | 4 +- .../admin/app-page-admin-approvers.tpl.js | 2 +- src/client/scss/components/approver-type.scss | 34 ++++++++ src/client/scss/style.scss | 2 + 7 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/client/js/components/app-approver-type.js create mode 100644 src/client/js/components/app-approver-type.tpl.js create mode 100644 src/client/scss/components/approver-type.scss diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js new file mode 100644 index 0000000..5c273a8 --- /dev/null +++ b/src/client/js/components/app-approver-type.js @@ -0,0 +1,61 @@ +import { LitElement } from 'lit'; +import {render, styles} from "./app-approver-type.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +import "./ucdlib-employee-search-basic.js" + + +export default class AppApproverType extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + existingApprovers:{type: Array, attribute: 'existingApprovers'}, + } + } + + // static get styles() { + // return styles(); + // } + + constructor() { + super(); + this.systemGenerated = true; + this.existingApprovers = []; + this.render = render.bind(this); + this._injectModel('AppStateModel', 'AdminApproverTypeModel'); + + + } + + connectedCallback() { + this._getApproverType(); + + super.connectedCallback() + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + // async _onAppStateUpdate(state) { + // console.log(state); + // this._getApproverType(); + // } + + + async _getApproverType(){ + let args = [{id:[1, 2, 3, 4, 10], status:"active"}]; + let approvers = await this.AdminApproverTypeModel.query(args); + let approverArray = approvers.payload.filter(function (el) { + return el.archived == false && + el.hideFromFundAssignment == false; + }); + + this.existingApprovers = approverArray; + this.requestUpdate(); + } + +} + +customElements.define('app-approver-type', AppApproverType); \ No newline at end of file diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js new file mode 100644 index 0000000..6000303 --- /dev/null +++ b/src/client/js/components/app-approver-type.tpl.js @@ -0,0 +1,78 @@ +import { html, css } from 'lit'; + +import '@ucd-lib/theme-elements/brand/ucd-theme-brand-textbox/ucd-theme-brand-textbox.js' + + +export function styles() { + const elementStyles = css` + :host { + display: block; + } + `; + + return [elementStyles]; +} + +export function render() { +return html` +
+

Approvers

+ +
+

When a request is submitted to this application, approval is required from a list + of employees determined by the funding source. Employees must be registered as an + approver before they can be added to the approval chain of a funding source. Some + approver types are automatically generated by this system and cannot be removed + in this section. +

+
+ + ${console.log(this.existingApprovers.length)} +
+ ${this.existingApprovers.map(approver => {html` +
+ +

${approver.label} + + ${!this.systemGenerated ? html``:html``} +

+
+ ${approver.description}
+ +

 ${!this.systemGenerated ? html`Sample Person`:html`System Generated`}

+
+
+ `})} +
+ +
+
+

Edit Approver +
+ + +
+ +
+ + +
+ + +

+ + +

+ + +

+
+
+ +

+ + +
+ + +`;} \ No newline at end of file diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js index 04ec816..491ba0c 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -12,4 +12,5 @@ export function styles() { export function render() { return html` +

Approver Employees go here

`;} diff --git a/src/client/js/pages/admin/app-page-admin-approvers.js b/src/client/js/pages/admin/app-page-admin-approvers.js index 69b63e8..68de856 100644 --- a/src/client/js/pages/admin/app-page-admin-approvers.js +++ b/src/client/js/pages/admin/app-page-admin-approvers.js @@ -2,7 +2,7 @@ import { LitElement } from 'lit'; import {render} from "./app-page-admin-approvers.tpl.js"; import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; - +import "../../components/app-approver-type.js" export default class AppPageAdminApprovers extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -34,8 +34,10 @@ export default class AppPageAdminApprovers extends Mixin(LitElement) this.AppStateModel.store.breadcrumbs[this.id] ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + } customElements.define('app-page-admin-approvers', AppPageAdminApprovers); diff --git a/src/client/js/pages/admin/app-page-admin-approvers.tpl.js b/src/client/js/pages/admin/app-page-admin-approvers.tpl.js index 52bec91..f8d4651 100644 --- a/src/client/js/pages/admin/app-page-admin-approvers.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-approvers.tpl.js @@ -4,6 +4,6 @@ import "../../components/ucdlib-employee-search-basic.js"; export function render() { return html` - + `;} diff --git a/src/client/scss/components/approver-type.scss b/src/client/scss/components/approver-type.scss new file mode 100644 index 0000000..6ddef37 --- /dev/null +++ b/src/client/scss/components/approver-type.scss @@ -0,0 +1,34 @@ +app-approver-type { + .approvertype-block{ + border-bottom: medium solid #DBEAF7; + padding:15px, 0px, 15px, 0px; + } + .user-icon{ + color:#13639E; + margin-left:10px;width:20px;height:20px; + } + .trash-icon{ + color:#C10230; + margin-left:10px;width:20px;height:20px; + } + + .field-container{ + font-size:19px; + } + + .textLabel{ + margin: 10px 0px; + } + + .section-header{ + margin-top:10px; + } + + .approvertype-form{ + border-bottom: medium solid #DBEAF7; + } + .btn { + margin: 15px 10px 5px 0px; + } + + } diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index aabba61..8338ebb 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -8,3 +8,5 @@ // components @use './components/dialog-modal.scss' as dialogModal; +@use './components/approver-type.scss' as approverType; + From b2e83dfd02317cde36989ffc12c3bcb68d7ab007 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Fri, 3 May 2024 16:27:21 -0400 Subject: [PATCH 033/274] #35 --- src/client/js/app-main.tpl.js | 2 +- .../ucdlib-employee-search-advanced.js | 25 +++++++++++ .../ucdlib-employee-search-advanced.tpl.js | 19 +++++++++ .../admin/app-page-admin-allocations-new.js | 42 +++++++++++++++++++ .../app-page-admin-allocations-new.tpl.js | 20 +++++++++ .../admin/app-page-admin-allocations.tpl.js | 17 +++++++- src/client/js/pages/bundles/admin.js | 1 + src/client/js/pages/bundles/index.js | 2 +- .../components/employee-search-advanced.scss | 0 src/client/scss/style.scss | 1 + src/lib/cork/models/AppStateModel.js | 6 ++- src/lib/cork/stores/AppStateStore.js | 1 + 12 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 src/client/js/components/ucdlib-employee-search-advanced.js create mode 100644 src/client/js/components/ucdlib-employee-search-advanced.tpl.js create mode 100644 src/client/js/pages/admin/app-page-admin-allocations-new.js create mode 100644 src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js create mode 100644 src/client/scss/components/employee-search-advanced.scss diff --git a/src/client/js/app-main.tpl.js b/src/client/js/app-main.tpl.js index 62f534e..fac4a1d 100644 --- a/src/client/js/app-main.tpl.js +++ b/src/client/js/app-main.tpl.js @@ -42,12 +42,12 @@ return html` - + diff --git a/src/client/js/components/ucdlib-employee-search-advanced.js b/src/client/js/components/ucdlib-employee-search-advanced.js new file mode 100644 index 0000000..e96a0db --- /dev/null +++ b/src/client/js/components/ucdlib-employee-search-advanced.js @@ -0,0 +1,25 @@ +import { LitElement } from 'lit'; +import {render} from "./ucdlib-employee-search-advanced.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + _initialized: {state: true}, + } + } + + constructor() { + super(); + this.render = render.bind(this); + this._initialized = false; + + this._injectModel('EmployeeModel'); + } + +} + +customElements.define('ucdlib-employee-search-advanced', UcdlibEmployeeSearchAdvanced); \ No newline at end of file diff --git a/src/client/js/components/ucdlib-employee-search-advanced.tpl.js b/src/client/js/components/ucdlib-employee-search-advanced.tpl.js new file mode 100644 index 0000000..9ea7afe --- /dev/null +++ b/src/client/js/components/ucdlib-employee-search-advanced.tpl.js @@ -0,0 +1,19 @@ +import { html, css } from 'lit'; + +export function render() { + if ( this._initialized == 'error' ) { + return html` +
+ An error occurred while loading the employee search form. Please try again later. +
+ `; + } else if ( this._initialized == 'loading' ) { + return html` +

Loading...

+ `; + } else if ( this._initialized ) { + return html` +

lets do some searching

+ `; + } +} \ No newline at end of file diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.js b/src/client/js/pages/admin/app-page-admin-allocations-new.js new file mode 100644 index 0000000..1729eeb --- /dev/null +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.js @@ -0,0 +1,42 @@ +import { LitElement } from 'lit'; +import {render} from "./app-page-admin-allocations-new.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +export default class AppPageAdminAllocationsNew extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this._injectModel('AppStateModel'); + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.setTitle('Add Employee Allocations'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin, + this.AppStateModel.store.breadcrumbs['admin-allocations'], + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + } + +} + +customElements.define('app-page-admin-allocations-new', AppPageAdminAllocationsNew); \ No newline at end of file diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js new file mode 100644 index 0000000..0ce3486 --- /dev/null +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js @@ -0,0 +1,20 @@ +import { html } from 'lit'; + +import "../../components/ucdlib-employee-search-advanced.js"; + +export function render() { +return html` +
+
+
+ +
+
+`;} \ No newline at end of file diff --git a/src/client/js/pages/admin/app-page-admin-allocations.tpl.js b/src/client/js/pages/admin/app-page-admin-allocations.tpl.js index 6b4d94e..2142088 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-allocations.tpl.js @@ -2,6 +2,19 @@ import { html } from 'lit'; export function render() { return html` - - + `;} \ No newline at end of file diff --git a/src/client/js/pages/bundles/admin.js b/src/client/js/pages/bundles/admin.js index 2c1ff04..8a7cd8a 100644 --- a/src/client/js/pages/bundles/admin.js +++ b/src/client/js/pages/bundles/admin.js @@ -1,5 +1,6 @@ import "../admin/app-page-admin.js"; import "../admin/app-page-admin-allocations.js"; +import "../admin/app-page-admin-allocations-new.js"; import "../admin/app-page-admin-approvers.js"; import "../admin/app-page-admin-line-items.js"; import "../admin/app-page-admin-reimbursement.js"; diff --git a/src/client/js/pages/bundles/index.js b/src/client/js/pages/bundles/index.js index eefbce8..2e683de 100644 --- a/src/client/js/pages/bundles/index.js +++ b/src/client/js/pages/bundles/index.js @@ -11,7 +11,7 @@ const defs = { ], "admin": [ 'admin', 'admin-approvers', 'admin-settings', - 'admin-allocations', 'admin-line-items', 'admin-reimbursement' + 'admin-allocations', 'admin-line-items', 'admin-reimbursement', 'admin-allocations-new' ], "reports": [ 'reports' diff --git a/src/client/scss/components/employee-search-advanced.scss b/src/client/scss/components/employee-search-advanced.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index aabba61..89b8ace 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -8,3 +8,4 @@ // components @use './components/dialog-modal.scss' as dialogModal; +@use './components/employee-search-advanced.scss' as employeeSearchAdvanced; diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index 6511daa..0c3e8b7 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -68,7 +68,11 @@ class AppStateModelImpl extends AppStateModel { } else if ( secondaryRoute === 'settings' ){ p = 'admin-settings'; } else if ( secondaryRoute === 'allocations' ){ - p = 'admin-allocations'; + if ( tertiaryRoute === 'new' ){ + p = 'admin-allocations-new'; + } else { + p = 'admin-allocations'; + } } else if ( secondaryRoute === 'line-items' ){ p = 'admin-line-items'; } else if ( secondaryRoute === 'reimbursement' ){ diff --git a/src/lib/cork/stores/AppStateStore.js b/src/lib/cork/stores/AppStateStore.js index 1e98d5f..61e545f 100644 --- a/src/lib/cork/stores/AppStateStore.js +++ b/src/lib/cork/stores/AppStateStore.js @@ -16,6 +16,7 @@ class AppStateStoreImpl extends AppStateStore { 'reports': {text: 'Reports', link: '/reports'}, 'admin': {text: 'Admin', link: '/admin'}, 'admin-allocations': {text: 'Employee Allocations', link: '/admin/allocations'}, + 'admin-allocations-new': {text: 'New', link: '/admin/allocations/new'}, 'admin-approvers': {text: 'Approvers and Funding Sources', link: '/admin/approvers'}, 'admin-reimbursement': {text: 'Reimbursement Requests', link: '/admin/reimbursement'}, 'admin-settings': {text: 'General Settings', link: '/admin/settings'}, From a93fd8f42546c1808d57b599a28e8bb47e72bf82 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Mon, 6 May 2024 16:29:47 -0400 Subject: [PATCH 034/274] #34 --- src/client/js/app-main.js | 8 + .../ucdlib-employee-search-advanced.js | 162 +++++++++++++++++- .../ucdlib-employee-search-advanced.tpl.js | 124 +++++++++++++- .../admin/app-page-admin-allocations-new.js | 69 ++++++-- .../app-page-admin-allocations-new.tpl.js | 11 +- .../pages/admin/app-page-admin-line-items.js | 18 +- .../components/employee-search-advanced.scss | 16 ++ src/lib/cork/models/AppStateModel.js | 27 ++- src/lib/utils/urlUtils.js | 15 ++ 9 files changed, 411 insertions(+), 39 deletions(-) diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 0a217ed..c5b4d98 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -44,6 +44,9 @@ import "./pages/app-page-home.js"; import "./components/app-toast.js"; import "./components/app-dialog-modal.js"; +// utils +import urlUtils from '../../lib/utils/urlUtils.js'; + /** * @class AppMain * @description The main app web component, which controls routing and other app-level functionality. @@ -258,6 +261,11 @@ export default class AppMain extends Mixin(LitElement) kc.onAuthError = () => {AuthModel.redirectUnauthorized();}; kc.onAuthSuccess = () => { customElements.define('app-main', AppMain); + + // replace state in history to remove keycloak state + const hash = urlUtils.stripFromHash(['iss']); + window.history.replaceState(null, null, hash ? `#${hash}` : window.location.pathname); + AuthModel.init(); AuthModel._onAuthRefreshSuccess(); }; diff --git a/src/client/js/components/ucdlib-employee-search-advanced.js b/src/client/js/components/ucdlib-employee-search-advanced.js index e96a0db..b43a4e4 100644 --- a/src/client/js/components/ucdlib-employee-search-advanced.js +++ b/src/client/js/components/ucdlib-employee-search-advanced.js @@ -8,18 +8,176 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) static get properties() { return { + departments: {type: Array}, + titleCodes: {type: Array}, + selectedDepartments: {type: Array}, + selectedTitleCodes: {type: Array}, + employeeName: {type: String}, + page: {type: Number}, + results: {type: Array}, + selectedEmployees: {type: Array}, + selectButtonText: {type: String}, + clearOnSelectConfirmation: {type: Boolean}, _initialized: {state: true}, + _formDisabled: {state: true}, + _searching: {state: true}, + _error: {state: true}, + _didSearch: {state: true}, + _allSelected: {state: true}, } } constructor() { super(); this.render = render.bind(this); + this.departments = []; + this.titleCodes = []; + this.selectedDepartments = []; + this.selectedTitleCodes = []; + this.page = 1; + this.employeeName = ''; + this.results = []; + this.selectedEmployees = []; this._initialized = false; + this._formDisabled = false; + this._searching = false; + this._error = false; + this._didSearch = false; + this.selectButtonText = 'Select'; + this.clearOnSelectConfirmation = false; + this._allSelected = false; - this._injectModel('EmployeeModel'); + this._injectModel('EmployeeModel', 'DepartmentModel'); + } + + /** + * @description Lit lifecycle hook + * @param {Map} changedProps - updated properties + */ + willUpdate(changedProps) { + + // disable form if no search criteria or already searching + let props = ['selectedDepartments', 'selectedTitleCodes', 'employeeName', '_searching']; + if( props.some(prop => changedProps.has(prop)) ) { + this._formDisabled = this._searching || (!this.employeeName && !this.selectedDepartments.length && !this.selectedTitleCodes.length); + } + + props = ['selectedEmployees', 'results']; + if( props.some(prop => changedProps.has(prop)) ) { + const resultsIds = new Set(this.results.map(e => e.user_id)); + const selectedIds = new Set(this.selectedEmployees.map(e => e.user_id)); + this._allSelected = this.results.length && [...resultsIds].every(id => selectedIds.has(id)); + } + } + + /** + * @description Initialize the component. Fetches data required for rendering filter selects. + * @returns {Promise} - returns array of cork-app-state promises e.g. {status: 'fulfilled', value: {state: 'loaded', payload: {}} + */ + async init(){ + + this._initialized = 'loading'; + + const promises = [ + this.DepartmentModel.getActiveDepartments(), + this.EmployeeModel.getActiveTitles() + ]; + + const resolvedPromises = await Promise.allSettled(promises); + const hasError = resolvedPromises.some(e => e.status === 'rejected' || e.value.state === 'error'); + if( hasError ) { + this._initialized = 'error'; + return resolvedPromises; + } + + this.departments = resolvedPromises[0].value.payload; + const titleCodes = resolvedPromises[1].value.payload; + titleCodes.sort((a, b) => { + if( a.titleDisplayName < b.titleDisplayName ) return -1; + if( a.titleDisplayName > b.titleDisplayName ) return 1; + return 0; + }); + this.titleCodes = titleCodes; + this._initialized = true; + + return resolvedPromises; + } + + async _onFormSubmit(e){ + e.preventDefault(); + if ( this._searching || this._formDisabled ) return; + this._searching = true; + this._error = false; + let results = []; + let maxPage = 1; + this._didSearch = false; + + try { + const response = await this.EmployeeModel.queryIam(this.getQueryObject()); + if ( response.state === 'loaded' ) { + results = response.payload.data; + maxPage = response.payload.totalPages; + } else { + this._error = true; + } + } catch(e) { + console.error(e); + this._error = true; + } + + this._searching = false; + this.results = results; + this.maxPage = maxPage; + this._didSearch = true; + } + + /** + * @description Get the query object for EmployeeModel.queryIam + * @returns {Object} + */ + getQueryObject(){ + const q = {}; + if( this.employeeName ) q.name = this.employeeName; + if( this.selectedDepartments.length ) q.department = this.selectedDepartments; + if( this.selectedTitleCodes.length ) q.titleCode = this.selectedTitleCodes; + if( this.page ) q.page = this.page; + return q; + } + + _employeeIsSelected(employee){ + return this.selectedEmployees.some(e => e.user_id === employee.user_id); + } + + _onEmployeeSelectToggle(employee){ + if( this._employeeIsSelected(employee) ) { + this.selectedEmployees = this.selectedEmployees.filter(e => e.user_id !== employee.user_id); + } else { + this.selectedEmployees = [...this.selectedEmployees, employee]; + } + } + + _getEmployeeDepartment(employee){ + let out = ''; + for (const group of (employee.groups || [])) { + if ( group.partOfOrg ) return group.name || ''; + } + return out; + } + + _onSelectAllToggle(){} + + _onSelectConfirmation(){ + this.dispatchEvent(new CustomEvent('employee-select', { + detail: this.selectedEmployees + })); + + if( this.clearOnSelectConfirmation ) { + this.selectedEmployees = []; + this.results = []; + this._didSearch = false; + } } } -customElements.define('ucdlib-employee-search-advanced', UcdlibEmployeeSearchAdvanced); \ No newline at end of file +customElements.define('ucdlib-employee-search-advanced', UcdlibEmployeeSearchAdvanced); diff --git a/src/client/js/components/ucdlib-employee-search-advanced.tpl.js b/src/client/js/components/ucdlib-employee-search-advanced.tpl.js index 9ea7afe..c6a0016 100644 --- a/src/client/js/components/ucdlib-employee-search-advanced.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-advanced.tpl.js @@ -1,6 +1,11 @@ -import { html, css } from 'lit'; +import { html } from 'lit'; +import '@ucd-lib/theme-elements/brand/ucd-theme-slim-select/ucd-theme-slim-select.js' -export function render() { +/** + * @description Main render function for this element + * @returns {TemplateResult} + */ +export function render() { if ( this._initialized == 'error' ) { return html`
@@ -13,7 +18,118 @@ export function render() { `; } else if ( this._initialized ) { return html` -

lets do some searching

+
+ ${renderForm.call(this)} + ${this._error ? html` +
+ An error occurred while searching the Library personnel database. Please try again. +
+ ` : renderResults.call(this)} +
`; } -} \ No newline at end of file +} + +/** + * @description Render the form for the advanced employee search + * @returns {TemplateResult} + */ +function renderForm(){ + return html` +
+
+ + this.employeeName = e.target.value}> +
+
+ + this.selectedDepartments = e.detail.map(option => parseInt(option.value))}> + + +
+
+ + this.selectedTitleCodes = e.detail.map(option => option.value)}> + + +
+ +
+ `; +} + +function renderResults(){ + if ( !this._didSearch ) { + return html``; + } + if ( !this.results.length ) { + return html` +
+ No results found. +
+ `; + } + + const selectAllId = Math.random().toString(36).substring(7); + return html` +
+
+
+ + +
+
+
+ ${this.results.map(employee => html` +
+ this._onEmployeeSelectToggle(employee)}> + +
+ `)} +
+ +
+ ` +} diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.js b/src/client/js/pages/admin/app-page-admin-allocations-new.js index 1729eeb..9891145 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.js @@ -2,13 +2,15 @@ import { LitElement } from 'lit'; import {render} from "./app-page-admin-allocations-new.tpl.js"; import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +import { WaitController } from "@ucd-lib/theme-elements/utils/controllers/wait.js"; +import { createRef } from 'lit/directives/ref.js'; export default class AppPageAdminAllocationsNew extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { static get properties() { return { - + } } @@ -16,6 +18,9 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) super(); this.render = render.bind(this); + this.employeeSearchRef = createRef(); + this.waitController = new WaitController(this); + this._injectModel('AppStateModel'); } @@ -23,20 +28,56 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) * @description bound to AppStateModel app-state-update event * @param {Object} state - AppStateModel state */ - async _onAppStateUpdate(state) { - if ( this.id !== state.page ) return; - - this.AppStateModel.setTitle('Add Employee Allocations'); - - const breadcrumbs = [ - this.AppStateModel.store.breadcrumbs.home, - this.AppStateModel.store.breadcrumbs.admin, - this.AppStateModel.store.breadcrumbs['admin-allocations'], - this.AppStateModel.store.breadcrumbs[this.id] - ]; - this.AppStateModel.setBreadcrumbs(breadcrumbs); + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.showLoading(); + + this.AppStateModel.setTitle('Add Employee Allocations'); + + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin, + this.AppStateModel.store.breadcrumbs['admin-allocations'], + this.AppStateModel.store.breadcrumbs[this.id] + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); + + const d = await this.getPageData(); + const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + if( hasError ) { + this.AppStateModel.showError(d); + return; } + this.AppStateModel.showLoaded(this.id); + this.requestUpdate(); + } + + /** + * @description Get all data required for rendering this page + */ + async getPageData(){ + + // need to ensure that employee search has been rendered before we can initialize it + await this.waitController.waitForUpdate(); + + const promises = []; + promises.push(this.employeeSearchRef.value.init()); + const resolvedPromises = await Promise.allSettled(promises); + + // flatten resolved promises - employee search returns an array of promises + const out = []; + resolvedPromises.forEach(p => { + if ( Array.isArray(p.value) ) { + out.push(...p.value); + } else { + out.push(p); + } + }); + return out; + + } } -customElements.define('app-page-admin-allocations-new', AppPageAdminAllocationsNew); \ No newline at end of file +customElements.define('app-page-admin-allocations-new', AppPageAdminAllocationsNew); diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js index 0ce3486..4947f36 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js @@ -1,8 +1,9 @@ import { html } from 'lit'; +import { ref } from 'lit/directives/ref.js'; import "../../components/ucdlib-employee-search-advanced.js"; -export function render() { +export function render() { return html`
@@ -11,10 +12,14 @@ return html`

Search Library Employees

- + +
-`;} \ No newline at end of file +`;} diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index aaa5a23..8225e63 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -59,15 +59,15 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); - try { - const d = await this.getPageData(); - const hasError = d.some(e => e.state === 'error'); - if ( !hasError ) this.AppStateModel.showLoaded(this.id); - this.requestUpdate(); - } catch(e) { - this.AppStateModel.showError(this.id); + const d = await this.getPageData(); + const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + if ( hasError ) { + this.AppStateModel.showError(d); + return; } + this.AppStateModel.showLoaded(this.id); + this.requestUpdate(); } /** @@ -182,7 +182,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) /** * @description bound to LineItemsModel LINE_ITEM_CREATED event */ - async _onLineItemCreated(e){ + async _onLineItemCreated(e){ if ( e.state === 'error' ) { if ( e.error?.payload?.is400 ) { this.newLineItem.validationHandler = new ValidationHandler(e); @@ -251,7 +251,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) const promises = []; promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); promises.push(this.LineItemsModel.getActiveLineItems()); - const resolvedPromises = await Promise.all(promises); + const resolvedPromises = await Promise.allSettled(promises); return resolvedPromises; } diff --git a/src/client/scss/components/employee-search-advanced.scss b/src/client/scss/components/employee-search-advanced.scss index e69de29..e9ebe95 100644 --- a/src/client/scss/components/employee-search-advanced.scss +++ b/src/client/scss/components/employee-search-advanced.scss @@ -0,0 +1,16 @@ +ucdlib-employee-search-advanced { + + form { + border-bottom: 2px dotted #ffbf00; + padding-bottom: 1.5rem; + margin-bottom: 1.5rem; + } + .result { + + margin-bottom: .5rem; + + label { + width: calc(100% - 1.75rem); + } + } +} diff --git a/src/lib/cork/models/AppStateModel.js b/src/lib/cork/models/AppStateModel.js index 0c3e8b7..e352faa 100644 --- a/src/lib/cork/models/AppStateModel.js +++ b/src/lib/cork/models/AppStateModel.js @@ -170,7 +170,7 @@ class AppStateModelImpl extends AppStateModel { this.store.emit('breadcrumb-update', b); } - + /** * @description Show dismissable toast banner in popup. Will disappear on next app-state-update event * @param {Object} options Toast object with the following properties: @@ -180,13 +180,13 @@ class AppStateModelImpl extends AppStateModel { showToast(option){ if ( Array.isArray(option) ) return; - if( typeof option === 'object' ) + if( typeof option === 'object' ) this.store.emit('toast-update', option); - + } /** - * @description Show a modal dialog box. + * @description Show a modal dialog box. * To listen for the action event, add the _onDialogAction method to your element and then filter on e.action * @param {Object} options Dialog object with the following properties: * - title {String} - The title of the dialog (optional) @@ -197,7 +197,7 @@ class AppStateModelImpl extends AppStateModel { * - invert {Boolean} - Invert the button color (optional) * - color {String} - The brand color string of the button (optional) * - data {Object} - Any data to pass along in the action event (optional) - * + * * If the actions array is empty, a 'Dismiss' button will be added automatically */ showDialogModal(options={}){ @@ -222,7 +222,6 @@ class AppStateModelImpl extends AppStateModel { */ dismissToast(){ let dismissMessage = "Toast Dismissed"; - this.store.emit('toast-dismiss', {message: dismissMessage}); } @@ -238,9 +237,23 @@ class AppStateModelImpl extends AppStateModel { * @param {String|Object} msg Error message to show or cork-app-utils response object */ showError(msg='', fallbackMessage=''){ - let errorMessage = '' + let errorMessage = ''; + + // if array, find and use first error + if ( Array.isArray(msg) ) { + msg = msg.find(m => m.status === 'rejected' || m.value.state === 'error'); + if ( !msg ) msg = fallbackMessage; + } + if ( typeof msg === 'object' ) { console.error(msg); + + // is object from Promise.allSettled + if ( msg.status === 'fulfilled' ){ + msg = msg.value; + } + + // try to get error message from cork-app-utils response object if ( msg?.error?.response?.status == 404 ){ errorMessage = 'Page not found'; } else if ( msg?.error?.response?.status == 401 ){ diff --git a/src/lib/utils/urlUtils.js b/src/lib/utils/urlUtils.js index 604d7ae..8df654c 100644 --- a/src/lib/utils/urlUtils.js +++ b/src/lib/utils/urlUtils.js @@ -28,6 +28,21 @@ class UrlUtils { v.sort(); return v.join(','); } + + /** + * @description Strip keys from a hash + * @param {Array} keys - keys to strip from hash + * @param {String} hash - hash - default window.location.hash + * @returns {String} + */ + stripFromHash(keys=[], hash){ + hash = hash || window.location.hash; + hash = hash.replace(/^#/,''); + if ( !hash ) return ''; + const searchParams = new URLSearchParams(hash); + keys.forEach(k => searchParams.delete(k)); + return searchParams.toString(); + } } export default new UrlUtils(); From 8a7f1f29386fecb63b3dd39666fef5313bfb74fc Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 7 May 2024 09:58:34 -0400 Subject: [PATCH 035/274] #34 --- .../ucdlib-employee-search-advanced.js | 108 +++++++++++++++++- .../ucdlib-employee-search-advanced.tpl.js | 16 ++- .../admin/app-page-admin-allocations-new.js | 4 + .../app-page-admin-allocations-new.tpl.js | 1 + .../components/employee-search-advanced.scss | 9 ++ 5 files changed, 135 insertions(+), 3 deletions(-) diff --git a/src/client/js/components/ucdlib-employee-search-advanced.js b/src/client/js/components/ucdlib-employee-search-advanced.js index b43a4e4..c4c57e0 100644 --- a/src/client/js/components/ucdlib-employee-search-advanced.js +++ b/src/client/js/components/ucdlib-employee-search-advanced.js @@ -3,6 +3,28 @@ import {render} from "./ucdlib-employee-search-advanced.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +/** + * @description Advanced employee search component. + * Allows for searching the Library personnel database by name, department, and title code. + * And selecting multiple employees to add to a list. + * @param {Array} departments - array of department objects retrieved from DepartmentModel + * @param {Array} titleCodes - array of title code objects retrieved from EmployeeModel + * @param {Array} selectedDepartments - array of selected department ids + * @param {Array} selectedTitleCodes - array of selected title codes + * @param {String} employeeName - search input for employee name + * @param {Number} page - current page number of search results + * @param {Number} maxPage - total number of pages of search results + * @param {Array} results - array of employee objects returned from search + * @param {Array} selectedEmployees - array of selected employee objects + * @param {String} selectButtonText - text for the select confirmation button at the bottom of the component + * @param {Boolean} clearOnSelectConfirmation - clear form and results on select confirmation + * @param {Boolean} _initialized - component initialization status + * @param {Boolean} _formDisabled - form is disabled + * @param {Boolean} _searching - search is in progress + * @param {Boolean} _error - error occurred during previous search + * @param {Boolean} _didSearch - search has been performed + * @param {Boolean} _allSelected - all displayed employees are selected + */ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -14,6 +36,7 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) selectedTitleCodes: {type: Array}, employeeName: {type: String}, page: {type: Number}, + maxPage: {type: Number}, results: {type: Array}, selectedEmployees: {type: Array}, selectButtonText: {type: String}, @@ -23,7 +46,7 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) _searching: {state: true}, _error: {state: true}, _didSearch: {state: true}, - _allSelected: {state: true}, + _allSelected: {state: true} } } @@ -35,6 +58,7 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) this.selectedDepartments = []; this.selectedTitleCodes = []; this.page = 1; + this.maxPage = 1; this.employeeName = ''; this.results = []; this.selectedEmployees = []; @@ -103,6 +127,11 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) return resolvedPromises; } + /** + * @description Handle form submission. Queries the employee model with the form data. + * @param {Event} e - form submit event + * @returns + */ async _onFormSubmit(e){ e.preventDefault(); if ( this._searching || this._formDisabled ) return; @@ -110,6 +139,7 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) this._error = false; let results = []; let maxPage = 1; + this.page = 1; this._didSearch = false; try { @@ -129,6 +159,7 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) this.results = results; this.maxPage = maxPage; this._didSearch = true; + this.selectedEmployees = []; } /** @@ -144,10 +175,19 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) return q; } + /** + * @description Check if an employee is selected + * @param {Object} employee - employee object retrieved from model + * @returns {Boolean} + */ _employeeIsSelected(employee){ return this.selectedEmployees.some(e => e.user_id === employee.user_id); } + /** + * @description Handle individual employee select toggle + * @param {Object} employee - employee object retrieved from model + */ _onEmployeeSelectToggle(employee){ if( this._employeeIsSelected(employee) ) { this.selectedEmployees = this.selectedEmployees.filter(e => e.user_id !== employee.user_id); @@ -156,6 +196,11 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) } } + /** + * @description Get the department name of an employee + * @param {Object} employee - employee object retrieved from model + * @returns {String} + */ _getEmployeeDepartment(employee){ let out = ''; for (const group of (employee.groups || [])) { @@ -164,8 +209,23 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) return out; } - _onSelectAllToggle(){} + /** + * @description Handle select all toggle. Removes or adds all displayed employees to selectedEmployees array + */ + _onSelectAllToggle(){ + const displayedEmployeeIds = this.results.map(e => e.user_id); + if ( this._allSelected ) { + this.selectedEmployees = this.selectedEmployees.filter(e => !displayedEmployeeIds.includes(e.user_id)); + } else { + const newlySelectedEmployees = this.results.filter(e => !this._employeeIsSelected(e)); + this.selectedEmployees = [...this.selectedEmployees, ...newlySelectedEmployees]; + } + } + + /** + * @description Handle "Add" button click. Dispatches employee-select event with selected employees + */ _onSelectConfirmation(){ this.dispatchEvent(new CustomEvent('employee-select', { detail: this.selectedEmployees @@ -175,9 +235,53 @@ export default class UcdlibEmployeeSearchAdvanced extends Mixin(LitElement) this.selectedEmployees = []; this.results = []; this._didSearch = false; + this.clearForm(); } } + /** + * @description Clear form inputs + */ + clearForm(){ + this.employeeName = ''; + this.selectedDepartments = []; + this.selectedTitleCodes = []; + } + + /** + * @description Handle pagination change. Queries the employee model with the new page number + * @param {Number} newPage - new page number + * @returns + */ + async _onPaginationChange(newPage){ + if ( this._searching ) return; + + this._searching = true; + this._error = false; + let results = []; + let maxPage = 1; + this.page = newPage; + this._didSearch = false; + + try { + const response = await this.EmployeeModel.queryIam(this.getQueryObject()); + if ( response.state === 'loaded' ) { + results = response.payload.data; + maxPage = response.payload.totalPages; + } else { + this._error = true; + } + } catch(e) { + console.error(e); + this._error = true; + } + + this._searching = false; + this.results = results; + this.maxPage = maxPage; + this._didSearch = true; + } + } customElements.define('ucdlib-employee-search-advanced', UcdlibEmployeeSearchAdvanced); diff --git a/src/client/js/components/ucdlib-employee-search-advanced.tpl.js b/src/client/js/components/ucdlib-employee-search-advanced.tpl.js index c6a0016..6b7dc48 100644 --- a/src/client/js/components/ucdlib-employee-search-advanced.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-advanced.tpl.js @@ -1,5 +1,7 @@ import { html } from 'lit'; + import '@ucd-lib/theme-elements/brand/ucd-theme-slim-select/ucd-theme-slim-select.js' +import '@ucd-lib/theme-elements/brand/ucd-theme-pagination/ucd-theme-pagination.js' /** * @description Main render function for this element @@ -84,6 +86,10 @@ function renderForm(){ `; } +/** + * @description Render the results of the advanced employee search + * @returns {TemplateResult} + */ function renderResults(){ if ( !this._didSearch ) { return html``; @@ -96,7 +102,7 @@ function renderResults(){ `; } - const selectAllId = Math.random().toString(36).substring(7); + const selectAllId = Math.random().toString(36).substring(10); return html`
@@ -125,6 +131,14 @@ function renderResults(){
`)}
+ this._onPaginationChange(e.detail.page)} + > +
diff --git a/src/client/scss/components/employee-search-advanced.scss b/src/client/scss/components/employee-search-advanced.scss index e9ebe95..d594e26 100644 --- a/src/client/scss/components/employee-search-advanced.scss +++ b/src/client/scss/components/employee-search-advanced.scss @@ -5,6 +5,15 @@ ucdlib-employee-search-advanced { padding-bottom: 1.5rem; margin-bottom: 1.5rem; } + .results-header { + + margin-bottom: 1rem; + + label { + font-weight: 700; + color: #022851; + } + } .result { margin-bottom: .5rem; From 22a31f9cf2520fb5695bdaee536619dc21e06159 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 7 May 2024 14:46:39 -0400 Subject: [PATCH 036/274] #36 --- deploy/cmds/get-env.sh | 2 +- .../admin/app-page-admin-allocations-new.js | 47 +++++++- .../app-page-admin-allocations-new.tpl.js | 106 +++++++++++++++--- .../pages/admin/app-page-admin-line-items.js | 9 ++ .../admin/app-page-admin-line-items.tpl.js | 13 ++- src/client/scss/global.scss | 3 + src/client/scss/pages/admin/allocations.scss | 49 ++++++++ src/client/scss/style.scss | 1 + src/lib/utils/iamEmployeeObjectAccessor.js | 41 +++++++ 9 files changed, 246 insertions(+), 25 deletions(-) create mode 100644 src/client/scss/pages/admin/allocations.scss create mode 100644 src/lib/utils/iamEmployeeObjectAccessor.js diff --git a/deploy/cmds/get-env.sh b/deploy/cmds/get-env.sh index 0bfa804..42e50a3 100755 --- a/deploy/cmds/get-env.sh +++ b/deploy/cmds/get-env.sh @@ -33,7 +33,7 @@ while getopts "fl" opt; do done if [ "$LOCAL" = true ]; then - if [ -d "$LOCAL_DEV_DIRECTORY" ]; then + if [ ! -d "$LOCAL_DEV_DIRECTORY" ]; then mkdir -p "$LOCAL_DEV_DIRECTORY" fi ENV_PATH="$LOCAL_DEV_DIRECTORY/.env" diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.js b/src/client/js/pages/admin/app-page-admin-allocations-new.js index dd7346d..e054ec8 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.js @@ -4,19 +4,25 @@ import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; import { WaitController } from "@ucd-lib/theme-elements/utils/controllers/wait.js"; import { createRef } from 'lit/directives/ref.js'; +import IamEmployeeObjectAccessor from '../../../../lib/utils/iamEmployeeObjectAccessor.js'; export default class AppPageAdminAllocationsNew extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { static get properties() { return { - + employees: {type: Array}, + startDate: {type: String}, + endDate: {type: String} } } constructor() { super(); this.render = render.bind(this); + this.employees = []; + this.startDate = ''; + this.endDate = ''; this.employeeSearchRef = createRef(); this.waitController = new WaitController(this); @@ -53,8 +59,45 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) this.requestUpdate(); } + /** + * @description Event handler for when an employee is removed from the list + * @param {Object} employee - employee object + */ + _onRemoveEmployee(employee){ + this.employees = this.employees.filter(e => e.kerberos !== employee.kerberos); + } + + /** + * @description Event handler for when employees are selected from the employee search component + * @param {CustomEvent} e - employee-select event from ucdlib-employee-search-advanced + */ _onEmployeeSelect(e) { - console.log('Employee selected', e.detail); + const newEmployees = []; + for( let employee of e.detail ) { + employee = (new IamEmployeeObjectAccessor(employee)).travelAppObject; + if( this.employeeIsSelected(employee) ) continue; + newEmployees.push(employee); + } + this.employees = [...this.employees, ...newEmployees]; + } + + /** + * @description Check if an employee is already in the selected list + * @param {Object} employee - employee object + * @returns + */ + employeeIsSelected(employee) { + return this.employees.find(e => e.kerberos === employee.kerberos); + } + + _onFormSubmit(e) { + e.preventDefault(); + console.log('submit'); + } + + _onFormInput(prop, value){ + this[prop] = value; + this.requestUpdate(); } /** diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js index 7e97141..8222708 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js @@ -4,23 +4,93 @@ import { ref } from 'lit/directives/ref.js'; import "../../components/ucdlib-employee-search-advanced.js"; export function render() { -return html` -
-
-
- -`;} + `;} + + function renderForm(){ + const page = 'app-page-admin-allocations-new'; + + return html` +
+
+ Allocation +
+
+
+ + this._onFormInput('startDate', e.target.value)} + > +
+
+
+
+ + this._onFormInput('endDate', e.target.value)} + > +
+
+
+
+
+ Employees +
0}> +

No employees selected. Use search form to add employees to this list.

+
+
+
+
+
+
Name
+
+
Department
+
+ ${this.employees.map(employee => html` +
+
+ +
${employee.firstName} ${employee.lastName}
+
+
+
+
${employee?.department?.label || ''}
+
+
+ `)} +
+
+ +
+ `; + } diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index 8225e63..15ea476 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -225,6 +225,10 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } } + /** + * @description Bound to delete button for each line item + * @param {Object} lineItem - line item object to delete + */ _onDeleteClick(lineItem){ this.AppStateModel.showDialogModal({ title : 'Delete Line Item', @@ -237,6 +241,11 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) }); } + /** + * @description Callback for dialog-action AppStateModel event + * @param {Object} e - AppStateModel dialog-action event + * @returns + */ _onDialogAction(e){ if ( e.action !== 'delete-line-item' ) return; const lineItem = e.data.lineItem; diff --git a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js index 097b476..fe02403 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.tpl.js @@ -15,8 +15,8 @@ return html`
${renderLineItemForm.call(this, this.newLineItem)}
-
@@ -32,8 +32,8 @@ function renderLineItem(item) { title='Edit line item' @click=${e => this._onEditClick(item)} class='icon-link'> - this._onDeleteClick(item)} class='icon-link double-decker'> @@ -46,6 +46,11 @@ function renderLineItem(item) { ` } +/** + * @description Renders the form for editing or creating a line item + * @param {Object} item - Line Item object to edit or create + * @returns + */ function renderLineItemForm(item) { if ( !item || Object.keys(item).length === 0 ) return html``; const itemId = item.expenditureOptionId || 'new'; diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 02a9d75..127ddff 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -16,6 +16,9 @@ .bold { font-weight: 700; } +.flex { + display: flex; +} .field-error { diff --git a/src/client/scss/pages/admin/allocations.scss b/src/client/scss/pages/admin/allocations.scss new file mode 100644 index 0000000..8fcc7d3 --- /dev/null +++ b/src/client/scss/pages/admin/allocations.scss @@ -0,0 +1,49 @@ +app-page-admin-allocations-new { + .x-container { + display: flex; + align-items: center; + justify-content: center; + width: 25px; + height: 25px; + min-width: 25px; + min-height: 25px; + margin-right: .5rem; + margin-top: 3px; + } + .employee-list--header { + display: none; + font-weight: 700; + color: #022851; + padding-bottom: .5rem; + border-bottom: 2px dotted #ffbf00 + } + .employee-list--item { + padding: .5rem; + + .name { + font-weight: 700; + } + + .l-second { + margin-top: 0 !important; + + .x-container { + display: flex; + } + } + } + .employee-list--item:nth-child(odd) { + background-color: #ebf3fa; + } + @media (min-width: 992px) { + .employee-list--header { + display: grid; + } + .employee-list--item .name { + font-weight: 400; + } + .employee-list--item .l-second .x-container { + display: none; + } + } +} diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index 89b8ace..df823a9 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -5,6 +5,7 @@ // admin pages @use './pages/admin/settings.scss' as adminSettings; @use './pages//admin/line-items.scss' as adminLineItems; +@use './pages/admin/allocations.scss' as adminAllocations; // components @use './components/dialog-modal.scss' as dialogModal; diff --git a/src/lib/utils/iamEmployeeObjectAccessor.js b/src/lib/utils/iamEmployeeObjectAccessor.js new file mode 100644 index 0000000..9192088 --- /dev/null +++ b/src/lib/utils/iamEmployeeObjectAccessor.js @@ -0,0 +1,41 @@ +/** + * @name IamEmployeeObjectAccessor + * @description Used to access values and perform transformations on a Library employee IAM object. + */ +export default class IamEmployeeObjectAccessor { + constructor(iamObject={}) { + this.data = iamObject; + } + + /** + * @description Get simplified object with fields expected by the travel app + */ + get travelAppObject() { + const out = { + kerberos: this.data.user_id || '', + firstName: this.data.first_name || '', + lastName: this.data.last_name || '', + }; + const department = this.department; + if ( department && department.id ) { + out.department = { + departmentId: department.id, + label: department.name, + archived: false + } + } else { + out.department = null; + } + + return out; + } + + /** + * @description Get the department of the employee + */ + get department(){ + for (const group of (this.data.groups || [])) { + if ( group.partOfOrg ) return group; + } + } +} From 6780c7793fdc7ace1ce73b7ca288108e11b05965 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 7 May 2024 16:38:54 -0400 Subject: [PATCH 037/274] #36 --- src/api/admin/funding-source.js | 13 ++++ src/api/admin/index.js | 2 + src/lib/db-models/fundingSource.js | 99 ++++++++++++++++++++++++++++++ src/lib/db-models/pg.js | 18 +++++- 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src/api/admin/funding-source.js create mode 100644 src/lib/db-models/fundingSource.js diff --git a/src/api/admin/funding-source.js b/src/api/admin/funding-source.js new file mode 100644 index 0000000..d2fad8b --- /dev/null +++ b/src/api/admin/funding-source.js @@ -0,0 +1,13 @@ +import fundingSource from "../../lib/db-models/fundingSource.js"; +import protect from "../../lib/protect.js"; +export default (api) => { + + api.get('/funding-source', protect('hasBasicAccess'), async (req, res) => { + const data = await fundingSource.get({active: true}); + if ( data.error ){ + console.log(data); + } + return res.json(data); + }); + +}; diff --git a/src/api/admin/index.js b/src/api/admin/index.js index ff8b5ed..9104ee3 100644 --- a/src/api/admin/index.js +++ b/src/api/admin/index.js @@ -1,5 +1,6 @@ import express from 'express'; +import fundingSource from './funding-source.js'; import lineItems from './line-items.js'; import settings from './settings.js'; @@ -8,6 +9,7 @@ const router = express.Router(); // admin api routes +fundingSource(router); lineItems(router); settings(router); diff --git a/src/lib/db-models/fundingSource.js b/src/lib/db-models/fundingSource.js new file mode 100644 index 0000000..c5e8888 --- /dev/null +++ b/src/lib/db-models/fundingSource.js @@ -0,0 +1,99 @@ +import pg from "./pg.js"; +import EntityFields from "../utils/EntityFields.js"; + +class FundingSource { + constructor(){ + this.entityFields = new EntityFields([ + {dbName: 'funding_source_id', jsonName: 'fundingSourceId', required: true}, + {dbName: 'label', jsonName: 'label', label: 'Label', required: true, charLimit: 50}, + {dbName: 'description', jsonName: 'description'}, + {dbName: 'has_cap', jsonName: 'hasCap'}, + {dbName: 'cap_default', jsonName: 'capDefault', validateType: 'number'}, + {dbName: 'require_description', jsonName: 'requireDescription'}, + {dbName: 'form_order', jsonName: 'formOrder', validateType: 'integer'}, + {db_name: 'hide_from_form', jsonName: 'hideFromForm'}, + {dbName: 'archived', jsonName: 'archived'}, + {dbName: 'approver_types', jsonName: 'approverTypes', validateType: 'array'} + ]); + } + + /** + * + * @param {*} kwargs - optional arguments including: + * - active: boolean - if true, return only active (non-archived) options + * - archived: boolean - if true, return only archived options + * - ids: array|integer - if provided, return only funding sources with these ids + * @returns + */ + async get(kwargs={}){ + const whereArgs = {}; + if( kwargs.active ) { + whereArgs['fs.archived'] = false; + } else if( kwargs.archived ) { + whereArgs['fs.archived'] = true; + } + if( kwargs.ids ) { + whereArgs['fs.funding_source_id'] = Array.isArray(kwargs.ids) ? kwargs.ids : [kwargs.ids]; + } + const whereClause = pg.toWhereClause(whereArgs); + const query = ` + SELECT + fs.*, + json_agg(json_build_object( + 'approverTypeId', at.approver_type_id, + 'label', at.label, + 'systemGenerated', at.system_generated, + 'archived', at.archived, + 'approvalOrder', fsa.approval_order, + 'employees', ( + SELECT json_agg(json_build_object( + 'kerberos', e.kerberos, + 'firstName', e.first_name, + 'lastName', e.last_name, + 'approvalOrder', ata.approval_order + ) ORDER BY ata.approval_order) + FROM approver_type_employee ata + JOIN employee e ON ata.employee_kerberos = e.kerberos + WHERE ata.approver_type_id = at.approver_type_id + ) + ) ORDER BY fsa.approval_order) AS approver_types + FROM + funding_source fs + LEFT JOIN + funding_source_approver fsa ON fs.funding_source_id = fsa.funding_source_id + LEFT JOIN + approver_type at ON fsa.approver_type_id = at.approver_type_id + ${whereClause.sql ? `WHERE ${whereClause.sql}` : ''} + GROUP BY + fs.funding_source_id + ORDER BY + fs.form_order + ` + + const res = await pg.query(query, whereClause.values); + if( res.error ) return res; + return this._prepareResults(res.res.rows); + } + + /** + * @description Prepare the results of a fundingSource query for return + * @param {Object} res - the result of a fundingSource query + * @returns {Array} - array of fundingSource objects + */ + _prepareResults(res){ + const fundingSources = this.entityFields.toJsonArray(res); + for (const fundingSource of fundingSources) { + + if ( !fundingSource.approverTypes ) fundingSource.approverTypes = []; + fundingSource.approverTypes = fundingSource.approverTypes.filter(at => at.approverTypeId); + + for (const approverType of fundingSource.approverTypes) { + if ( !approverType.employees ) approverType.employees = []; + approverType.employees = approverType.employees.filter(e => e.kerberos); + } + } + return fundingSources; + } +} + +export default new FundingSource(); diff --git a/src/lib/db-models/pg.js b/src/lib/db-models/pg.js index 5115888..88a43ec 100644 --- a/src/lib/db-models/pg.js +++ b/src/lib/db-models/pg.js @@ -93,9 +93,21 @@ class Pg { let sql = ''; const values = []; if ( queryObject && typeof queryObject === 'object' ){ - for (const [i, k] of (Object.keys(queryObject)).entries()) { - values.push(queryObject[k]); - sql += `${i > 0 ? sep : ''}${k}=$${i+1}`; + let i = 0; + for (const k of Object.keys(queryObject)) { + // make an IN clause if the value is an array + if ( Array.isArray(queryObject[k]) ){ + const inClause = queryObject[k].map((v, j) => `$${i + j + 1}`).join(', '); + values.push(...queryObject[k]); + sql += `${i > 0 ? sep : ''}${k} IN (${inClause})`; + i += queryObject[k].length; + + // else make an equals clause + } else { + values.push(queryObject[k]); + sql += `${i > 0 ? sep : ''}${k}=$${i+1}`; + i++; + } } } return {sql, values}; From 523eda8ce8e3cd10cf3e2b1d6bb68d55212c7555 Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Tue, 7 May 2024 19:58:55 -0700 Subject: [PATCH 038/274] search component initial commit --- .../ucdlib-employee-search-basic.js | 167 +++++++++++++++++- .../ucdlib-employee-search-basic.tpl.js | 44 +++++ 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index 160cd92..a117945 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -7,7 +7,20 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) static get properties() { return { - + query: {type: String}, + labelText: {type: String, attribute: 'label-text'}, + hideLabel: {type: Boolean, attribute: 'hide-label'}, + results: {state: true}, + totalResults: {state: true}, + resultCtNotShown: {state: true}, + noResults: {state: true}, + error: {state: true}, + status: {state: true}, + isSearching: {state: true}, + showDropdown: {state: true}, + isFocused: {state: true}, + selectedText: {state: true}, + selectedObject: {state: true}, } } @@ -18,10 +31,162 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) constructor() { super(); this.render = render.bind(this); + this.query = ''; + this.results = []; + this.totalResults = 0; + this.resultCtNotShown = 0; + this.error = false; + this.labelText = 'Existing Employee Search'; + this.hideLabel = false; + this.status = 'idle'; + this.isSearching = false; + this.showDropdown = false; + this.isFocused = false; + this.noResults = false; + this.selectedText = ''; + this.selectedObject = {}; this._injectModel('EmployeeModel'); } + /** + * @description LitElement lifecycle called when element is updated + * @param {*} p - Changed properties + */ + willUpdate(p) { + if ( p.has('query') && p.length > 2 ){ + if ( this.searchTimeout ) clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + this.search(); + }, 500); + } + + if ( p.has('results') || p.has('totalResults') ) { + this.resultCtNotShown = this.totalResults - this.results.length; + } + + this._setStatus(p); + this._setShowDropdown(p); + } + + /** + * @description Disables the shadowdom + * @returns + */ + createRenderRoot() { + return this; + } + + /** + * @description Searches for employees by name. Fires when query property changes. + * @returns + */ + async search(){ + this.noResults = false; + this.selectedText = ''; + this.selectedObject = {}; + if ( !this.query ) { + this.results = []; + this.totalResults = 0; + this.error = false; + return; + } + this.isSearching = true; + const r = await this.EmployeeModel.queryIam(this.query); + this.isSearching = false; + if ( r.state === 'loaded' ) { + this.results = r.payload.results; + this.totalResults = r.payload.total; + this.noResults = !this.results.length; + this.error = false; + } + if ( r.state === 'error' ) { + this.error = true; + } + } + + /** + * @description Sets the status of the element based on the updated properties + * @param {*} p - Changed properties + */ + _setStatus(p){ + if ( p.has('isSearching') || p.has('query') || p.has('noResults') || p.has('selectedText') ){ + const detail = {status: this.status}; + let status = 'idle'; + if ( this.isSearching ) { + status = 'searching'; + } else if ( this.noResults ){ + status = 'no-results'; + } else if ( this.selectedText ){ + status = 'selected'; + detail.employee = this.selectedObject; + } + this.status = status; + + this.dispatchEvent(new CustomEvent('status-change', { + detail: detail + })); + } + + } + + /** + * @description Shows/hides the results dropdown based on the element's updated properties + * @param {*} p + */ + _setShowDropdown(p){ + if ( p.has('isFocused') || p.has('results') || p.has('query') || p.has('selectedText')){ + this.showDropdown = this.isFocused && this.results.length && this.query && !this.selectedText; + } + } + + /** + * @description Renders a single result item in the results dropdown + * @param {Object} result - an Employee object from the database + * @returns {TemplateResult} + */ + _renderResult(result){ + if ( !this.query) return html``; + + // highlight search term + let name = `${result.firstName} ${result.lastName}`.replace(//g, '>'); + const queries = this.query.replace(//g, '').split(' ').filter(q => q); + for (let query of queries) { + const regex = new RegExp(query, 'gi'); + name = name.replace(regex, (match) => `<${match}>`); + } + name = name.replace(/').replace(/>/g, ''); + name=`
${name}
`; + + return html` + ${unsafeHTML(name)} +
${result.title}
+ `; + + } + + /** + * @description Fires when a result is clicked from the dropdown + * @param {Object} result - an Employee object from the database + */ + _onSelect(result){ + this.selectedText = `${result.firstName} ${result.lastName}`; + this.selectedObject = result; + this.dispatchEvent(new CustomEvent('select', { + detail: {employee: result} + })); + } + + /** + * @description Hides dropdown box on input blur. + * Must give time for click event to fire on dropdown item. + */ + _onBlur(){ + setTimeout(() => { + this.isFocused = false; + }, 250); + } + } customElements.define('ucdlib-employee-search-basic', UcdlibEmployeeSearchBasic); diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js index 04ec816..a49a4ef 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -12,4 +12,48 @@ export function styles() { export function render() { return html` +
+
+
+ + +
+
+
+
+
+ ${this.results.map(result => html` +
this._onSelect(result)}> + ${this._renderResult(result)} +
+ `)} +
+
+ And ${this.resultCtNotShown} more employees...
Try refining your search. +
+
+
+
No results matched your search!
+
`;} From 87fc222b2d5c98df9d4ae04d96525d77c807e979 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 8 May 2024 10:01:33 -0400 Subject: [PATCH 039/274] #37 --- src/api/admin/funding-source.js | 23 +++++++++- src/client/js/app-main.js | 3 +- .../admin/app-page-admin-allocations-new.js | 2 +- src/lib/cork/models/FundingSourceModel.js | 34 ++++++++++++++ src/lib/cork/services/FundingSourceService.js | 24 ++++++++++ src/lib/cork/stores/FundingSourceStore.js | 45 +++++++++++++++++++ src/lib/utils/apiUtils.js | 21 +++++++++ 7 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/lib/cork/models/FundingSourceModel.js create mode 100644 src/lib/cork/services/FundingSourceService.js create mode 100644 src/lib/cork/stores/FundingSourceStore.js diff --git a/src/api/admin/funding-source.js b/src/api/admin/funding-source.js index d2fad8b..12ba95b 100644 --- a/src/api/admin/funding-source.js +++ b/src/api/admin/funding-source.js @@ -1,11 +1,32 @@ import fundingSource from "../../lib/db-models/fundingSource.js"; +import apiUtils from "../../lib/utils/apiUtils.js"; import protect from "../../lib/protect.js"; export default (api) => { + /** + * @description Get array of all active (non-archived) funding sources + */ api.get('/funding-source', protect('hasBasicAccess'), async (req, res) => { const data = await fundingSource.get({active: true}); if ( data.error ){ - console.log(data); + console.error('Error in GET /funding-source', data.error); + return res.status(500).json({error: true, message: 'Error getting funding sources.'}); + } + return res.json(data); + }); + + /** + * @description Get array of funding sources by id + * @param {String} req.params.ids - comma-separated list of funding source ids + */ + api.get('/funding-source/:ids', protect('hasBasicAccess'), async (req, res) => { + const ids = apiUtils.explode(req.params.ids, true); + if ( !ids ) return res.status(400).json({error: true, message: 'No valid ids provided.'}); + + const data = await fundingSource.get({ids}); + if ( data.error ){ + console.error('Error in GET /funding-source/:ids', data.error); + return res.status(500).json({error: true, message: 'Error getting funding sources.'}); } return res.json(data); }); diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index c5b4d98..51b3ba5 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -25,8 +25,9 @@ AppStateModel.init(appConfig.routes); // import cork models import "../../lib/cork/models/DepartmentModel.js"; import "../../lib/cork/models/EmployeeModel.js"; -import "../../lib/cork/models/SettingsModel.js"; +import "../../lib/cork/models/FundingSourceModel.js"; import "../../lib/cork/models/LineItemsModel.js"; +import "../../lib/cork/models/SettingsModel.js"; // auth import Keycloak from 'keycloak-js'; diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.js b/src/client/js/pages/admin/app-page-admin-allocations-new.js index e054ec8..59f2a6b 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.js @@ -27,7 +27,7 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) this.employeeSearchRef = createRef(); this.waitController = new WaitController(this); - this._injectModel('AppStateModel'); + this._injectModel('AppStateModel', 'FundingSourceModel'); } /** diff --git a/src/lib/cork/models/FundingSourceModel.js b/src/lib/cork/models/FundingSourceModel.js new file mode 100644 index 0000000..44fb9c1 --- /dev/null +++ b/src/lib/cork/models/FundingSourceModel.js @@ -0,0 +1,34 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import FundingSourceService from '../services/FundingSourceService.js'; +import FundingSourceStore from '../stores/FundingSourceStore.js'; + +class FundingSourceModel extends BaseModel { + + constructor() { + super(); + + this.store = FundingSourceStore; + this.service = FundingSourceService; + + this.register('FundingSourceModel'); + } + + /** + * @description Get all active (non-archived) funding sources + */ + async getActiveFundingSources(){ + let state = this.store.data.activeFundingSources; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.getActiveFundingSources(); + } + } catch(e) {} + return this.store.data.activeFundingSources; + } + +} + +const model = new FundingSourceModel(); +export default model; diff --git a/src/lib/cork/services/FundingSourceService.js b/src/lib/cork/services/FundingSourceService.js new file mode 100644 index 0000000..6965c1f --- /dev/null +++ b/src/lib/cork/services/FundingSourceService.js @@ -0,0 +1,24 @@ +import BaseService from './BaseService.js'; +import FundingSourceStore from '../stores/FundingSourceStore.js'; + +class FundingSourceService extends BaseService { + + constructor() { + super(); + this.store = FundingSourceStore; + } + + getActiveFundingSources(){ + return this.request({ + url : `/api/admin/funding-source`, + checkCached: () => this.store.data.activeFundingSources, + onLoading : request => this.store.activeFundingSourcesLoading(request), + onLoad : result => this.store.activeFundingSourcesLoaded(result.body), + onError : e => this.store.activeFundingSourcesError(e) + }); + } + +} + +const service = new FundingSourceService(); +export default service; diff --git a/src/lib/cork/stores/FundingSourceStore.js b/src/lib/cork/stores/FundingSourceStore.js new file mode 100644 index 0000000..2fd37de --- /dev/null +++ b/src/lib/cork/stores/FundingSourceStore.js @@ -0,0 +1,45 @@ +import {BaseStore} from '@ucd-lib/cork-app-utils'; + +class FundingSourceStore extends BaseStore { + + constructor() { + super(); + + this.data = { + activeFundingSources: {} + }; + this.events = { + ACTIVE_FUNDING_SOURCES_FETCHED: 'active-funding-sources-fetched', + }; + } + + activeFundingSourcesLoading(request) { + this._setActiveFundingSourcesState({ + state : this.STATE.LOADING, + request + }); + } + + activeFundingSourcesLoaded(payload) { + this._setActiveFundingSourcesState({ + state : this.STATE.LOADED, + payload + }); + } + + activeFundingSourcesError(error) { + this._setActiveFundingSourcesState({ + state : this.STATE.ERROR, + error + }); + } + + _setActiveFundingSourcesState(state) { + this.data.activeFundingSources = state; + this.emit(this.events.ACTIVE_FUNDING_SOURCES_FETCHED, state); + } + +} + +const store = new FundingSourceStore(); +export default store; diff --git a/src/lib/utils/apiUtils.js b/src/lib/utils/apiUtils.js index c822960..99e8718 100644 --- a/src/lib/utils/apiUtils.js +++ b/src/lib/utils/apiUtils.js @@ -1,3 +1,6 @@ +/** + * @description Utility functions for common API tasks - mostly request parsing stuff + */ class ApiUtils { /** @@ -17,6 +20,24 @@ class ApiUtils { if ( !Array.isArray(arr) ) return false; return arr.every(item => typeof item === 'object'); } + + /** + * @description Split a string into an array of values and optionally convert to integers + * @param {String} value - the value to split + * @param {Boolean} asInt - if true, convert to integers + * @returns {Array} + */ + explode(value, asInt=false){ + let out = []; + if ( !value ) return out; + if ( Array.isArray(value) ) { + out = value.map(item => item.trim()); + } else { + out = value.split(',').map(item => item.trim()); + } + if ( !asInt ) return out; + return out.map(item => parseInt(item)).filter(item => !isNaN(item)); + } } export default new ApiUtils(); From 7a7113b6d86e34c12be5480b161cbd9b0d977915 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 8 May 2024 16:22:41 -0400 Subject: [PATCH 040/274] #36 --- src/api/admin/employee-allocation.js | 18 +++ src/api/admin/index.js | 2 + src/client/js/app-main.js | 1 + .../admin/app-page-admin-allocations-new.js | 73 ++++++++++-- .../app-page-admin-allocations-new.tpl.js | 39 ++++++- .../cork/models/EmployeeAllocationModel.js | 31 ++++++ .../services/EmployeeAllocationService.js | 28 +++++ .../cork/stores/EmployeeAllocationStore.js | 48 ++++++++ src/lib/db-models/employee.js | 61 +++++++++- src/lib/db-models/employeeAllocation.js | 105 ++++++++++++++++++ src/lib/utils/EntityFields.js | 48 +++++++- 11 files changed, 440 insertions(+), 14 deletions(-) create mode 100644 src/api/admin/employee-allocation.js create mode 100644 src/lib/cork/models/EmployeeAllocationModel.js create mode 100644 src/lib/cork/services/EmployeeAllocationService.js create mode 100644 src/lib/cork/stores/EmployeeAllocationStore.js create mode 100644 src/lib/db-models/employeeAllocation.js diff --git a/src/api/admin/employee-allocation.js b/src/api/admin/employee-allocation.js new file mode 100644 index 0000000..a489b3a --- /dev/null +++ b/src/api/admin/employee-allocation.js @@ -0,0 +1,18 @@ +import employeeAllocation from "../../lib/db-models/employeeAllocation.js"; +import protect from "../../lib/protect.js"; + +export default (api) => { + + api.post('/employee-allocation', protect('hasAdminAccess'), async (req, res) => { + const payload = (typeof req.body === 'object') && !Array.isArray(req.body) ? req.body : {}; + const data = await employeeAllocation.create(payload); + if ( data.error && data.is400 ) { + return res.status(400).json(data); + } + if ( data.error ) { + console.error('Error in POST /employee-allocation', data.error); + return res.status(500).json({error: true, message: 'Error creating employee allocation.'}); + } + return res.json(data); + }); +}; diff --git a/src/api/admin/index.js b/src/api/admin/index.js index 9104ee3..5d84542 100644 --- a/src/api/admin/index.js +++ b/src/api/admin/index.js @@ -1,5 +1,6 @@ import express from 'express'; +import employeeAllocation from './employee-allocation.js'; import fundingSource from './funding-source.js'; import lineItems from './line-items.js'; import settings from './settings.js'; @@ -9,6 +10,7 @@ const router = express.Router(); // admin api routes +employeeAllocation(router); fundingSource(router); lineItems(router); settings(router); diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 51b3ba5..19c4ebb 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -24,6 +24,7 @@ AppStateModel.init(appConfig.routes); // import cork models import "../../lib/cork/models/DepartmentModel.js"; +import "../../lib/cork/models/EmployeeAllocationModel.js"; import "../../lib/cork/models/EmployeeModel.js"; import "../../lib/cork/models/FundingSourceModel.js"; import "../../lib/cork/models/LineItemsModel.js"; diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.js b/src/client/js/pages/admin/app-page-admin-allocations-new.js index 59f2a6b..12dc717 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.js @@ -13,21 +13,34 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) return { employees: {type: Array}, startDate: {type: String}, - endDate: {type: String} + endDate: {type: String}, + fundingAmount: {type: Number}, + fundingSources: {type: Array}, + selectedFundingSource: {type: Object} } } constructor() { super(); this.render = render.bind(this); - this.employees = []; - this.startDate = ''; - this.endDate = ''; + this.fundingSources = []; + this.resetForm(); this.employeeSearchRef = createRef(); this.waitController = new WaitController(this); - this._injectModel('AppStateModel', 'FundingSourceModel'); + this._injectModel('AppStateModel', 'FundingSourceModel', 'EmployeeAllocationModel'); + } + + /** + * @description Reset form state + */ + resetForm(){ + this.employees = []; + this.startDate = ''; + this.endDate = ''; + this.fundingAmount = 0; + this.selectedFundingSource = {}; } /** @@ -90,16 +103,61 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) return this.employees.find(e => e.kerberos === employee.kerberos); } - _onFormSubmit(e) { + /** + * @description Event handler for form submission + * @param {Event} e - Submit event + */ + async _onFormSubmit(e) { e.preventDefault(); - console.log('submit'); + const payload = { + startDate: this.startDate, + endDate: this.endDate, + fundingSourceId: this.selectedFundingSource.fundingSourceId, + amount: this.fundingAmount, + employees: this.employees + }; + console.log('submit', payload); + await this.EmployeeAllocationModel.createEmployeeAllocations(payload); + } + /** + * @description Event handler for form input fields + * @param {String} prop - property name + * @param {*} value - input value + */ _onFormInput(prop, value){ + if ( prop === 'fundingAmount' ) value = Number(value); this[prop] = value; this.requestUpdate(); } + /** + * @description Event handler for when a funding source is selected + * @param {Object} fundingSourceId - funding source id + * @returns + */ + _onFundingSourceSelect(fundingSourceId){ + const fundingSource = this.fundingSources.find(f => f.fundingSourceId == fundingSourceId); + if ( !fundingSource ) { + this.selectedFundingSource = {}; + this.fundingAmount = 0; + return; + } + this.selectedFundingSource = fundingSource; + this.fundingAmount = fundingSource.capDefault ? Number(fundingSource.capDefault) : 0; + } + + /** + * @description Attached to active-funding-sources-fetched event from FundingSourceModel + * @param {Object} e - cork-app-utils event object + * @returns + */ + _onActiveFundingSourcesFetched(e){ + if ( e.state !== 'loaded' ) return; + this.fundingSources = e.payload; + } + /** * @description Get all data required for rendering this page */ @@ -110,6 +168,7 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) const promises = []; promises.push(this.employeeSearchRef.value.init()); + promises.push(this.FundingSourceModel.getActiveFundingSources()); const resolvedPromises = await Promise.allSettled(promises); // flatten resolved promises - employee search returns an array of promises diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js index 8222708..4b97232 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js @@ -3,6 +3,10 @@ import { ref } from 'lit/directives/ref.js'; import "../../components/ucdlib-employee-search-advanced.js"; +/** + * @description Main render function + * @returns {TemplateResult} + */ export function render() { return html` @@ -28,6 +32,10 @@ export function render() {
`;} +/** + * @description Render the form + * @returns {TemplateResult} + */ function renderForm(){ const page = 'app-page-admin-allocations-new'; @@ -57,6 +65,35 @@ export function render() { +
+
+
+ + +
+
+
+
+ + this._onFormInput('fundingAmount', e.target.value)} + > +
+
+
Employees @@ -90,7 +127,7 @@ export function render() { `)}
- + `; } diff --git a/src/lib/cork/models/EmployeeAllocationModel.js b/src/lib/cork/models/EmployeeAllocationModel.js new file mode 100644 index 0000000..87ce144 --- /dev/null +++ b/src/lib/cork/models/EmployeeAllocationModel.js @@ -0,0 +1,31 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import EmployeeAllocationService from '../services/EmployeeAllocationService.js'; +import EmployeeAllocationStore from '../stores/EmployeeAllocationStore.js'; + +class EmployeeAllocationModel extends BaseModel { + + constructor() { + super(); + + this.store = EmployeeAllocationStore; + this.service = EmployeeAllocationService; + + this.register('EmployeeAllocationModel'); + } + + async createEmployeeAllocations(payload) { + let timestamp = Date.now(); + try { + await this.service.createEmployeeAllocations(payload, timestamp); + } catch(e) {} + const state = this.store.data.employeeAllocationsCreated[timestamp]; + if ( state && state.state === 'loaded' ) { + // todo clear cache + } + return state; + } + +} + +const model = new EmployeeAllocationModel(); +export default model; diff --git a/src/lib/cork/services/EmployeeAllocationService.js b/src/lib/cork/services/EmployeeAllocationService.js new file mode 100644 index 0000000..0e1d158 --- /dev/null +++ b/src/lib/cork/services/EmployeeAllocationService.js @@ -0,0 +1,28 @@ +import BaseService from './BaseService.js'; +import EmployeeAllocationStore from '../stores/EmployeeAllocationStore.js'; + +class EmployeeAllocationService extends BaseService { + + constructor() { + super(); + this.store = EmployeeAllocationStore; + } + + createEmployeeAllocations(payload, timestamp) { + return this.request({ + url : '/api/admin/employee-allocation', + fetchOptions : { + method : 'POST', + body : payload + }, + json: true, + onLoading : request => this.store.employeeAllocationsCreatedLoading(request, timestamp), + onLoad : result => this.store.employeeAllocationsCreatedLoaded(result.body, timestamp), + onError : e => this.store.employeeAllocationsCreatedError(e, timestamp) + }); + } + +} + +const service = new EmployeeAllocationService(); +export default service; diff --git a/src/lib/cork/stores/EmployeeAllocationStore.js b/src/lib/cork/stores/EmployeeAllocationStore.js new file mode 100644 index 0000000..3ef5525 --- /dev/null +++ b/src/lib/cork/stores/EmployeeAllocationStore.js @@ -0,0 +1,48 @@ +import {BaseStore} from '@ucd-lib/cork-app-utils'; + +class EmployeeAllocationStore extends BaseStore { + + constructor() { + super(); + + this.data = { + employeeAllocationsCreated: {}, + }; + this.events = { + EMPLOYEE_ALLOCATIONS_CREATED: 'employee-allocations-created', + }; + } + + employeeAllocationsCreatedLoading(request, timestamp) { + this._setEmployeeAllocationsCreatedState({ + state : this.STATE.LOADING, + request, + timestamp + }); + } + + employeeAllocationsCreatedLoaded(payload, timestamp) { + this._setEmployeeAllocationsCreatedState({ + state : this.STATE.LOADED, + payload, + timestamp + }); + } + + employeeAllocationsCreatedError(error, timestamp) { + this._setEmployeeAllocationsCreatedState({ + state : this.STATE.ERROR, + error, + timestamp + }); + } + + _setEmployeeAllocationsCreatedState(state) { + this.data.employeeAllocationsCreated[state.timestamp] = state; + this.emit(this.events.EMPLOYEE_ALLOCATIONS_CREATED, state); + } + +} + +const store = new EmployeeAllocationStore(); +export default store; diff --git a/src/lib/db-models/employee.js b/src/lib/db-models/employee.js index 1d72012..ab958d6 100644 --- a/src/lib/db-models/employee.js +++ b/src/lib/db-models/employee.js @@ -4,7 +4,9 @@ import pg from "./pg.js"; /** * @class Employee - * @description Class for querying data about library employees + * @description Model for: + * 1. querying data about library employees + * 2. accessing and updating local database employee records and cache */ class Employee { @@ -28,6 +30,63 @@ class Employee { ]; } + /** + * @description Insert or update an employee record in the local database as part of a transaction + * @param {*} client - A connected pg pool that has already 'begun' e.g: + * client = await pg.pool.connect() + * await client.query('BEGIN') + * @param {Object} employee - A basic employee record object with the following properties: + * - kerberos: String (required) + * - firstName: String (optional) + * - lastName: String (optional) + * - department: Object (optional) - with the following properties: + * - departmentId: String (required) + * - label: String (optional) + */ + async upsertInTransaction(client, employee){ + + if ( !employee.kerberos ) { + throw new Error('Employee record must have a kerberos id.'); + } + + // upsert department if it exists + const departmentId = employee?.department?.departmentId; + if ( departmentId ) { + const label = employee.department.label || ''; + const departmentRes = await client.query('SELECT * FROM department WHERE department_id = $1', [departmentId]); + if ( departmentRes.rowCount ) { + if ( label && departmentRes.rows[0].label !== label ) { + await client.query('UPDATE department SET label = $1 WHERE department_id = $2', [label, departmentId]); + } + } else { + await client.query('INSERT INTO department (department_id, label) VALUES ($1, $2)', [departmentId, label]); + } + } + + // upsert employee record + const kerberos = employee.kerberos; + const firstName = employee.firstName || ''; + const lastName = employee.lastName || ''; + const employeeRes = await client.query('SELECT * FROM employee WHERE kerberos = $1', [kerberos]); + if ( employeeRes.rowCount ) { + const existingEmployee = employeeRes.rows[0]; + if ( firstName && lastName && (existingEmployee.first_name !== firstName || existingEmployee.last_name !== lastName) ) { + await client.query('UPDATE employee SET first_name = $1, last_name = $2 WHERE kerberos = $3', [firstName, lastName, kerberos]); + } + } else { + await client.query('INSERT INTO employee (kerberos, first_name, last_name) VALUES ($1, $2, $3)', [kerberos, firstName, lastName]); + } + + // check department membership based on current date and department id + // update if necessary + if ( !departmentId ) return; + const now = new Date(); + const membershipRes = await client.query('SELECT * FROM employee_department WHERE employee_kerberos = $1 AND department_id = $2 AND start_date <= $3 AND (end_date IS NULL OR end_date >= $3)', [kerberos, departmentId, now]); + if ( membershipRes.rowCount ) return; + await client.query('INSERT INTO employee_department (employee_kerberos, department_id, start_date) VALUES ($1, $2, $3)', [kerberos, departmentId, now]); + await client.query('UPDATE employee_department SET end_date = $1 WHERE employee_kerberos = $2 AND department_id != $3 AND end_date IS NULL', [now, kerberos, departmentId]); + } + /** * @description Get an array of all UC PATH title codes that are primary appointment of library employees * @param {Boolean} skipCache - Will not use local db cache diff --git a/src/lib/db-models/employeeAllocation.js b/src/lib/db-models/employeeAllocation.js new file mode 100644 index 0000000..b190aa8 --- /dev/null +++ b/src/lib/db-models/employeeAllocation.js @@ -0,0 +1,105 @@ +import pg from "./pg.js"; +import EntityFields from "../utils/EntityFields.js"; +import employeeModel from "./employee.js"; + +class EmployeeAllocation { + + constructor(){ + + this.entityFields = new EntityFields([ + {dbName: 'employee_allocation_id', jsonName: 'employeeAllocationId', required: true}, + {dbName: 'employee', jsonName: 'employee'}, + {dbName: 'employees', jsonName: 'employees', customValidation: this.validateEmployeeList.bind(this)}, + {dbName: 'funding_source_id', jsonName: 'fundingSourceId'}, + {dbName: 'funding_source_label', jsonName: 'fundingSourceLabel'}, + { + dbName: 'start_date', + jsonName: 'startDate', + validateType: 'date', + required: true, + customValidation: this.validateDateRange.bind(this) + }, + { + dbName: 'end_date', + jsonName: 'endDate', + validateType: 'date', + required: true, + customValidation: this.validateDateRange.bind(this) + }, + {dbName: 'amount', jsonName: 'amount'}, + {dbName: 'added_by', jsonName: 'addedBy'}, + {dbName: 'added_at', jsonName: 'addedAt'}, + {dbName: 'deleted', jsonName: 'deleted'}, + {dbName: 'deleted_by', jsonName: 'deletedBy'}, + {dbName: 'deleted_at', jsonName: 'deletedAt'} + ]); + } + + async create(data){ + + data = this.entityFields.toDbObj(data); + const validation = this.entityFields.validate(data, ['employee_allocation_id']); + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + + let out = {}; + const client = await pg.pool.connect(); + + try { + await client.query('BEGIN'); + for (const employee of data.employees) { + await employeeModel.upsertInTransaction(client, employee); + } + await client.query('COMMIT') + }catch (e) { + await client.query('ROLLBACK'); + out.error = e; + } finally { + client.release(); + } + + return out; + } + + /** + * @description Validate that the employees field is an array of valid employee objects. + * See EntityFields.validate method for signature. + */ + validateEmployeeList(field, value, out) { + let error = {errorType: 'required', message: 'At least one employee is required'}; + if ( !Array.isArray(value) || value.length === 0 ) { + out.valid = false; + this.entityFields.pushError(out, field, error); + return; + } + error = {errorType: 'invalid', message: 'Invalid employee object'}; + for (const employee of value) { + if ( !employee || !employee.kerberos ) { + out.valid = false; + this.entityFields.pushError(out, field, error); + return; + } + } + } + + /** + * @description Validate that the start date is before the end date. + * See EntityFields.validate method for signature. + */ + validateDateRange(field, value, out, payload) { + if ( this.entityFields.fieldHasError(out, 'startDate') || this.entityFields.fieldHasError(out, 'endDate') ) return; + const startDate = new Date(payload.start_date); + const endDate = new Date(payload.end_date); + if ( startDate < endDate ) return; + out.valid = false; + const error = {errorType: 'invalid', message: 'Start date must be before end date'}; + if ( field.jsonName === 'endDate' ) { + error.message = 'End date must be after start date'; + } + this.entityFields.pushError(out, field, error); + } + +} + +export default new EmployeeAllocation(); diff --git a/src/lib/utils/EntityFields.js b/src/lib/utils/EntityFields.js index ab3023a..ff6e1d9 100644 --- a/src/lib/utils/EntityFields.js +++ b/src/lib/utils/EntityFields.js @@ -7,6 +7,7 @@ * - label {String} OPTIONAL - human readable label for the field * - required {Boolean} OPTIONAL - if the field is required * - charLimit {Number} OPTIONAL - maximum number of characters allowed + * - customValidation {Function} OPTIONAL - custom validation function */ export default class EntityFields { constructor(fields = []){ @@ -88,6 +89,14 @@ export default class EntityFields { this._validateType(field, value, out); } + for (const field of this.fields) { + if ( skipFields.includes(field[namingScheme]) ) continue; + const value = record[field[namingScheme]]; + if ( field.customValidation ) { + field.customValidation(field, value, out, record); + } + } + return out; } @@ -100,9 +109,9 @@ export default class EntityFields { _validateRequired(field, value, out) { if ( !field.required ) return; const error = {errorType: 'required', message: 'This field is required'}; - if (!value) { + if ( value === undefined || value === null || value === '') { out.valid = false; - this._pushError(out, field, error); + this.pushError(out, field, error); } } @@ -118,7 +127,7 @@ export default class EntityFields { const error = {errorType: 'charLimit', message: `This field must be less than ${field.charLimit} characters`}; if (value && value.length > field.charLimit) { out.valid = false; - this._pushError(out, field, error); + this.pushError(out, field, error); } } @@ -127,14 +136,30 @@ export default class EntityFields { */ _validateType(field, value, out) { if ( !field.validateType ) return; + if ( value === undefined || value === null ) return; + const error = {errorType: 'validateType', message: `This field must be of type: ${field.validateType}`}; if (field.validateType == 'integer' ) { value = value || value === '0' || value === 0 ? value : NaN; if ( !Number.isInteger(Number(value)) ) { out.valid = false; - this._pushError(out, field, error); + this.pushError(out, field, error); + } + } else if (field.validateType == 'date') { + // must be valid date in format YYYY-MM-DD + value = value.toString(); + if ( !value.match(/^\d{4}-\d{2}-\d{2}$/) ) { + out.valid = false; + this.pushError(out, field, error); + return; + } + const date = new Date(value); + if ( isNaN(date.getTime()) ) { + out.valid = false; + this.pushError(out, field, error); } } + } /** @@ -143,7 +168,7 @@ export default class EntityFields { * @param {Object} field - field object * @param {Object} error - error object with errorType and message properties */ - _pushError(out, field, error) { + pushError(out, field, error) { const fieldInOutput = out.fieldsWithErrors.find(f => f.jsonName === field.jsonName); if ( fieldInOutput ) { fieldInOutput.errors.push(error); @@ -151,4 +176,17 @@ export default class EntityFields { out.fieldsWithErrors.push({...field, errors: [error]}); } } + + /** + * @description Check if a field has an error in the output object + * @param {Object} out - output object from validate method + * @param {Object|String} field - field object or jsonName of field + * @returns {Boolean} + */ + fieldHasError(out, field) { + if ( typeof field === 'string' ) { + field = this.fields.find(f => f.jsonName === field); + } + return out.fieldsWithErrors.some(f => f.jsonName === field.jsonName); + } } From f69f0a18cb9630fa8507855759bb3f7c076cb0b1 Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Wed, 8 May 2024 14:39:31 -0700 Subject: [PATCH 041/274] add css --- .../ucdlib-employee-search-basic.js | 2 +- .../ucdlib-employee-search-basic.tpl.js | 77 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index a117945..510666b 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -36,7 +36,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.totalResults = 0; this.resultCtNotShown = 0; this.error = false; - this.labelText = 'Existing Employee Search'; + this.labelText = 'Search for a UC Davis Library Employee'; this.hideLabel = false; this.status = 'idle'; this.isSearching = false; diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js index a49a4ef..45baa23 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -5,6 +5,78 @@ export function styles() { :host { display: block; } + ucdlib-employee-search-basic { + max-width: 500px; + margin: auto; + display: block + } + + ucdlib-employee-search-basic .emp-search-top.field-container { + margin-bottom: 0 + } + + ucdlib-employee-search-basic .emp-search-bar { + position: relative + } + + ucdlib-employee-search-basic .emp-search-bar input { + flex-grow: 1; + padding-right: 2rem + } + + ucdlib-employee-search-basic .emp-search-bar .emp-search-icon { + position: absolute; + width: 2.5rem; + min-width: 2.5rem; + height: 2.5rem; + min-height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + right: 0; + top: 0; + color: #022851 + } + + ucdlib-employee-search-basic .emp-search-results-parent { + position: relative + } + + ucdlib-employee-search-basic .emp-search-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 100; + background-color: #fffbed; + border-right: 1px solid #ffbf00; + border-left: 1px solid #ffbf00; + border-bottom: 1px solid #ffbf00; + color: #13639e; + max-height: 250px; + overflow-y: scroll + } + + ucdlib-employee-search-basic .emp-search-results .emp-search-result { + padding: .5rem .75rem + } + + ucdlib-employee-search-basic .emp-search-results .emp-search-result:hover { + background-color: #fde9ac + } + + ucdlib-employee-search-basic .emp-search-results .highlight { + font-weight: 700 + } + + ucdlib-employee-search-basic .emp-search-results .muted { + font-weight: 400; + color: #545454 + } + + ucdlib-employee-search-basic .emp-search-results .emp-search-more { + padding: .5rem .75rem + } `; return [elementStyles]; @@ -22,10 +94,11 @@ return html` @focus=${() => this.isFocused = true} @blur=${() => this._onBlur()} .value=${this.selectedText ? this.selectedText : this.query} + placeholder=${this.labelText} type="text">
- +
@@ -34,7 +107,7 @@ return html`
- +
From f37d9bd3fa8c8b6a9bf3b187cbe65e827c6ea92e Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Wed, 8 May 2024 22:32:22 -0700 Subject: [PATCH 042/274] requested changes #23 #22 --- src/api/admin/approverType.js | 10 +- src/client/js/pages/app-page-home.js | 79 ++++- src/client/package.json | 1 - src/lib/cork/models/AdminApproverTypeModel.js | 29 +- .../cork/services/AdminApproverTypeService.js | 17 +- src/lib/cork/stores/AdminApproverTypeStore.js | 16 +- src/lib/db-models/approverType.js | 306 ++++++++++-------- 7 files changed, 292 insertions(+), 166 deletions(-) diff --git a/src/api/admin/approverType.js b/src/api/admin/approverType.js index edebfec..a126685 100644 --- a/src/api/admin/approverType.js +++ b/src/api/admin/approverType.js @@ -7,11 +7,11 @@ export default (api) => { * @description Query an approver-type */ api.get('/approver-type', protect('hasBasicAccess'), async (req, res) => { - const query = JSON.parse(req.query.data); + const query = req.query; - if ( query.length == 0) return res.status(400).json({error: true, message: 'Query is required.'}); + if (!query || Object.keys(query).length === 0 ) return res.status(400).json({error: true, message: 'Query is required.'}); - const data = await approverType.query(query[0]); + const data = await approverType.query(query); if ( data.error ) { console.error('Error in GET /approver-type', data.error); return res.status(500).json({error: true, message: 'Error getting approver-type.'}); @@ -29,7 +29,7 @@ export default (api) => { const data = await approverType.create(approverTypeData); if ( data.error ) { console.error('Error in POST /approver-type', data.error); - return res.status(500).json({error: true, message: 'Error creating approver-type.'}); + return res.status(400).json({error: true, message: 'Error creating approver-type.'}); } res.json({data: data, error: false}); }); @@ -44,7 +44,7 @@ export default (api) => { const data = await approverType.update(approverTypeData); if ( data.error ) { console.error('Error in PUT /approver-type', data.error); - return res.status(500).json({error: true, message: 'Error updating approver-type.'}); + return res.status(400).json({error: true, message: 'Error updating approver-type.'}); } res.json({data: data, error: false}); }); diff --git a/src/client/js/pages/app-page-home.js b/src/client/js/pages/app-page-home.js index 56b31e6..7012d1f 100644 --- a/src/client/js/pages/app-page-home.js +++ b/src/client/js/pages/app-page-home.js @@ -15,7 +15,8 @@ export default class AppPageHome extends Mixin(LitElement) super(); this.render = render.bind(this); - this._injectModel('AppStateModel'); + this._injectModel('AppStateModel', 'AdminApproverTypeModel'); + } @@ -26,7 +27,7 @@ export default class AppPageHome extends Mixin(LitElement) async _onAppStateUpdate(state) { if ( this.id !== state.page ) return; // this.AppStateModel.showLoading(); - + this.AppStateModel.setTitle('Home Page'); const breadcrumbs = [ @@ -37,6 +38,80 @@ export default class AppPageHome extends Mixin(LitElement) // const d = await this.getPageData(); // const hasError = d.some(e => e.state === 'error'); // if ( !hasError ) this.AppStateModel.showLoaded(this.id); + + // /* Query */ + // let data = [{id:[1, 2, 3], status:"active"}]; + // let data = [{id:2, status:"active"}, ]; + // let sample = await this.AdminApproverTypeModel.query(data); + // console.log(sample); + + /* Create */ + let data = { + "approverTypeId": 0, + "label": "bb88", + "description": "bb88", + "systemGenerated": false, + "hideFromFundAssignment": false, + "archived": false, + "employees": [] + }; + + + // let data = { + // "approverTypeId": 0, + // "label": "bb00", + // "description": "bb00", + // "systemGenerated": false, + // "hideFromFundAssignment": false, + // "archived": false, + // "employees":[ + // { + // "employee":{kerberos: "bbEmp003", first_name:"F", last_name:"G", department:null}, + // "approvalOrder": 71 + // }, + // { + // "employee":{kerberos: "bbEmp002", first_name:"G", last_name:"H", department:null}, + // "approvalOrder": 71 + // } + // ] + // }; + let sample = await this.AdminApproverTypeModel.create(data); + console.log(sample); + + + + // /* Update */ + // let data = { + // "approverTypeId": 151, + // "label": "bb99", + // "description": "bb99", + // "systemGenerated": false, + // "hideFromFundAssignment": false, + // "archived": false, + // "employees": [] + // }; + + // let data = { + // "approverTypeId": 151, + // "label": "bb22", + // "description": "bb22", + // "systemGenerated": false, + // "hideFromFundAssignment": false, + // "archived": false, + // "employees":[ + // { + // "employee":{kerberos: "emBB", first_name:"anotherR", last_name:"anotherS", department:null}, + // "approvalOrder": 72 + // }, + // { + // "employee":{kerberos: "emBB2", first_name:"anotherS", last_name:"anotherT", department:null}, + // "approvalOrder": 72 + // } + // ] + // }; + // let sample = await this.AdminApproverTypeModel.update(data); + // console.log(sample); + } /** diff --git a/src/client/package.json b/src/client/package.json index 9f66a21..66f858d 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -22,7 +22,6 @@ "keycloak-js": "^22.0.0", "lit": "^3.1.3", "normalize-scss": "^7.0.1", - "pg": "^8.11.5", "sass-burger": "^1.3.1" }, "devDependencies": { diff --git a/src/lib/cork/models/AdminApproverTypeModel.js b/src/lib/cork/models/AdminApproverTypeModel.js index bd4b9de..dec6a65 100644 --- a/src/lib/cork/models/AdminApproverTypeModel.js +++ b/src/lib/cork/models/AdminApproverTypeModel.js @@ -39,18 +39,6 @@ class AdminApproverTypeModel extends BaseModel { */ async create(data) { - // payload = Array.isArray(payload) ? payload : [payload]; - // let state = this.store.data.create; - - if(data.systemGenerated && data.archived) { - console.error("System Generated ApproverTypes can not be archived. Set Archive to false."); - return; - } - if(data.systemGenerated && data["employees"].length != 0) { - console.error("System Generated ApproverTypes can not have employees. Set employees to an empty array."); - return; - } - try { let state = this.store.data.create[data];; @@ -63,6 +51,14 @@ class AdminApproverTypeModel extends BaseModel { const out = this.store.data.create; + if ( !data ) { + this.store.data.update = {}; + } + if ( this.store.data.create[data] ) { + delete this.store.data.create[data]; + } + + return out; } @@ -83,10 +79,15 @@ class AdminApproverTypeModel extends BaseModel { const out = this.store.data.update; + if ( !data ) { + this.store.data.update = {}; + } + if ( this.store.data.update[data] ) { + delete this.store.data.update[data]; + } + return out; } - - } const model = new AdminApproverTypeModel(); diff --git a/src/lib/cork/services/AdminApproverTypeService.js b/src/lib/cork/services/AdminApproverTypeService.js index 88ac9ef..85c44a0 100644 --- a/src/lib/cork/services/AdminApproverTypeService.js +++ b/src/lib/cork/services/AdminApproverTypeService.js @@ -10,11 +10,11 @@ class AdminApproverTypeService extends BaseService { } query(data){ - let arrStr = encodeURIComponent(JSON.stringify(data)); + data = this.sort(data); return this.request({ - url : `/api/admin/approver-type?data=${arrStr}`, + url : `/api/admin/approver-type?id=${encodeURIComponent(JSON.stringify(data[0].id))}&status=${data[0].status}`, - checkCached: () => this.store.data.query[data], + checkCached: () => this.store.data.query[JSON.stringify(data)], onLoading : request => this.store.queryLoading(request, data), onLoad : result => this.store.queryLoaded(result.body, data), onError : e => this.store.queryError(e, data) @@ -29,7 +29,6 @@ class AdminApproverTypeService extends BaseService { body : data }, json: true, - checkCached: () => this.store.data.create[data], onLoading : request => this.store.createLoading(request, data), onLoad : result => this.store.createLoaded(result.body, data), onError : e => this.store.createError(e, data) @@ -44,7 +43,6 @@ class AdminApproverTypeService extends BaseService { body : data }, json: true, - checkCached: () => this.store.data.update[data], onLoading : request => this.store.updateLoading(request), onLoad : result => this.store.updateLoaded(result.body), onError : e => this.store.updateError(e) @@ -52,6 +50,15 @@ class AdminApproverTypeService extends BaseService { } + sort(data){ + let sortData = data.sort((a,b) => (a.id > b.id) ? 1 : ((b.id > a.id) ? -1 : 0)); + for(var i = 0; i < sortData.length; i++) { + if (Array.isArray(sortData[i].id)) { + sortData[i].id; + } + } + return sortData; + } } const service = new AdminApproverTypeService(); diff --git a/src/lib/cork/stores/AdminApproverTypeStore.js b/src/lib/cork/stores/AdminApproverTypeStore.js index 52cb18e..55387f1 100644 --- a/src/lib/cork/stores/AdminApproverTypeStore.js +++ b/src/lib/cork/stores/AdminApproverTypeStore.js @@ -11,9 +11,9 @@ class AdminApproverTypeStore extends BaseStore { update: {} }; this.events = { - APPROVERTYPE_QUERIED: 'approverType-queried', - APPROVERTYPE_CREATED: 'approverType-created', - APPROVERTYPE_UPDATED: 'approverType-updated' + APPROVER_TYPE_QUERIED: 'approverType-queried', + APPROVER_TYPE_CREATED: 'approverType-created', + APPROVER_TYPE_UPDATED: 'approverType-updated' }; } @@ -40,11 +40,9 @@ class AdminApproverTypeStore extends BaseStore { _setQueryState(state, data) { this.data.query[data] = state; - this.emit(this.events.APPROVERTYPE_QUERIED, state); + this.emit(this.events.APPROVER_TYPE_QUERIED, state); } - -///YOU ARE HERE createLoading(request, data) { this._setCreateState({ state : this.STATE.LOADING, @@ -68,7 +66,7 @@ class AdminApproverTypeStore extends BaseStore { _setCreateState(state, data) { this.data.create[data] = state; - this.emit(this.events.APPROVERTYPE_CREATED, state); + this.emit(this.events.APPROVER_TYPE_CREATED, state); } updateLoading(request) { @@ -89,12 +87,12 @@ class AdminApproverTypeStore extends BaseStore { this._setUpdateState({ state : this.STATE.ERROR, error - }); + }, data); } _setUpdateState(state) { this.data.update = state; - this.emit(this.events.APPROVERTYPE_UPDATED, state); + this.emit(this.events.APPROVER_TYPE_UPDATED, state); } } diff --git a/src/lib/db-models/approverType.js b/src/lib/db-models/approverType.js index 548b379..96b56df 100644 --- a/src/lib/db-models/approverType.js +++ b/src/lib/db-models/approverType.js @@ -9,41 +9,55 @@ class AdminApproverType { constructor(){ this.entityFields = new EntityFields([ - {dbName: 'approver_type_id', jsonName: 'approverTypeId'}, - {dbName: 'label', jsonName: 'label'}, - {dbName: 'description', jsonName: 'description', userEditable: true}, + {dbName: 'approver_type_id', jsonName: 'approverTypeId', required: true, userEditable: true}, + {dbName: 'label', jsonName: 'label', required: true, userEditable: true}, + {dbName: 'description', jsonName: 'description'}, {dbName: 'system_generated', jsonName: 'systemGenerated'}, {dbName: 'hide_from_fund_assignment', jsonName: 'hideFromFundAssignment'}, - {dbName: 'archived', jsonName: 'archived', userEditable: true}, + {dbName: 'archived', jsonName: 'archived'}, + {dbName: 'employees', jsonName: 'archived'}, + + ]); + this.entityEmployeeFields = new EntityFields([ + {dbName: 'approver_type_id', jsonName: 'approverTypeId', required: true}, + {dbName: 'employee_kerberos', jsonName: 'employees', required: true}, + {dbName: 'approval_order', jsonName: 'approvalOrder'}, + ]); } /** * @description Query the approver type - * @param {Object} args - object of ids, archive, active + * @param {Object} kwargs - optional arguments including: + * IDs: integer|array - Ids of approver types + * status = active: string - if status is "active", return only active (non-archived) approver Types + * status = archived: string - if status is "archived", return only archived (non-active) approver Types * @returns {Object|Array} * * all props in approver_type camelcased * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee */ - async query(args){ - let res; - let archive = "false" - - if(args["status"] == "archived") archive = "true"; - - if(Array.isArray(args["id"])) { - let v = pg.valuesArray(args.id); - - let text = `SELECT * FROM approver_type WHERE approver_type_id in ${v} AND archived IS ` + archive; + async query(kwargs){ + let res, id; + let idArray = JSON.parse(kwargs.id); + let query = `SELECT * FROM approver_type WHERE`; - res = await pg.query(text, args.id); + if(Array.isArray(idArray)) { + id = idArray; } else { - let text = `SELECT * FROM approver_type WHERE approver_type_id = $1 AND archived IS ` + archive; + id = [idArray]; + } + let v = pg.valuesArray(id); - res = await pg.query(text, [args.id]); + query += ` approver_type_id in ${v}`; - } + if(kwargs["status"] == "archived") { + query += ` AND archived = true`; + } else if (kwargs["status"] == "active"){ + query += ` AND archived = false`; + } + + res = await pg.query(query, id); if( res.error ) return res; const data = this.entityFields.toJsonArray(res.res.rows); @@ -53,76 +67,133 @@ class AdminApproverType { } + /** - * @description Converts camelCase to underscores (snakecase) for column names + * @description Check if Employee Exists in employee table and insert if not + * @param {Object} employee - Employee Object */ - underscore(s){ - return s.split(/\.?(?=[A-Z])/).join('_').toLowerCase(); + async existsEmployee(employee){ + const client = await pg.pool.connect(); + + let employeeExists = await pg.query(`SELECT * FROM employee WHERE kerberos = ($1)`, [employee.kerberos]); + + let employeeObj = { + "kerberos":employee.kerberos, + "first_name": employee.first_name, + "last_name":employee.last_name, + "department_id":employee.department + }; + + if(!employeeExists.res.rows.length) { + let data = pg.prepareObjectForInsert(employeeObj); + return await client.query(`INSERT INTO employee (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING *`, data.values); + } else { + const updateEmployeeClause = pg.toUpdateClause(employeeObj); + return await client.query(`UPDATE employee SET ${updateEmployeeClause.sql} + WHERE kerberos = $${updateEmployeeClause.values.length + 1} + RETURNING *`, [...updateEmployeeClause.values, employee.kerberos]);; + } + } + /** - * @description Create the admin approver type table - * @param {Object} data - admin approverType object including list of employees - * - * - * { - "label": "label", - "description": "descripton", - "systemGenerated": false, - "hideFromFundAssignment": false, - "archived": false, - "employees":{ - "employeeKerberos":"kerberos", - "approvalOrder": 3 + * @description Check if Employee Exists in the approver_type table and delete to replace + * @param {Number} id - ID Employee + + */ + async approverEmployeeCheck(id){ + const client = await pg.pool.connect(); + let employeeExists = await client.query(`SELECT * FROM approver_type_employee WHERE approver_type_id = ($1)`, [id]); + if(employeeExists.rowCount){ + return await pg.query(`DELETE FROM approver_type_employee WHERE approver_type_id = ($1)`, [id]); } - } + return false + } + + /** + * @description Use data for system validation + * @param {Object} data - Object of Entity fields with camelcase * + * @returns {Object} {status: false, message:""} + */ + async systemValidation(data){ + if(data.systemGenerated && data.archived) { + return {status:false, message: "System Generated ApproverTypes can not be archived. Set Archive to false."}; + } + if(data.systemGenerated && data["employees"].length != 0) { + return {status:false, message: "System Generated ApproverTypes can not have employees. Set employees to an empty array."}; + } + return {status:true, message:""} + } + + /** + * @description Create the admin approver type table + * @param {Object} data - Object of Entity fields with camelcase * * @returns {Object} {error: false} */ async create(data){ - let text = 'INSERT INTO approver_type ('; - let props = [ - 'label', 'description', 'systemGenerated', 'hideFromFundAssignment', - 'archived' - ]; - let approverEmployee = data.employees; - const values = []; - let first = true; - for (const prop of props) { - if ( data.hasOwnProperty(prop) ){ - if ( first ) { - text += this.underscore(prop); - first = false; - } else { - text += `, ${this.underscore(prop)}`; - } - values.push(data[prop]); - } - } - text += `) VALUES ${pg.valuesArray(values)} RETURNING *`; - if ( Object.keys(approverEmployee).length === 0 ) return await pg.query(text, values); + let systemValidate = await this.systemValidation(data); + if(!systemValidate.status) return {error: true, message: systemValidate.message, is400: true}; const client = await pg.pool.connect(); const out = {res: [], err: false}; + + await client.query('BEGIN'); + + let approverEmployee = data.employees; + + data = this.entityFields.toDbObj(data); + const validation = this.entityFields.validate(data, ['approver_type_id']); + + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + + delete data.approver_type_id; + if(!data.employees) delete data.employees; + + data = pg.prepareObjectForInsert(data); + const sql = `INSERT INTO approver_type (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING *`; + + if ( Object.keys(approverEmployee).length === 0 ) { + const res = await pg.query(sql, data.values); + if( res.error ) return res; + return this.entityFields.toJsonObj(res.res.rows); + } + try{ - await client.query('BEGIN'); - const approverType = await client.query(text, values); + const approverType = await client.query(sql, data.values); out.res.push(approverType); const approverTypeId = approverType.rows[0].approver_type_id; for (const a of approverEmployee) { - const approverText = ` - INSERT INTO approver_type_employee (approver_type_id, employee_kerberos, approval_order) - VALUES ($1, $2, $3) - RETURNING * - `; - const approverParams = [approverTypeId, a.employeeKerberos, a.approvalOrder]; - const r = await client.query(approverText, approverParams); + const newEmployee = await this.existsEmployee(a.employee); + + const toEmployeeCreate = {}; + if ( approverTypeId ) { + toEmployeeCreate['approver_type_id'] = approverTypeId; + } + if ( a.employee.kerberos ) { + toEmployeeCreate['employee_kerberos'] = a.employee.kerberos; + } + if ( a.approvalOrder ){ + toEmployeeCreate['approval_order'] = a.approvalOrder; + } + if ( !Object.keys(toEmployeeCreate).length ){ + return pg.returnError('no valid fields to update'); + } + + let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeCreate); + const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholdersString}) RETURNING *`; + + const r = await client.query(employeeSql, approverEmployeeData.values); + out.res.push(r); } await client.query('COMMIT'); @@ -140,82 +211,63 @@ class AdminApproverType { /** * @description Update the admin approver type table - * @param {Object} data - approverType object including list of employees + * @param {Object} data - Object of Entity fields with camelcase * use a transaction if changes are needed to the employee list * - * - * { - "label": "label", - "description": "descripton", - "systemGenerated": false, - "hideFromFundAssignment": false, - "archived": false, - "employees":{ - "employee_kerberos":"kerberos", - "approval_order": 3 - } - } - * - * * @returns {Object} {error: false} */ async update(data){ + let systemValidate = await this.systemValidation(data); + if(!systemValidate.status) return {error: true, message: systemValidate.message, is400: true}; - let id = data.approver_type_id; + const client = await pg.pool.connect(); + const out = {res: [], err: false}; - if ( data && Array.isArray(data) ) return pg.returnError('This takes only an ApproverType Object'); - if ( !data || Object.keys(data).length === 0 ) return pg.returnError('No data provided'); + await client.query('BEGIN'); + let approverEmployee = data.employees; - if ( !id ) { - return pg.returnError('id is required when updating approverType'); - } + data = this.entityFields.toDbObj(data); - let approverEmployee = data.employees; + const validation = this.entityFields.validate(data); + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + const id = data.approver_type_id; - const toUpdate = {}; - if ( data.label ) { - toUpdate['label'] = data.label; - } - if ( data.description ) { - toUpdate['description'] = data.description; - } - if ( data.systemGenerated ){ - toUpdate['system_generated'] = data.systemGenerated; - } - if ( data.hideFromFundAssignment ){ - toUpdate['hide_from_fund_assignment'] = data.hideFromFundAssignment; - } - if ( data.archived ){ - toUpdate['archived'] = data.archived; - } - if ( !Object.keys(toUpdate).length ){ - return pg.returnError('no valid fields to update'); - } + if(!data.employees) delete data.employees; - const updateClause = pg.toUpdateClause(toUpdate); - const text = ` - UPDATE approver_type SET ${updateClause.sql} - WHERE approver_type_id = $${updateClause.values.length + 1} - RETURNING * + const updateClause = pg.toUpdateClause(data); + const sql = ` + UPDATE approver_type + SET ${updateClause.sql} + WHERE approver_type_id = $${updateClause.values.length + 1} + RETURNING * `; - if ( Object.keys(approverEmployee).length === 0 ) return await pg.query(text, [...updateClause.values, id]); + if ( Object.keys(approverEmployee).length === 0 ) { + let currentEmployees = this.approverEmployeeCheck(id); - const client = await pg.pool.connect(); - const out = {res: [], err: false}; - try{ - await client.query('BEGIN'); - const approverType = await client.query(text, [...updateClause.values, id]); + const res = await pg.query(sql, [...updateClause.values, id]); + if( res.error ) return res; + return this.entityFields.toJsonObj(res.res.rows); + } + try{ + const approverType = await client.query(sql, [...updateClause.values, id]); out.res.push(approverType); const approverTypeId = approverType.rows[0].approver_type_id; + let employeeApproverCheck = await this.approverEmployeeCheck(approverTypeId); + for (const a of approverEmployee) { + + const newEmployee = await this.existsEmployee(a.employee); + const toEmployeeUpdate = {}; if ( approverTypeId ) { toEmployeeUpdate['approver_type_id'] = approverTypeId; } - if ( a.employeeKerberos ) { - toEmployeeUpdate['employee_kerberos'] = a.employeeKerberos; + if ( a.employee.kerberos ) { + toEmployeeUpdate['employee_kerberos'] = a.employee.kerberos; } if ( a.approvalOrder ){ toEmployeeUpdate['approval_order'] = a.approvalOrder; @@ -224,17 +276,11 @@ class AdminApproverType { return pg.returnError('no valid fields to update'); } - - const updateEmployeeClause = pg.toUpdateClause(toEmployeeUpdate); - - const approverEmployeeText = ` - UPDATE approver_type_employee SET ${updateEmployeeClause.sql} - WHERE approver_type_id = $${updateEmployeeClause.values.length + 1} - RETURNING * - `; - - const r = await client.query(approverEmployeeText, [...updateEmployeeClause.values, id]); + let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeUpdate); + const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholdersString}) RETURNING *`; + const r = await client.query(employeeSql, approverEmployeeData.values); out.res.push(r); + } await client.query('COMMIT'); } catch (error) { From 41b7aebebda0963c7548e9ddea413fa249581415 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 9 May 2024 09:20:14 -0400 Subject: [PATCH 043/274] bump kcjs version --- src/client/package-lock.json | 55 +++++++++++++++++++++++------------- src/client/package.json | 2 +- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/client/package-lock.json b/src/client/package-lock.json index 4520e90..ea552f5 100644 --- a/src/client/package-lock.json +++ b/src/client/package-lock.json @@ -15,7 +15,7 @@ "@ucd-lib/theme-elements": "^2.1.0", "@ucd-lib/theme-sass": "^6.0.1", "breakpoint-sass": "^3.0.0", - "keycloak-js": "^22.0.0", + "keycloak-js": "^24.0.4", "lit": "^3.1.3", "normalize-scss": "^7.0.1", "sass-burger": "^1.3.1" @@ -3911,6 +3911,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -3924,7 +3925,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "peer": true }, "node_modules/big.js": { "version": "5.2.2", @@ -7114,9 +7116,9 @@ "dev": true }, "node_modules/js-sha256": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", - "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", + "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==" }, "node_modules/js-tokens": { "version": "4.0.0", @@ -7213,13 +7215,21 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/keycloak-js": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-22.0.0.tgz", - "integrity": "sha512-4/xJm6aswS/RLD3h3OhfZTrHAH6Ku/oDvBbK8/tgTxg0rywbxKUjCRUG2Zkw7UGwJPsrqhVT/nmsGTQqj3sQAw==", + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-24.0.4.tgz", + "integrity": "sha512-eLjG7CzGGgAXh78M76QUJy1R8+QDQvIJXJf6T3bq9VJZ6RXBGZXMsXvII66b83ueYDFa1gi2JojmA31Z23HO0g==", "dependencies": { - "base64-js": "^1.5.1", - "js-sha256": "^0.9.0" + "js-sha256": "^0.11.0", + "jwt-decode": "^4.0.0" } }, "node_modules/kind-of": { @@ -17053,7 +17063,9 @@ "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "peer": true }, "big.js": { "version": "5.2.2", @@ -19615,9 +19627,9 @@ "dev": true }, "js-sha256": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", - "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", + "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==" }, "js-tokens": { "version": "4.0.0", @@ -19694,13 +19706,18 @@ "universalify": "^2.0.0" } }, + "jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==" + }, "keycloak-js": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-22.0.0.tgz", - "integrity": "sha512-4/xJm6aswS/RLD3h3OhfZTrHAH6Ku/oDvBbK8/tgTxg0rywbxKUjCRUG2Zkw7UGwJPsrqhVT/nmsGTQqj3sQAw==", + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-24.0.4.tgz", + "integrity": "sha512-eLjG7CzGGgAXh78M76QUJy1R8+QDQvIJXJf6T3bq9VJZ6RXBGZXMsXvII66b83ueYDFa1gi2JojmA31Z23HO0g==", "requires": { - "base64-js": "^1.5.1", - "js-sha256": "^0.9.0" + "js-sha256": "^0.11.0", + "jwt-decode": "^4.0.0" } }, "kind-of": { diff --git a/src/client/package.json b/src/client/package.json index 66f858d..e4f91f8 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -19,7 +19,7 @@ "@ucd-lib/theme-elements": "^2.1.0", "@ucd-lib/theme-sass": "^6.0.1", "breakpoint-sass": "^3.0.0", - "keycloak-js": "^22.0.0", + "keycloak-js": "^24.0.4", "lit": "^3.1.3", "normalize-scss": "^7.0.1", "sass-burger": "^1.3.1" From 567f8eecbc235489a780c224765694ce0f6226ea Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 9 May 2024 12:59:14 -0400 Subject: [PATCH 044/274] #36 --- src/api/admin/employee-allocation.js | 2 +- src/client/js/components/app-toast.js | 21 +-- .../admin/app-page-admin-allocations-new.js | 55 ++++++- .../app-page-admin-allocations-new.tpl.js | 25 +++- src/client/js/utils/ValidationHandler.js | 10 ++ src/client/scss/global.scss | 7 + src/client/scss/pages/admin/allocations.scss | 4 + src/lib/db-models/employeeAllocation.js | 134 ++++++++++++++++-- src/lib/utils/AccessToken.js | 13 ++ src/lib/utils/EntityFields.js | 5 + 10 files changed, 241 insertions(+), 35 deletions(-) diff --git a/src/api/admin/employee-allocation.js b/src/api/admin/employee-allocation.js index a489b3a..6b149d7 100644 --- a/src/api/admin/employee-allocation.js +++ b/src/api/admin/employee-allocation.js @@ -5,7 +5,7 @@ export default (api) => { api.post('/employee-allocation', protect('hasAdminAccess'), async (req, res) => { const payload = (typeof req.body === 'object') && !Array.isArray(req.body) ? req.body : {}; - const data = await employeeAllocation.create(payload); + const data = await employeeAllocation.create(payload, req.auth.token.employeeObject); if ( data.error && data.is400 ) { return res.status(400).json(data); } diff --git a/src/client/js/components/app-toast.js b/src/client/js/components/app-toast.js index 758f4e9..d2f90fa 100644 --- a/src/client/js/components/app-toast.js +++ b/src/client/js/components/app-toast.js @@ -4,8 +4,8 @@ import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; /** - * @description Component for handling sitewide toast - * + * @description Component for handling sitewide toast + * * item: the object that holds the display text and status * text: the text that is displayed * type: the status of the toast message @@ -14,8 +14,8 @@ import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; * hidden: the modal is hidden * animation: queues up the animation * time the amount of time the modal stays up - * - + * + this.AppStateModel.showToast(object); */ export default class AppToast extends Mixin(LitElement) @@ -60,22 +60,22 @@ export default class AppToast extends Mixin(LitElement) * @param {Object} options */ _onToastUpdate(items){ - this.hidden = false; this.queue.push(items) this.queueAmount = this.queue.length; for (let i in this.queue){ - + setTimeout(() => { this.animation = true; let item = this.queue.shift(); this.item = Object.assign({}, this.item, item); if ( !this.item.message ) return; - + this.text = this.item.message; this.type = this.item.type || 'information'; - + this.hidden = false; + if(this.type == "success") this.icon = html`✓`; else if(this.type == "error") this.icon = html`✕`; @@ -83,6 +83,7 @@ export default class AppToast extends Mixin(LitElement) if(this.queueAmount == 0) { setTimeout(() => { this.animation = false; + this.hidden = true; }, this.time ); } }, this.time * i ); @@ -109,10 +110,10 @@ export default class AppToast extends Mixin(LitElement) toast.style.display = "none"; console.log(message.message); - + this.requestUpdate(); } } -customElements.define('app-toast', AppToast); \ No newline at end of file +customElements.define('app-toast', AppToast); diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.js b/src/client/js/pages/admin/app-page-admin-allocations-new.js index 12dc717..246923d 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.js @@ -5,7 +5,18 @@ import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-el import { WaitController } from "@ucd-lib/theme-elements/utils/controllers/wait.js"; import { createRef } from 'lit/directives/ref.js'; import IamEmployeeObjectAccessor from '../../../../lib/utils/iamEmployeeObjectAccessor.js'; - +import ValidationHandler from "../../utils/ValidationHandler.js"; + +/** + * @class AppPageAdminAllocationsNew + * @description Page component for adding new employee allocations + * @property {Array} employees - list of employees to allocate funding to + * @property {String} startDate - start date for the allocation + * @property {String} endDate - end date for the allocation + * @property {Number} fundingAmount - amount of funding to allocate + * @property {Array} fundingSources - list of active funding sources from FundingSourceModel + * @property {Object} selectedFundingSource - selected funding source object from fundingSources list + */ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -41,6 +52,8 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) this.endDate = ''; this.fundingAmount = 0; this.selectedFundingSource = {}; + this.validationHandler = new ValidationHandler(); + this.requestUpdate(); } /** @@ -103,11 +116,22 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) return this.employees.find(e => e.kerberos === employee.kerberos); } + /** + * @description Check if an employee already has allocation from the validationHandler + * @param {Object} employee - basic employee object from employees list + * @returns {Boolean} + */ + employeeAlreadyHasAllocation(employee) { + const employeeErrors = this.validationHandler.getError('employees', 'already-exists'); + if ( !employeeErrors ) return false; + return employeeErrors?.employees.find(e => e.kerberos === employee.kerberos) ? true : false; + } + /** * @description Event handler for form submission * @param {Event} e - Submit event */ - async _onFormSubmit(e) { + _onFormSubmit(e) { e.preventDefault(); const payload = { startDate: this.startDate, @@ -116,9 +140,32 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) amount: this.fundingAmount, employees: this.employees }; - console.log('submit', payload); - await this.EmployeeAllocationModel.createEmployeeAllocations(payload); + this.EmployeeAllocationModel.createEmployeeAllocations(payload); + } + /** + * @description Event handler for when employee allocations are created. + * Callback for employee-allocations-created event in EmployeeAllocationModel + * @param {Object} e - cork-app-utils event object + */ + _onEmployeeAllocationsCreated(e){ + if ( e.state === 'error' ) { + if ( e.error?.payload?.is400 ) { + this.validationHandler = new ValidationHandler(e); + this.requestUpdate(); + this.AppStateModel.showToast({message: 'Error when creating the employee allocations. Form data needs fixing.', type: 'error'}); + } else { + this.AppStateModel.showToast({message: 'An unknown error occurred when creating the employee allocations', type: 'error'}); + } + this.AppStateModel.showLoaded(this.id); + } else if ( e.state === 'loading') { + this.AppStateModel.showLoading(); + } else if ( e.state === 'loaded' ) { + this.AppStateModel.showLoaded(this.id); + this.AppStateModel.setLocation(this.AppStateModel.store.breadcrumbs['admin-allocations'].link); + this.AppStateModel.showToast({message: 'Employee allocations created successfully', type: 'success'}); + this.resetForm(); + } } /** diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js index 4b97232..b9547e8 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.tpl.js @@ -45,31 +45,39 @@ export function render() { Allocation
-
+
this._onFormInput('startDate', e.target.value)} > +
${this.validationHandler.renderErrorMessages('startDate')}
-
+
this._onFormInput('endDate', e.target.value)} > +
${this.validationHandler.renderErrorMessages('endDate')}
-
+
- this._onFundingSourceSelect(e.target.value)} + .value=${this.selectedFundingSource?.fundingSourceId || ''} + > ${this.fundingSources.map(fundingSource => html`
-
+
this._onFormInput('fundingAmount', e.target.value)} >
+
${this.validationHandler.renderErrorMessages('amount')}
-
+
Employees +
${this.validationHandler.renderErrorMessages('employees')}
0}>

No employees selected. Use search form to add employees to this list.

@@ -109,7 +120,7 @@ export function render() {
Department
${this.employees.map(employee => html` -
+
e.errorType === errorType); + } + } diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 127ddff..c4bf9ce 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -36,6 +36,13 @@ color: #c10230; } } +fieldset.field-error { + border-top: 3px solid #c10230; + + > legend { + color: #c10230; + } +} .field-error-message { display: none; } diff --git a/src/client/scss/pages/admin/allocations.scss b/src/client/scss/pages/admin/allocations.scss index 8fcc7d3..002d8d7 100644 --- a/src/client/scss/pages/admin/allocations.scss +++ b/src/client/scss/pages/admin/allocations.scss @@ -20,6 +20,10 @@ app-page-admin-allocations-new { .employee-list--item { padding: .5rem; + &.already-exists { + background-color: #f8d7da !important; + } + .name { font-weight: 700; } diff --git a/src/lib/db-models/employeeAllocation.js b/src/lib/db-models/employeeAllocation.js index b190aa8..51ac100 100644 --- a/src/lib/db-models/employeeAllocation.js +++ b/src/lib/db-models/employeeAllocation.js @@ -7,11 +7,34 @@ class EmployeeAllocation { constructor(){ this.entityFields = new EntityFields([ - {dbName: 'employee_allocation_id', jsonName: 'employeeAllocationId', required: true}, - {dbName: 'employee', jsonName: 'employee'}, - {dbName: 'employees', jsonName: 'employees', customValidation: this.validateEmployeeList.bind(this)}, - {dbName: 'funding_source_id', jsonName: 'fundingSourceId'}, - {dbName: 'funding_source_label', jsonName: 'fundingSourceLabel'}, + { + dbName: 'employee_allocation_id', + jsonName: 'employeeAllocationId', + required: true + }, + { + dbName: 'employee', + jsonName: 'employee' + }, + { + dbName: 'employees', + jsonName: 'employees', + customValidation: this.validateEmployeeList.bind(this) + }, + { + dbName: 'employee_kerberos', + jsonName: 'employeeKerberos' + }, + { + dbName: 'funding_source_id', + jsonName: 'fundingSourceId', + required: true, + validateType: 'integer' + }, + { + dbName: 'funding_source_label', + jsonName: 'fundingSourceLabel' + }, { dbName: 'start_date', jsonName: 'startDate', @@ -26,35 +49,97 @@ class EmployeeAllocation { required: true, customValidation: this.validateDateRange.bind(this) }, - {dbName: 'amount', jsonName: 'amount'}, - {dbName: 'added_by', jsonName: 'addedBy'}, - {dbName: 'added_at', jsonName: 'addedAt'}, - {dbName: 'deleted', jsonName: 'deleted'}, - {dbName: 'deleted_by', jsonName: 'deletedBy'}, - {dbName: 'deleted_at', jsonName: 'deletedAt'} + { + dbName: 'amount', + jsonName: 'amount', + required: true, + validateType: 'number' + }, + { + dbName: 'added_by', + jsonName: 'addedBy' + }, + { + dbName: 'added_at', + jsonName: 'addedAt' + }, + { + dbName: 'deleted', + jsonName: 'deleted' + }, + { + dbName: 'deleted_by', + jsonName: 'deletedBy' + }, + { + dbName: 'deleted_at', + jsonName: 'deletedAt' + } ]); } - async create(data){ + async create(data, submittedBy={}){ data = this.entityFields.toDbObj(data); const validation = this.entityFields.validate(data, ['employee_allocation_id']); if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; } + delete data.employee_allocation_id; + delete data.employee; + + // check if an allocation already exists for this date range, funding source, and employees + // wont break the db if a duplicate is inserted, but it's not a user pattern we want to happen + const existingAllocations = await this.allocationExists(data.start_date, data.end_date, data.funding_source_id, data.employees.map(e => e.kerberos)); + if ( existingAllocations.error ) return existingAllocations; + if ( Object.values(existingAllocations.res).some(v => v) ) { + const field = {...this.entityFields.fields.find(f => f.jsonName === 'employees')}; + const employees = data.employees.filter(e => existingAllocations.res[e.kerberos]); + const message = 'An allocation already exists for this date range and funding source for the following employees:'; + field.errors = [{errorType: 'already-exists', message, employees}]; + return { + error: true, + message: 'Validation Error', + is400: true, + fieldsWithErrors: [field] + } + } let out = {}; const client = await pg.pool.connect(); try { await client.query('BEGIN'); + + // make sure all employees are in the database for (const employee of data.employees) { await employeeModel.upsertInTransaction(client, employee); } + + // add who submitted the allocation + if ( submittedBy.kerberos ) { + data.added_by = submittedBy.kerberos; + await employeeModel.upsertInTransaction(client, submittedBy); + } + + // insert allocation for each employee + for (const employee of data.employees) { + employee.added_at = new Date(); + let d = {...data, 'employee_kerberos': employee.kerberos }; + delete d.employees; + d = pg.prepareObjectForInsert(d); + const sql = `INSERT INTO employee_allocation (${d.keysString}) VALUES (${d.placeholdersString}) RETURNING employee_allocation_id`; + await client.query(sql, d.values); + + out = []; + // todo retrieve the full inserted record when method is built + } + await client.query('COMMIT') + }catch (e) { await client.query('ROLLBACK'); - out.error = e; + out = {error: e}; } finally { client.release(); } @@ -62,6 +147,29 @@ class EmployeeAllocation { return out; } + /** + * @description Check if an allocation exists for a given date range, funding source for a list of employees + * @param {String} startDate - start date in format YYYY-MM-DD + * @param {String} endDate - end date in format YYYY-MM-DD + * @param {Number} fundingSourceId - funding source id + * @param {Array} kerberosIds - array of kerberos ids + * @returns {Object} {error, res} where res is an object with kerberos ids as keys and boolean values indicating if an allocation exists + */ + async allocationExists(startDate, endDate, fundingSourceId, kerberosIds ) { + const sql = ` + SELECT employee_kerberos, true as allocation_exists + FROM employee_allocation + WHERE start_date = $1 AND end_date = $2 AND funding_source_id = $3 AND employee_kerberos = ANY($4) + `; + const res = await pg.query(sql, [startDate, endDate, fundingSourceId, kerberosIds]); + if ( res.error ) return res; + const out = {}; + for (const row of res.res.rows) { + out[row.employee_kerberos] = row.allocation_exists; + } + return {res: out}; + } + /** * @description Validate that the employees field is an array of valid employee objects. * See EntityFields.validate method for signature. diff --git a/src/lib/utils/AccessToken.js b/src/lib/utils/AccessToken.js index 2e9aead..afa6fba 100644 --- a/src/lib/utils/AccessToken.js +++ b/src/lib/utils/AccessToken.js @@ -67,6 +67,19 @@ export default class AccessToken { return []; } + /** + * @description Returns employee object for logged in user + * Employee object is used to track user actions in the system + * and is the expected format for upsertInTransaction method in the employee model + */ + get employeeObject(){ + return { + kerberos: this.id, + firstName: this.token.given_name || '', + lastName: this.token.family_name || '', + } + } + /** * @description Returns username (kerberos) of logged in user */ diff --git a/src/lib/utils/EntityFields.js b/src/lib/utils/EntityFields.js index ff6e1d9..3d00231 100644 --- a/src/lib/utils/EntityFields.js +++ b/src/lib/utils/EntityFields.js @@ -158,6 +158,11 @@ export default class EntityFields { out.valid = false; this.pushError(out, field, error); } + } else if (field.validateType == 'number') { + if ( isNaN(Number(value)) ) { + out.valid = false; + this.pushError(out, field, error); + } } } From f8ccc53119340137df55721455110a4c9acdfa5c Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Thu, 9 May 2024 11:25:47 -0700 Subject: [PATCH 045/274] did not see your employee method additon and previously made my own; new functionality is now added --- src/client/js/pages/app-page-home.js | 75 +------------------ src/lib/cork/models/AdminApproverTypeModel.js | 7 -- src/lib/db-models/approverType.js | 41 ++-------- 3 files changed, 6 insertions(+), 117 deletions(-) diff --git a/src/client/js/pages/app-page-home.js b/src/client/js/pages/app-page-home.js index 7012d1f..9529462 100644 --- a/src/client/js/pages/app-page-home.js +++ b/src/client/js/pages/app-page-home.js @@ -15,7 +15,7 @@ export default class AppPageHome extends Mixin(LitElement) super(); this.render = render.bind(this); - this._injectModel('AppStateModel', 'AdminApproverTypeModel'); + this._injectModel('AppStateModel'); } @@ -39,79 +39,6 @@ export default class AppPageHome extends Mixin(LitElement) // const hasError = d.some(e => e.state === 'error'); // if ( !hasError ) this.AppStateModel.showLoaded(this.id); - // /* Query */ - // let data = [{id:[1, 2, 3], status:"active"}]; - // let data = [{id:2, status:"active"}, ]; - // let sample = await this.AdminApproverTypeModel.query(data); - // console.log(sample); - - /* Create */ - let data = { - "approverTypeId": 0, - "label": "bb88", - "description": "bb88", - "systemGenerated": false, - "hideFromFundAssignment": false, - "archived": false, - "employees": [] - }; - - - // let data = { - // "approverTypeId": 0, - // "label": "bb00", - // "description": "bb00", - // "systemGenerated": false, - // "hideFromFundAssignment": false, - // "archived": false, - // "employees":[ - // { - // "employee":{kerberos: "bbEmp003", first_name:"F", last_name:"G", department:null}, - // "approvalOrder": 71 - // }, - // { - // "employee":{kerberos: "bbEmp002", first_name:"G", last_name:"H", department:null}, - // "approvalOrder": 71 - // } - // ] - // }; - let sample = await this.AdminApproverTypeModel.create(data); - console.log(sample); - - - - // /* Update */ - // let data = { - // "approverTypeId": 151, - // "label": "bb99", - // "description": "bb99", - // "systemGenerated": false, - // "hideFromFundAssignment": false, - // "archived": false, - // "employees": [] - // }; - - // let data = { - // "approverTypeId": 151, - // "label": "bb22", - // "description": "bb22", - // "systemGenerated": false, - // "hideFromFundAssignment": false, - // "archived": false, - // "employees":[ - // { - // "employee":{kerberos: "emBB", first_name:"anotherR", last_name:"anotherS", department:null}, - // "approvalOrder": 72 - // }, - // { - // "employee":{kerberos: "emBB2", first_name:"anotherS", last_name:"anotherT", department:null}, - // "approvalOrder": 72 - // } - // ] - // }; - // let sample = await this.AdminApproverTypeModel.update(data); - // console.log(sample); - } /** diff --git a/src/lib/cork/models/AdminApproverTypeModel.js b/src/lib/cork/models/AdminApproverTypeModel.js index dec6a65..7899a95 100644 --- a/src/lib/cork/models/AdminApproverTypeModel.js +++ b/src/lib/cork/models/AdminApproverTypeModel.js @@ -54,10 +54,6 @@ class AdminApproverTypeModel extends BaseModel { if ( !data ) { this.store.data.update = {}; } - if ( this.store.data.create[data] ) { - delete this.store.data.create[data]; - } - return out; } @@ -82,9 +78,6 @@ class AdminApproverTypeModel extends BaseModel { if ( !data ) { this.store.data.update = {}; } - if ( this.store.data.update[data] ) { - delete this.store.data.update[data]; - } return out; } diff --git a/src/lib/db-models/approverType.js b/src/lib/db-models/approverType.js index 96b56df..0e1ec86 100644 --- a/src/lib/db-models/approverType.js +++ b/src/lib/db-models/approverType.js @@ -1,5 +1,6 @@ import pg from "./pg.js"; import EntityFields from "../utils/EntityFields.js"; +import employeeModel from "./employee.js"; /** * @class Admin Approver Type @@ -65,38 +66,6 @@ class AdminApproverType { return data; } - - - - /** - * @description Check if Employee Exists in employee table and insert if not - * @param {Object} employee - Employee Object - */ - async existsEmployee(employee){ - const client = await pg.pool.connect(); - - let employeeExists = await pg.query(`SELECT * FROM employee WHERE kerberos = ($1)`, [employee.kerberos]); - - let employeeObj = { - "kerberos":employee.kerberos, - "first_name": employee.first_name, - "last_name":employee.last_name, - "department_id":employee.department - }; - - if(!employeeExists.res.rows.length) { - let data = pg.prepareObjectForInsert(employeeObj); - return await client.query(`INSERT INTO employee (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING *`, data.values); - } else { - const updateEmployeeClause = pg.toUpdateClause(employeeObj); - return await client.query(`UPDATE employee SET ${updateEmployeeClause.sql} - WHERE kerberos = $${updateEmployeeClause.values.length + 1} - RETURNING *`, [...updateEmployeeClause.values, employee.kerberos]);; - } - - } - - /** * @description Check if Employee Exists in the approver_type table and delete to replace @@ -142,7 +111,6 @@ class AdminApproverType { const client = await pg.pool.connect(); const out = {res: [], err: false}; - await client.query('BEGIN'); let approverEmployee = data.employees; @@ -162,8 +130,9 @@ class AdminApproverType { if ( Object.keys(approverEmployee).length === 0 ) { const res = await pg.query(sql, data.values); + if( res.error ) return res; - return this.entityFields.toJsonObj(res.res.rows); + return this.entityFields.toJsonObj(res.res.rows[0]); } try{ @@ -173,8 +142,8 @@ class AdminApproverType { const approverTypeId = approverType.rows[0].approver_type_id; for (const a of approverEmployee) { - const newEmployee = await this.existsEmployee(a.employee); + await employeeModel.upsertInTransaction(client, a.employee); const toEmployeeCreate = {}; if ( approverTypeId ) { toEmployeeCreate['approver_type_id'] = approverTypeId; @@ -260,7 +229,7 @@ class AdminApproverType { for (const a of approverEmployee) { - const newEmployee = await this.existsEmployee(a.employee); + await employeeModel.upsertInTransaction(client, a.employee); const toEmployeeUpdate = {}; if ( approverTypeId ) { From b0e1266600de3208fb15bcc1baac430225883d7d Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Thu, 9 May 2024 12:06:38 -0700 Subject: [PATCH 046/274] split styles off component --- .../ucdlib-employee-search-basic.js | 2 +- .../ucdlib-employee-search-basic.tpl.js | 75 +----------------- .../components/employee-search-basic.scss | 77 +++++++++++++++++++ src/client/scss/style.scss | 1 + 4 files changed, 81 insertions(+), 74 deletions(-) create mode 100644 src/client/scss/components/employee-search-basic.scss diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index 510666b..ee353e7 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -54,7 +54,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) * @param {*} p - Changed properties */ willUpdate(p) { - if ( p.has('query') && p.length > 2 ){ + if ( p.has('query') ){ if ( this.searchTimeout ) clearTimeout(this.searchTimeout); this.searchTimeout = setTimeout(() => { this.search(); diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js index 45baa23..e07262c 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -5,78 +5,7 @@ export function styles() { :host { display: block; } - ucdlib-employee-search-basic { - max-width: 500px; - margin: auto; - display: block - } - - ucdlib-employee-search-basic .emp-search-top.field-container { - margin-bottom: 0 - } - - ucdlib-employee-search-basic .emp-search-bar { - position: relative - } - - ucdlib-employee-search-basic .emp-search-bar input { - flex-grow: 1; - padding-right: 2rem - } - - ucdlib-employee-search-basic .emp-search-bar .emp-search-icon { - position: absolute; - width: 2.5rem; - min-width: 2.5rem; - height: 2.5rem; - min-height: 2.5rem; - display: flex; - align-items: center; - justify-content: center; - right: 0; - top: 0; - color: #022851 - } - - ucdlib-employee-search-basic .emp-search-results-parent { - position: relative - } - - ucdlib-employee-search-basic .emp-search-results { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 100; - background-color: #fffbed; - border-right: 1px solid #ffbf00; - border-left: 1px solid #ffbf00; - border-bottom: 1px solid #ffbf00; - color: #13639e; - max-height: 250px; - overflow-y: scroll - } - - ucdlib-employee-search-basic .emp-search-results .emp-search-result { - padding: .5rem .75rem - } - - ucdlib-employee-search-basic .emp-search-results .emp-search-result:hover { - background-color: #fde9ac - } - - ucdlib-employee-search-basic .emp-search-results .highlight { - font-weight: 700 - } - - ucdlib-employee-search-basic .emp-search-results .muted { - font-weight: 400; - color: #545454 - } - - ucdlib-employee-search-basic .emp-search-results .emp-search-more { - padding: .5rem .75rem - } + `; return [elementStyles]; @@ -87,7 +16,7 @@ return html`
- + - + + `; } diff --git a/src/client/js/pages/admin/app-page-admin-allocations.js b/src/client/js/pages/admin/app-page-admin-allocations.js index 0f1e7f2..09c5e17 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations.js +++ b/src/client/js/pages/admin/app-page-admin-allocations.js @@ -3,6 +3,19 @@ import {render} from "./app-page-admin-allocations.tpl.js"; import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +/** + * @class AppPageAdminAllocations + * @description Admin page for managing employee allocations + * @property {Array} fundingSourceFilters - list of active funding sources returned from EmployeeAllocationModel + * @property {Array} employeeFilters - list of employees with at least one allocation returned from EmployeeAllocationModel + * @property {Array} selectedFundingSourceFilters - list of selected funding source ids + * @property {Array} selectedEmployeeFilters - list of selected employee kerberos ids + * @property {Array} selectedDateRangeFilters - list of selected date range keywords + * @property {Number} page - current page number of query results + * @property {Number} maxPage - total number of pages returned by query + * @property {Array} results - list of employee allocations returned by query + * @property {String} queryState - loading, loaded, or no-results + */ export default class AppPageAdminAllocations extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -15,11 +28,11 @@ export default class AppPageAdminAllocations extends Mixin(LitElement) selectedDateRangeFilters: {type: Array}, page: {type: Number}, maxPage: {type: Number}, - results: {type: Array} + results: {type: Array}, + queryState: {type: String} } } - constructor() { super(); this.render = render.bind(this); @@ -30,6 +43,7 @@ export default class AppPageAdminAllocations extends Mixin(LitElement) this.resetFilters(); this.maxPage = 1; this.results = []; + this.queryState = 'loading'; this._injectModel('AppStateModel', 'EmployeeAllocationModel'); } @@ -43,6 +57,14 @@ export default class AppPageAdminAllocations extends Mixin(LitElement) this.selectedDateRangeFilters = ['current']; } + /** + * @description Query employee allocations using current filter values + */ + async query(){ + this.queryState = 'loading'; + return await this.EmployeeAllocationModel.query(this._queryObject()); + } + /** * @description bound to AppStateModel app-state-update event * @param {Object} state - AppStateModel state @@ -88,14 +110,19 @@ export default class AppPageAdminAllocations extends Mixin(LitElement) async getPageData(){ const promises = []; promises.push(this.EmployeeAllocationModel.getFilters()); - promises.push(this.EmployeeAllocationModel.query(this._queryObject())); + promises.push(this.query()); const resolvedPromises = await Promise.allSettled(promises); return resolvedPromises; } + /** + * @description bound to EmployeeAllocationModel employee-allocations-requested event + * Fires any time employee allocations are requested (not just fetched) + */ _onEmployeeAllocationsRequested(e) { if ( e.state !== 'loaded') return; if ( e.query !== this.EmployeeAllocationModel.queryString(this._queryObject()) ) return; + this.queryState = parseInt(e.payload.total) ? 'loaded' : 'no-results'; this.results = e.payload.data; this.maxPage = e.payload.totalPages; } @@ -130,14 +157,65 @@ export default class AppPageAdminAllocations extends Mixin(LitElement) this.results = []; this.maxPage = 1; - this.EmployeeAllocationModel.query(this._queryObject()); + this.query(); } + /** + * @description Event handler for when the page changes + */ _onPageChange(e){ this.page = e.detail.page; this.results = []; - this.EmployeeAllocationModel.query(this._queryObject()); + this.query(); + } + + /** + * @description Event handler for when the delete button is clicked on an allocation + */ + _onDeleteClick(allocation){ + this.AppStateModel.showDialogModal({ + title : 'Delete Allocation', + content : 'Are you sure you want to delete this employee allocation?', + actions : [ + {text: 'Delete', value: 'delete-allocation', color: 'double-decker'}, + {text: 'Cancel', value: 'cancel', invert: true, color: 'primary'} + ], + data : {allocation} + }); + } + + /** + * @description Callback for dialog-action AppStateModel event + * @param {Object} e - AppStateModel dialog-action event + * @returns + */ + _onDialogAction(e){ + if ( e.action !== 'delete-allocation' ) return; + const allocation = e.data.allocation; + const payload = {ids: [allocation.employeeAllocationId]}; + this.EmployeeAllocationModel.delete(payload); + } + + /** + * @description Event handler for when employee allocations are deleted. + * Callback for employee-allocations-deleted event in EmployeeAllocationModel + */ + async _onEmployeeAllocationsDeleted(e){ + if ( e.state === 'loading' ){ + this.AppStateModel.showLoading(); + return; + } + + if ( e.state === 'loaded' ){ + await this.query(); + this.AppStateModel.showLoaded(this.id); + this.AppStateModel.showToast({message: 'Allocation deleted.', type: 'success'}); + return; + } + + this.AppStateModel.showLoaded(this.id); + this.AppStateModel.showToast({message: 'Error deleting allocation.', type: 'error'}); } /** diff --git a/src/client/js/pages/admin/app-page-admin-allocations.tpl.js b/src/client/js/pages/admin/app-page-admin-allocations.tpl.js index 7a36e31..dc5b98d 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations.tpl.js +++ b/src/client/js/pages/admin/app-page-admin-allocations.tpl.js @@ -2,62 +2,27 @@ import { html } from 'lit'; import '@ucd-lib/theme-elements/brand/ucd-theme-slim-select/ucd-theme-slim-select.js' import '@ucd-lib/theme-elements/brand/ucd-theme-pagination/ucd-theme-pagination.js' +/** + * @description Main render function for this element + */ export function render() { return html`
-
-
-
- - this._onFilterChange(e.detail, 'selectedDateRangeFilters')}> - - -
-
-
-
- - this._onFilterChange(e.detail, 'selectedEmployeeFilters')}> - - -
+ ${renderFilters.call(this)} +
+
${this.results.map(result => renderAllocationItem.call(this, result))}
+
+
-
-
- - this._onFilterChange(e.detail, 'selectedFundingSourceFilters', true)}> - - -
+
+ + No allocations match the applied filters.
-
-
- ${this.results.map(result => renderAllocationItem.call(this, result))}
@@ -76,17 +41,82 @@ return html`
`;} +/** + * @description Render the filters for the allocations page + */ +function renderFilters() { + return html` +
+
+
+ + this._onFilterChange(e.detail, 'selectedDateRangeFilters')}> + + +
+
+
+
+ + this._onFilterChange(e.detail, 'selectedEmployeeFilters')}> + + +
+
+
+
+ + this._onFilterChange(e.detail, 'selectedFundingSourceFilters', true)}> + + +
+
+
+ `; +} + +/** + * @description Render an individual allocation item + */ function renderAllocationItem(allocation) { return html`
-
+
${allocation.employee.firstName} ${allocation.employee.lastName}
${allocation.fundingSourceLabel}
From: ${allocation.startDate}
To: ${allocation.endDate}
+
Amount: $${allocation.amount}
-
$${allocation.amount}
+
$${allocation.amount}
`; diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index c4bf9ce..8410de5 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -16,9 +16,14 @@ .bold { font-weight: 700; } + + .flex { display: flex; } +.flex--justify-center { + justify-content: center; +} .field-error { @@ -50,3 +55,6 @@ fieldset.field-error { font-size: .875rem; color: #13639e; } +.pad-icon-top { + padding-top: .3rem; +} diff --git a/src/client/scss/pages/admin/allocations.scss b/src/client/scss/pages/admin/allocations.scss index bca569a..26f584d 100644 --- a/src/client/scss/pages/admin/allocations.scss +++ b/src/client/scss/pages/admin/allocations.scss @@ -70,10 +70,11 @@ app-page-admin-allocations { flex-grow: 1; } - .allocation-amount { + .allocation-amount__desktop { align-self: center; font-weight: 700; color: #022851; + display: none; } } @@ -82,4 +83,15 @@ app-page-admin-allocations { margin-top: 1rem; } } + + @media (min-width: 480px) { + .allocation-item { + .allocation-amount__desktop { + display: block; + } + .allocation-amount__mobile { + display: none; + } + } + } } diff --git a/src/lib/cork/models/EmployeeAllocationModel.js b/src/lib/cork/models/EmployeeAllocationModel.js index 8c956fc..449cb4d 100644 --- a/src/lib/cork/models/EmployeeAllocationModel.js +++ b/src/lib/cork/models/EmployeeAllocationModel.js @@ -20,6 +20,14 @@ class EmployeeAllocationModel extends BaseModel { this.register('EmployeeAllocationModel'); } + /** + * @description Query employee allocations + * @param {Object} query - query object with the following properties: + * - fundingSources {Array} - array of funding source ids + * - employees {Array} - array of kerberos ids + * - dateRanges {Array} - array of date range keywords: current, future, past + * - page {Number} - page number for pagination + */ async query(query={}){ const queryString = this.queryString(query); @@ -56,7 +64,23 @@ class EmployeeAllocationModel extends BaseModel { } catch(e) {} const state = this.store.data.employeeAllocationsCreated[timestamp]; if ( state && state.state === 'loaded' ) { - // todo clear cache + this.store.data.fetched = {}; + } + return state; + } + + /** + * @description Delete employee allocations + * @param {Object} payload - object with ids property containing array of allocation ids to delete + */ + async delete(payload) { + let timestamp = Date.now(); + try { + await this.service.delete(payload, timestamp); + } catch(e) {} + const state = this.store.data.deleted[timestamp]; + if ( state && state.state === 'loaded' ) { + this.store.data.fetched = {}; } return state; } diff --git a/src/lib/cork/services/EmployeeAllocationService.js b/src/lib/cork/services/EmployeeAllocationService.js index 32064ff..d5f516b 100644 --- a/src/lib/cork/services/EmployeeAllocationService.js +++ b/src/lib/cork/services/EmployeeAllocationService.js @@ -8,6 +8,20 @@ class EmployeeAllocationService extends BaseService { this.store = EmployeeAllocationStore; } + delete(payload, timestamp) { + return this.request({ + url : '/api/admin/employee-allocation', + fetchOptions : { + method : 'DELETE', + body : payload + }, + json: true, + onLoading : request => this.store.employeeAllocationsDeletedLoading(request, timestamp), + onLoad : result => this.store.employeeAllocationsDeletedLoaded(result.body, timestamp), + onError : e => this.store.employeeAllocationsDeletedError(e, timestamp) + }); + } + query(query) { return this.request({ url : `/api/admin/employee-allocation${query ? '?' + query : ''}`, diff --git a/src/lib/cork/stores/EmployeeAllocationStore.js b/src/lib/cork/stores/EmployeeAllocationStore.js index 5a8267b..ef0e6e7 100644 --- a/src/lib/cork/stores/EmployeeAllocationStore.js +++ b/src/lib/cork/stores/EmployeeAllocationStore.js @@ -8,16 +8,47 @@ class EmployeeAllocationStore extends BaseStore { this.data = { employeeAllocationsCreated: {}, filters: {}, - fetched: {} + fetched: {}, + deleted: {} }; this.events = { EMPLOYEE_ALLOCATIONS_CREATED: 'employee-allocations-created', EMPLOYEE_ALLOCATIONS_FILTERS_FETCHED: 'employee-allocations-filters-fetched', EMPLOYEE_ALLOCATIONS_FETCHED: 'employee-allocations-fetched', - EMPLOYEE_ALLOCATIONS_REQUESTED: 'employee-allocations-requested' + EMPLOYEE_ALLOCATIONS_REQUESTED: 'employee-allocations-requested', + EMPLOYEE_ALLOCATIONS_DELETED: 'employee-allocations-deleted' }; } + employeeAllocationsDeletedLoading(request, timestamp) { + this._setEmployeeAllocationsDeletedState({ + state : this.STATE.LOADING, + request, + timestamp + }); + } + + employeeAllocationsDeletedLoaded(payload, timestamp) { + this._setEmployeeAllocationsDeletedState({ + state : this.STATE.LOADED, + payload, + timestamp + }); + } + + employeeAllocationsDeletedError(error, timestamp) { + this._setEmployeeAllocationsDeletedState({ + state : this.STATE.ERROR, + error, + timestamp + }); + } + + _setEmployeeAllocationsDeletedState(state) { + this.data.deleted[state.timestamp] = state; + this.emit(this.events.EMPLOYEE_ALLOCATIONS_DELETED, state); + } + employeeAllocationsFetchedLoading(request, query) { this._setEmployeeAllocationsFetchedState({ state : this.STATE.LOADING, diff --git a/src/lib/db-models/employeeAllocation.js b/src/lib/db-models/employeeAllocation.js index 131735d..3e4d951 100644 --- a/src/lib/db-models/employeeAllocation.js +++ b/src/lib/db-models/employeeAllocation.js @@ -112,6 +112,7 @@ class EmployeeAllocation { } let out = {}; + const ids = []; const client = await pg.pool.connect(); try { @@ -135,12 +136,9 @@ class EmployeeAllocation { delete d.employees; d = pg.prepareObjectForInsert(d); const sql = `INSERT INTO employee_allocation (${d.keysString}) VALUES (${d.placeholdersString}) RETURNING employee_allocation_id`; - await client.query(sql, d.values); - - out = []; - // todo retrieve the full inserted record when method is built + const res = await client.query(sql, d.values); + ids.push(res.rows[0].employee_allocation_id); } - await client.query('COMMIT') }catch (e) { @@ -150,7 +148,10 @@ class EmployeeAllocation { client.release(); } - return out; + if ( out.error ) return out; + + // return inserted records + return await this.get({ids}); } /** @@ -217,6 +218,7 @@ class EmployeeAllocation { /** * @description Get list of employee allocations * @param {Object} kwargs - optional arguments including: + * - ids: array - array of allocation ids to include (optional) * - startDate: object - {value: string, operator: string} - start date in format YYYY-MM-DD (optional) * - endDate: object - {value: string, operator: string} - end date in format YYYY-MM-DD (optional) * - employees: array - array of employee kerberos ids to include (optional) @@ -241,6 +243,9 @@ class EmployeeAllocation { if ( kwargs.fundingSources && kwargs.fundingSources.length) { whereArgs['ea.funding_source_id'] = kwargs.fundingSources; } + if ( kwargs.ids && kwargs.ids.length ) { + whereArgs['ea.employee_allocation_id'] = kwargs.ids; + } const whereClause = pg.toWhereClause(whereArgs); @@ -303,6 +308,55 @@ class EmployeeAllocation { return allocations; } + /** + * @description Archive employee allocations + */ + async archive(ids=[], archivedBy={}) { + ids = (Array.isArray(ids) ? ids : [ids]).map(id => parseInt(id)).filter(id => !isNaN(id)); + if ( ids.length === 0 ) return {error: true, message: 'No valid ids provided'}; + + const client = await pg.pool.connect(); + let out = {}; + const archivedByKerb = archivedBy?.kerberos ? archivedBy.kerberos : ''; + + try { + await client.query('BEGIN'); + + // add who archived the allocations + if ( archivedByKerb ) { + await employeeModel.upsertInTransaction(client, archivedBy); + } + + // archive each allocation + for (const id of ids) { + const sql = ` + UPDATE + employee_allocation + SET + deleted = true, + deleted_by = $1, + deleted_at = NOW() + WHERE + employee_allocation_id = $2 + `; + await client.query(sql, [archivedByKerb, id]); + } + + await client.query('COMMIT'); + + } catch (e) { + await client.query('ROLLBACK'); + out = {error: e}; + } finally { + client.release(); + } + if ( out.error ) return out; + + // return updated records + return await this.get({ids}); + + } + /** * @description Get list of employees with their total allocations * @param {Object} kwargs - optional arguments including: diff --git a/src/lib/utils/apiUtils.js b/src/lib/utils/apiUtils.js index dccd27b..600b800 100644 --- a/src/lib/utils/apiUtils.js +++ b/src/lib/utils/apiUtils.js @@ -31,9 +31,11 @@ class ApiUtils { let out = []; if ( !value ) return out; if ( Array.isArray(value) ) { - out = value.map(item => item.trim()); - } else { + out = value.map(item => typeof item === 'string' ? item.trim() : item); + } else if ( typeof value === 'string' ) { out = value.split(',').map(item => item.trim()); + } else { + return out; } if ( !asInt ) return out; return out.map(item => parseInt(item)).filter(item => !isNaN(item)); From 7e952cb426a2ba7dec602b562a62b4ef1c8e2192 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Wed, 15 May 2024 14:26:10 -0700 Subject: [PATCH 059/274] adding edit and create function --- src/client/js/components/app-approver-type.js | 208 +++++++++++++----- .../js/components/app-approver-type.tpl.js | 101 ++++++--- src/client/scss/components/approver-type.scss | 11 +- 3 files changed, 232 insertions(+), 88 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index c5f79ae..b83b27c 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -11,7 +11,6 @@ export default class AppApproverType extends Mixin(LitElement) static get properties() { return { existingApprovers:{type: Array, attribute: 'existingApprovers'}, - } } @@ -23,6 +22,15 @@ export default class AppApproverType extends Mixin(LitElement) super(); this.systemGenerated = true; this.existingApprovers = []; + this.newApprover = { + approverTypeId: 0, + label: {}, + description: {}, + systemGenerated:false, + hideFromFundAssignment:false, + archived: false, + employees: [] + }; this.render = render.bind(this); this._injectModel('AppStateModel', 'AdminApproverTypeModel'); @@ -35,6 +43,20 @@ export default class AppApproverType extends Mixin(LitElement) super.connectedCallback() } + /** + * @description bound to ApproverType BASIC_EMPLOYEES_FETCHED event + * fires when active line items are fetched from the server + */ + // _onBasicEmployeesFetched(e){ + // if ( e.state !== 'loaded' ) return; + // this.newApprover = e.payload.map(at => { + // at = {...at}; + // at.editing = false; + // // at.validationHandler = new ValidationHandler(); + // return at; + // }); + // } + async _setLabel(value){ this.label = value; } @@ -47,15 +69,87 @@ export default class AppApproverType extends Mixin(LitElement) * @description on submit button get the form data * */ - async _onSubmit(){ - console.log(this.label); - console.log(this.description); + async _onNewSubmit(){ + this.newApprover.label = this.label; + this.newApprover.description = this.description; + // this.newApprover.employees = this.employees; document.querySelector(".inputLabel").value = ""; - document.querySelector(".textDescription").value = "";; + document.querySelector(".textDescription").value = ""; + // await this.AdminApproverTypeModel.create(this.newApprover); + this._getApproverType(); + + this.newApprover = {}; this.requestUpdate(); + + + } + + /** + * @description on edit button from a approver + * + */ + async _onEdit(e, approver){ + approver.editing = true; + this.requestUpdate(); + + } + + + /** + * @description on edit Save button from a approver + * + */ + async _onEditSave(e, approver){ + let editApprover = approver; + editApprover.editing = false; + editApprover.label = this.label; + editApprover.description = this.description; + // editApprover.employees = this.employees; + console.log(editApprover); + + // await this.AdminApproverTypeModel.update(approver); + this._getApproverType(); + this.requestUpdate(); + + } + + /** + * @description on edit Cancel button from a approver + * + */ + async _onEditCancel(e, approver){ + approver.editing = false; + + this.requestUpdate(); + + } + + /** + * @description on archive button from a approver + * + */ + async _onDelete(approver){ + approver.archived = true; + + this.AppStateModel.showDialogModal({ + title : 'Delete Approver Type Option', + content : 'Are you sure you want to delete this Approver Type Option?', + actions : [ + {text: 'Delete', value: 'delete-approver-item', color: 'double-decker'}, + {text: 'Cancel', value: 'cancel', invert: true, color: 'primary'} + ], + data : {approver} + }); + } + + _onDialogAction(e){ + if ( e.action !== 'delete-approver-item' ) return; + const approverItem = e.data.approver; + approverItem.archived = true; + // await this.AdminApproverTypeModel.update(approverItem); } /** @@ -73,61 +167,67 @@ export default class AppApproverType extends Mixin(LitElement) let approverArray = [ { - "approverTypeID": 175, - "label": "updateNew44", - "description": "updateNew44", - "systemGenerated": false, - "hide_from_fund_assignment": false, - "archived": false, - "approvalOrder": 72, - "employees": [{ - "kerberos": "updateNew44Emp", - "firstName": "anotherR", - "lastName": "anotherS", - "approvalOrder": 72 - }, - { - "kerberos": "updateNew44Emp22", - "firstName": "anotherS", - "lastName": "anotherT", - "approvalOrder": 72 - }] + "approverTypeID": 175, + "label": "updateNew44", + "description": "updateNew44", + "systemGenerated": false, + "hide_from_fund_assignment": false, + "archived": false, + "approvalOrder": 72, + "employees": [ + { + "kerberos": "updateNew44Emp", + "firstName": "anotherR", + "lastName": "anotherS", + "approvalOrder": 72 + }, + { + "kerberos": "updateNew44Emp22", + "firstName": "anotherS", + "lastName": "anotherT", + "approvalOrder": 72 + } + ] }, - { - "approverTypeID": 1, - "label": "Supervisor", - "description": "The current direct supervisor of the requester from iam.staff.library.ucdavis.edu.", - "systemGenerated": true, - "hide_from_fund_assignment": false, - "archived": false, - "approvalOrder": null, - "employees": [{ - "kerberos": null, - "firstName": null, - "lastName": null, - "approvalOrder": null - }] + "approverTypeID": 1, + "label": "Supervisor", + "description": "The current direct supervisor of the requester from iam.staff.library.ucdavis.edu.", + "systemGenerated": true, + "hide_from_fund_assignment": false, + "archived": false, + "approvalOrder": null, + "employees": { + "kerberos": null, + "firstName": null, + "lastName": null, + "approvalOrder": null + } }, { - "approverTypeID": 3, - "label": "Finance Head", - "description": "The head of the Library Finance department", - "systemGenerated": true, - "hide_from_fund_assignment": false, - "archived": false, - "approvalOrder": null, - "employees": [{ - "kerberos": null, - "firstName": null, - "lastName": null, - "approvalOrder": null - }] + "approverTypeID": 3, + "label": "Finance Head", + "description": "The head of the Library Finance department", + "systemGenerated": true, + "hide_from_fund_assignment": false, + "archived": false, + "approvalOrder": null, + "employees": { + "kerberos": null, + "firstName": null, + "lastName": null, + "approvalOrder": null + } } - ]; + ]; + + approverArray.map((emp) => { + if(!Array.isArray(emp.employees)) emp.employees = [emp.employees] + }); + + this.existingApprovers = approverArray; - this.existingApprovers = approverArray; - this.requestUpdate(); + this.requestUpdate(); } } diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index 1d31efe..6558ea1 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -28,53 +28,90 @@ return html`
- ${this.existingApprovers.map((approver) => html` -
+ ${this.existingApprovers.map((approver) => { + if(approver.editing) return renderApproverForm.call(this, approver); + return renderApproverItem.call(this, approver); + })} +
+ + ${renderApproverForm.call(this, this.newApprover)} + +

+ + +
+ + +`;} + + +function renderApproverItem(approver) { + let ap = approver; + const approverId = approver.approverTypeID; + const itemIdLabel = `item-label-${approverId}`; + const itemIdDescription = `item-description-${approverId}`; + const itemIdEmployees = `item-employee-${approverId}`; + + // if(this.editApprover) { + // ap = this.editApprover; + // console.log(ap); + + // } else { + // ap = approver; + // } + + return html` +
-

${approver.label} - - ${!approver.systemGenerated ? html``:html``} +

${ap.label} + this._onEdit(e, ap)} class="user-icon"> + ${!ap.systemGenerated ? html` this._onDelete(ap)} class="trash-icon">`:html``}

- ${approver.description}
+ ${ap.description}
- ${approver.employees.map((employee) => html` - -  ${!approver.systemGenerated ? html`${employee.firstName} ${employee.lastName}`:html`System Generated`}
-
- `)} +
+ ${ap.employees.map((employee) => html` + +  ${!ap.systemGenerated ? html`${employee.firstName} ${employee.lastName}`:html`System Generated`}
+
+ `)} +
- `)} - + ` +} +function renderApproverForm(approver) { + // if ( !approver || Object.keys(approver).length === 0 ) return html``; + const approverId = approver.approverTypeID || 'new'; + const inputIdLabel = `approver-label-${approverId}`; + const inputIdDescription = `approver-description-${approverId}`; + + return html`

Edit Approver
- - this._setLabel(e.target.value)} type="text" placeholder="Position Title"> + + this._setLabel(e.target.value)} type="text" placeholder="Position Title">
-
- - + +

- -
- -

- + ${approver.editing ? html` + +

+ + +

+
+ `:html``} -
- - -`;} \ No newline at end of file + + ` +} \ No newline at end of file diff --git a/src/client/scss/components/approver-type.scss b/src/client/scss/components/approver-type.scss index 6ddef37..7396621 100644 --- a/src/client/scss/components/approver-type.scss +++ b/src/client/scss/components/approver-type.scss @@ -5,11 +5,18 @@ app-approver-type { } .user-icon{ color:#13639E; - margin-left:10px;width:20px;height:20px; + margin-left:10px; + width:15px; + height:15px; + cursor: pointer; + } .trash-icon{ color:#C10230; - margin-left:10px;width:20px;height:20px; + margin-left:10px; + width:15px; + height:15px; + cursor: pointer; } .field-container{ From c913c34939b554bef8b609fee4d187b3d0b6805f Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Thu, 16 May 2024 03:40:25 -0700 Subject: [PATCH 060/274] small changes --- src/api/admin/approverType.js | 3 +- src/client/js/pages/app-page-home.js | 2 +- src/lib/cork/models/AdminApproverTypeModel.js | 13 +- .../cork/services/AdminApproverTypeService.js | 28 +-- src/lib/db-models/approverType.js | 202 ++++++++---------- 5 files changed, 106 insertions(+), 142 deletions(-) diff --git a/src/api/admin/approverType.js b/src/api/admin/approverType.js index 4d0b50e..b106f17 100644 --- a/src/api/admin/approverType.js +++ b/src/api/admin/approverType.js @@ -8,7 +8,6 @@ export default (api) => { */ api.get('/approver-type', protect('hasBasicAccess'), async (req, res) => { const query = req.query; - if (!query || Object.keys(query).length === 0 ) return res.status(400).json({error: true, message: 'Query is required.'}); const data = await approverType.query(query); @@ -48,4 +47,4 @@ export default (api) => { } res.json({data: data, error: false}); }); -}; +}; \ No newline at end of file diff --git a/src/client/js/pages/app-page-home.js b/src/client/js/pages/app-page-home.js index 5696cf3..e11d08b 100644 --- a/src/client/js/pages/app-page-home.js +++ b/src/client/js/pages/app-page-home.js @@ -14,7 +14,7 @@ export default class AppPageHome extends Mixin(LitElement) constructor() { super(); this.render = render.bind(this); - this._injectModel('AppStateModel'); + this._injectModel('AppStateModel',); } diff --git a/src/lib/cork/models/AdminApproverTypeModel.js b/src/lib/cork/models/AdminApproverTypeModel.js index 05356a5..0d6f62a 100644 --- a/src/lib/cork/models/AdminApproverTypeModel.js +++ b/src/lib/cork/models/AdminApproverTypeModel.js @@ -1,6 +1,7 @@ import {BaseModel} from '@ucd-lib/cork-app-utils'; import AdminApproverTypeService from '../services/AdminApproverTypeService.js'; import AdminApproverTypeStore from '../stores/AdminApproverTypeStore.js'; +import urlUtils from '../../utils/urlUtils.js'; class AdminApproverTypeModel extends BaseModel { @@ -22,17 +23,17 @@ class AdminApproverTypeModel extends BaseModel { * */ async query(args = {}) { - args = this.service.sort(args); - let state = this.store.data.query[args];; + let state = this.store.data.query[args]; + args = urlUtils.queryStringFromObject(args); + try { if( state && state.state === 'loading' ) { await state.request; } else { await this.service.query(args); } - - if (state.state === 'loaded') { this.store.data.query = {} } } catch(e) {} + return this.store.data.query[args]; } @@ -42,7 +43,7 @@ class AdminApproverTypeModel extends BaseModel { */ async create(data) { - data = this.service.sort(data); + // data = this.service.sort(data); try { let state = this.store.data.create[data];; await this.service.create(data); @@ -65,7 +66,7 @@ class AdminApproverTypeModel extends BaseModel { * @param {String} data - data to update for approvers */ async update(data) { - data = this.service.sort(data); + // data = this.service.sort(data); try { let state = this.store.data.update[data]; await this.service.update(data); diff --git a/src/lib/cork/services/AdminApproverTypeService.js b/src/lib/cork/services/AdminApproverTypeService.js index 7745a01..5c89326 100644 --- a/src/lib/cork/services/AdminApproverTypeService.js +++ b/src/lib/cork/services/AdminApproverTypeService.js @@ -1,6 +1,7 @@ import BaseService from './BaseService.js'; import AdminApproverTypeStore from '../stores/AdminApproverTypeStore.js'; + class AdminApproverTypeService extends BaseService { constructor() { @@ -10,12 +11,8 @@ class AdminApproverTypeService extends BaseService { } query(data){ - data = this.sort(data); - let id = data[0].id ? `id=${encodeURIComponent(JSON.stringify(data[0].id))}&` :``; - return this.request({ - url : `/api/admin/approver-type?${id}status=${data[0].status}`, - + url : `/api/admin/approver-type?${data}`, checkCached: () => this.store.data.query[JSON.stringify(data)], onLoading : request => this.store.queryLoading(request, data), onLoad : result => this.store.queryLoaded(result.body, data), @@ -52,27 +49,6 @@ class AdminApproverTypeService extends BaseService { } - sort(data){ - if(data.label) { - data = [data]; - let sortData = data.sort((a,b) => (a.label > b.label) ? 1 : ((b.label > a.label) ? -1 : 0)); - for(var i = 0; i < sortData.length; i++) { - if (Array.isArray(sortData[i].label)) { - sortData[i].label; - } - } - return sortData[0]; - }else { - let sortData = data.sort((a,b) => (a.id > b.id) ? 1 : ((b.id > a.id) ? -1 : 0)); - for(var i = 0; i < sortData.length; i++) { - if (Array.isArray(sortData[i].id)) { - sortData[i].id; - } - } - return sortData; - } - - } } const service = new AdminApproverTypeService(); diff --git a/src/lib/db-models/approverType.js b/src/lib/db-models/approverType.js index 4a07c01..64cb7a4 100644 --- a/src/lib/db-models/approverType.js +++ b/src/lib/db-models/approverType.js @@ -17,8 +17,7 @@ class AdminApproverType { {dbName: 'hide_from_fund_assignment', jsonName: 'hideFromFundAssignment'}, {dbName: 'archived', jsonName: 'archived'}, {dbName: 'approval_order', jsonName: 'approvalOrder'}, - {dbName: 'employees', jsonName: 'employees'}, - + {dbName: 'employees', jsonName: 'employees', customValidation: this.kerberosValidation.bind(this)}, ]); this.entityEmployeeFields = new EntityFields([ {dbName: 'approver_type_id', jsonName: 'approverTypeId', required: true}, @@ -39,89 +38,77 @@ class AdminApproverType { * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee */ async query(kwargs){ - let id; - let idArray = kwargs.id ? JSON.parse(kwargs.id): ''; - const whereArgs = {}; + let idArray; + if(typeof(kwargs.id) === "number"){ + idArray = [kwargs.id]; + } else { + idArray = kwargs.id ? kwargs.id.split(',') : ''; + } + const status = kwargs.status ? kwargs.status : ''; - if(idArray != ''){ - if(Array.isArray(idArray)) { - id = idArray; - } else { - id = [idArray]; - } - whereArgs['ap.approver_type_id'] = Array.isArray(idArray) ? idArray : [idArray]; - } + const whereArgs = {}; + if(idArray != '') whereArgs['ap.approver_type_id'] = idArray; - if(kwargs["status"] == "archived") { + if(status == "archived") { whereArgs['ap.archived'] = true; - } else if (kwargs["status"] == "active"){ + } else if (status == "active"){ whereArgs['ap.archived'] = false; } const whereClause = pg.toWhereClause(whereArgs); let query = ` - SELECT - json_agg(json_build_object( - 'approverTypeID', ap.approver_type_id, - 'label', ap.label, - 'description', ap.description, - 'systemGenerated', ap.system_generated, - 'hide_from_fund_assignment', ap.hide_from_fund_assignment, - 'archived', ap.archived, - 'approvalOrder', ate.approval_order, - 'employees', json_build_object( - 'kerberos', emp.kerberos, - 'firstName', emp.first_name, - 'lastName', emp.last_name, - 'approvalOrder', ate.approval_order - ) - ) ORDER BY ate.approval_order) AS approver_types + SELECT + ap.*, + ( + SELECT json_agg(json_build_object( + 'kerberos', emp.kerberos, + 'firstName', emp.first_name, + 'lastName', emp.last_name + )) + FROM employee emp + WHERE ate.employee_kerberos = emp.kerberos + ) AS employees FROM approver_type ap LEFT JOIN approver_type_employee ate ON ap.approver_type_id = ate.approver_type_id - LEFT JOIN - employee emp ON ate.employee_kerberos = emp.kerberos ${whereClause.sql ? `WHERE ${whereClause.sql}` : ''} ` const res = await pg.query(query, whereClause.values); - let r = res.res.rows[0].approver_types; + let r = this.entityFields.toJsonArray(res.res.rows); if( res.error ) return res; - const data = this.queryFormat(r); + const data = this.queryFormat(r); return data; } /** - * @description Check if Employee Exists in the approver_type table and delete to replace - * @param {Number} id - ID Employee - + * @description Formats the query results to combine the data based on approverTypeID + * @param {Number} data - Query Results + * + * @returns {Array} Array of Objects for the combined results + * */ - async queryFormat(array){ - let output = []; - array.forEach(function(item) { - var existing = output.filter(function(v, i) { - return v.approverTypeID == item.approverTypeID; - }); - if (existing.length) { - var existingIndex = output.indexOf(existing[0]); - output[existingIndex].employees = [output[existingIndex].employees].concat([item.employees]); + async queryFormat(data){ + const mergedData = data.reduce((acc, item) => { + const existingItem = acc.find(element => element.approverTypeId === item.approverTypeId); + if (existingItem) { + existingItem.employees = existingItem.employees.concat(item.employees || []); } else { - if (typeof item.employees == 'string') - item.employees = [item.employees]; - output.push(item); + acc.push(item); } - }); - return output; + return acc; + }, []); + return mergedData; } /** - * @description Use data for system validation + * @description Use data for employees validation * @param {Object} data - Object of Entity fields with camelcase * * @returns {Object} {status: false, message:""} @@ -142,6 +129,23 @@ class AdminApproverType { } } + /** + * @description Use data for kerberos validation + * @param {Object} data - Object of Entity fields with camelcase + * + * @returns {Object} {status: false, message:""} + */ + async kerberosValidation(field, value, out){ + let error = {errorType: 'invalid', message: 'Employee Kerberos Error.'}; + + const noKerberos = value.every(v => ( v.kerberos && v.kerberos != '' && v.kerberos != undefined)) + + if ( !noKerberos ) { + this.entityFields.pushError(out, field, error); + return; + } + } + /** * @description Create the admin approver type table * @param {Object} data - Object of Entity fields with camelcase @@ -149,50 +153,41 @@ class AdminApproverType { * @returns {Object} {error: false} */ async create(data){ + + data = this.entityFields.toDbObj(data); + const validation = this.entityFields.validate(data, ['approver_type_id']); + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } const client = await pg.pool.connect(); let out = {}; let approverTypeId; - try{ - await client.query('BEGIN'); - - let approverEmployee = data.employees; - - const noKerberos = approverEmployee.every(a => ( a.employee.kerberos && a.employee.kerberos != '' && a.employee.kerberos != undefined)) - - if ( !noKerberos ) { - return {error: true, message: 'Employee Kerberos Error', is400: true}; - } + try{ + let approverEmployee = data.employees; + await client.query('BEGIN'); - data = this.entityFields.toDbObj(data); - const validation = this.entityFields.validate(data, ['approver_type_id']); - if ( !validation.valid ) { - return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; - } - + delete data.approver_type_id; + delete data.employees; - delete data.approver_type_id; - delete data.employees; + data = pg.prepareObjectForInsert(data); - data = pg.prepareObjectForInsert(data); + const sql = `INSERT INTO approver_type (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING approver_type_id`; - const sql = `INSERT INTO approver_type (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING approver_type_id`; - const res = await pg.query(sql, data.values); - approverTypeId = res.res.rows[0].approver_type_id; + const res = await client.query(sql, data.values); + approverTypeId = res.rows[0].approver_type_id; if ( Object.keys(approverEmployee).length ) { for (const [index, a] of approverEmployee.entries()) { await employeeModel.upsertInTransaction(client, a.employee); - const toEmployeeCreate = {}; - if ( approverTypeId ) { - toEmployeeCreate['approver_type_id'] = approverTypeId; - } - if ( a.employee.kerberos ) { - toEmployeeCreate['employee_kerberos'] = a.employee.kerberos; - } + const toEmployeeCreate = { + 'approver_type_id' : approverTypeId, + 'employee_kerberos': a.employee.kerberos + }; + if ( a.approvalOrder ){ toEmployeeCreate['approval_order'] = index; } @@ -227,29 +222,23 @@ class AdminApproverType { * @returns {Object} {error: false} */ async update(data){ + + data = this.entityFields.toDbObj(data); + const validation = this.entityFields.validate(data); + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + const client = await pg.pool.connect(); let out = {}; let approverTypeId; - try{ - await client.query('BEGIN'); + try{ let approverEmployee = data.employees; - - const noKerberos = approverEmployee.every(a => ( a.employee.kerberos && a.employee.kerberos != '' && a.employee.kerberos != undefined)) - - if ( !noKerberos ) { - return {error: true, message: 'Employee Kerberos Error', is400: true}; - } - - - data = this.entityFields.toDbObj(data); - const validation = this.entityFields.validate(data); - if ( !validation.valid ) { - return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; - } - approverTypeId = data.approver_type_id; + await client.query('BEGIN'); + delete data.employees; const updateClause = pg.toUpdateClause(data); @@ -260,20 +249,18 @@ class AdminApproverType { RETURNING approver_type_id `; - await pg.query(`DELETE FROM approver_type_employee WHERE approver_type_id = ($1)`, [approverTypeId]); - const res = await pg.query(sql, [...updateClause.values, approverTypeId]); + await client.query(`DELETE FROM approver_type_employee WHERE approver_type_id = ($1)`, [approverTypeId]); + await client.query(sql, [...updateClause.values, approverTypeId]); if ( Object.keys(approverEmployee).length ) { for (const [index, a] of approverEmployee.entries()) { await employeeModel.upsertInTransaction(client, a.employee); - const toEmployeeUpdate = {}; - if ( approverTypeId ) { - toEmployeeUpdate['approver_type_id'] = approverTypeId; - } - if ( a.employee.kerberos ) { - toEmployeeUpdate['employee_kerberos'] = a.employee.kerberos; - } + const toEmployeeUpdate = { + 'approver_type_id' : approverTypeId, + 'employee_kerberos': a.employee.kerberos + }; + if ( a.approvalOrder ){ toEmployeeUpdate['approval_order'] = index; } @@ -282,6 +269,7 @@ class AdminApproverType { } let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeUpdate); + const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholdersString}) RETURNING *`; await client.query(employeeSql, approverEmployeeData.values); } From 64ef8c9acdaa943b9ca6304960b68b489649ee41 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Thu, 16 May 2024 03:55:09 -0700 Subject: [PATCH 061/274] stash --- src/client/js/components/app-approver-type.js | 52 +++++++++---------- .../js/components/app-approver-type.tpl.js | 8 --- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index b83b27c..d5211fc 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -39,7 +39,6 @@ export default class AppApproverType extends Mixin(LitElement) connectedCallback() { this._getApproverType(); - super.connectedCallback() } @@ -79,7 +78,6 @@ export default class AppApproverType extends Mixin(LitElement) // await this.AdminApproverTypeModel.create(this.newApprover); this._getApproverType(); - this.newApprover = {}; this.requestUpdate(); @@ -94,7 +92,6 @@ export default class AppApproverType extends Mixin(LitElement) async _onEdit(e, approver){ approver.editing = true; this.requestUpdate(); - } @@ -108,7 +105,6 @@ export default class AppApproverType extends Mixin(LitElement) editApprover.label = this.label; editApprover.description = this.description; // editApprover.employees = this.employees; - console.log(editApprover); // await this.AdminApproverTypeModel.update(approver); this._getApproverType(); @@ -116,41 +112,43 @@ export default class AppApproverType extends Mixin(LitElement) } - /** + /** * @description on edit Cancel button from a approver * */ - async _onEditCancel(e, approver){ + async _onEditCancel(e, approver){ approver.editing = false; - this.requestUpdate(); - } /** * @description on archive button from a approver * */ - async _onDelete(approver){ - approver.archived = true; - - this.AppStateModel.showDialogModal({ - title : 'Delete Approver Type Option', - content : 'Are you sure you want to delete this Approver Type Option?', - actions : [ - {text: 'Delete', value: 'delete-approver-item', color: 'double-decker'}, - {text: 'Cancel', value: 'cancel', invert: true, color: 'primary'} - ], - data : {approver} - }); - } + async _onDelete(approver){ + approver.archived = true; + + this.AppStateModel.showDialogModal({ + title : 'Delete Approver Type Option', + content : 'Are you sure you want to delete this Approver Type Option?', + actions : [ + {text: 'Delete', value: 'delete-approver-item', color: 'double-decker'}, + {text: 'Cancel', value: 'cancel', invert: true, color: 'primary'} + ], + data : {approver} + }); + } - _onDialogAction(e){ - if ( e.action !== 'delete-approver-item' ) return; - const approverItem = e.data.approver; - approverItem.archived = true; - // await this.AdminApproverTypeModel.update(approverItem); - } + /** + * @description on dialog action for deleting an approver + * + */ + _onDialogAction(e){ + if ( e.action !== 'delete-approver-item' ) return; + const approverItem = e.data.approver; + approverItem.archived = true; + // await this.AdminApproverTypeModel.update(approverItem); + } /** * @description Get Approver type from query diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index 6558ea1..147d12c 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -52,14 +52,6 @@ function renderApproverItem(approver) { const itemIdDescription = `item-description-${approverId}`; const itemIdEmployees = `item-employee-${approverId}`; - // if(this.editApprover) { - // ap = this.editApprover; - // console.log(ap); - - // } else { - // ap = approver; - // } - return html`
From c40e85cdec1cfb0f6bf5c5ee669482370046059d Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Thu, 16 May 2024 12:51:34 -0700 Subject: [PATCH 062/274] updated gui with query --- src/client/js/components/app-approver-type.js | 171 +++++++++--------- .../js/components/app-approver-type.tpl.js | 22 ++- 2 files changed, 94 insertions(+), 99 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index d5211fc..17e5675 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -22,18 +22,11 @@ export default class AppApproverType extends Mixin(LitElement) super(); this.systemGenerated = true; this.existingApprovers = []; - this.newApprover = { - approverTypeId: 0, - label: {}, - description: {}, - systemGenerated:false, - hideFromFundAssignment:false, - archived: false, - employees: [] - }; + this.render = render.bind(this); this._injectModel('AppStateModel', 'AdminApproverTypeModel'); + this._resetProperties(); } @@ -42,7 +35,36 @@ export default class AppApproverType extends Mixin(LitElement) super.connectedCallback() } - /** + /** + * @description runs the refresh properties after edit/create/delete function runs + * + */ + async _refreshProperties(){ + this._getApproverType(); + this._resetProperties(); + this.requestUpdate(); + } + + /** + * @description reset properties for the approver + * + */ + async _resetProperties(){ + this.label = ""; + this.description = ""; + this.employees = []; + this.newApprover = { + approverTypeId: 0, + label: {}, + description: {}, + systemGenerated:false, + hideFromFundAssignment:false, + archived: false, + employees: [] + }; + } + + /** * @description bound to ApproverType BASIC_EMPLOYEES_FETCHED event * fires when active line items are fetched from the server */ @@ -71,15 +93,17 @@ export default class AppApproverType extends Mixin(LitElement) async _onNewSubmit(){ this.newApprover.label = this.label; this.newApprover.description = this.description; + // this.newApprover.employees = this.employees; + this.newApprover = this.employeeFormat(this.newApprover); + document.querySelector(".inputLabel").value = ""; document.querySelector(".textDescription").value = ""; - // await this.AdminApproverTypeModel.create(this.newApprover); - this._getApproverType(); - this.newApprover = {}; - this.requestUpdate(); + await this.AdminApproverTypeModel.create(this.newApprover); + + this._refreshProperties(); @@ -94,6 +118,26 @@ export default class AppApproverType extends Mixin(LitElement) this.requestUpdate(); } + /** + * @description on edit button from a approver + * @returns {Array} array of objects with updated employees + * + */ + employeeFormat(approver){ + if(approver.employees[0] == null) approver.employees = []; + + // approver.employees = [{ + // employee: { + // "kerberos": "EditGuiEmp", + // "firstName": "emp1", + // "lastName": "emp2", + // "department": null + // }, + // approvalOrder: 5 + // }]; + // approver.employees = this.employees; + return approver; +} /** * @description on edit Save button from a approver @@ -104,21 +148,21 @@ export default class AppApproverType extends Mixin(LitElement) editApprover.editing = false; editApprover.label = this.label; editApprover.description = this.description; - // editApprover.employees = this.employees; + + editApprover = this.employeeFormat(editApprover); - // await this.AdminApproverTypeModel.update(approver); - this._getApproverType(); - this.requestUpdate(); - + await this.AdminApproverTypeModel.update(editApprover); + + this._refreshProperties(); } /** * @description on edit Cancel button from a approver * */ - async _onEditCancel(e, approver){ - approver.editing = false; - this.requestUpdate(); + async _onEditCancel(e, approver){ + approver.editing = false; + this.requestUpdate(); } /** @@ -126,8 +170,6 @@ export default class AppApproverType extends Mixin(LitElement) * */ async _onDelete(approver){ - approver.archived = true; - this.AppStateModel.showDialogModal({ title : 'Delete Approver Type Option', content : 'Are you sure you want to delete this Approver Type Option?', @@ -143,11 +185,17 @@ export default class AppApproverType extends Mixin(LitElement) * @description on dialog action for deleting an approver * */ - _onDialogAction(e){ + async _onDialogAction(e){ if ( e.action !== 'delete-approver-item' ) return; - const approverItem = e.data.approver; + let approverItem = e.data.approver; approverItem.archived = true; - // await this.AdminApproverTypeModel.update(approverItem); + + approverItem = this.employeeFormat(approverItem); + + await this.AdminApproverTypeModel.update(approverItem); + + this._refreshProperties(); + } /** @@ -155,69 +203,12 @@ export default class AppApproverType extends Mixin(LitElement) * */ async _getApproverType(){ - // let args = [{ status:"active"}]; - // let approvers = await this.AdminApproverTypeModel.query(args); - // let approverArray = approvers.payload.filter(function (el) { - // return el.archived == false && - // el.hideFromFundAssignment == false; - // }); - - let approverArray = - [ - { - "approverTypeID": 175, - "label": "updateNew44", - "description": "updateNew44", - "systemGenerated": false, - "hide_from_fund_assignment": false, - "archived": false, - "approvalOrder": 72, - "employees": [ - { - "kerberos": "updateNew44Emp", - "firstName": "anotherR", - "lastName": "anotherS", - "approvalOrder": 72 - }, - { - "kerberos": "updateNew44Emp22", - "firstName": "anotherS", - "lastName": "anotherT", - "approvalOrder": 72 - } - ] - }, - { - "approverTypeID": 1, - "label": "Supervisor", - "description": "The current direct supervisor of the requester from iam.staff.library.ucdavis.edu.", - "systemGenerated": true, - "hide_from_fund_assignment": false, - "archived": false, - "approvalOrder": null, - "employees": { - "kerberos": null, - "firstName": null, - "lastName": null, - "approvalOrder": null - } - }, - { - "approverTypeID": 3, - "label": "Finance Head", - "description": "The head of the Library Finance department", - "systemGenerated": true, - "hide_from_fund_assignment": false, - "archived": false, - "approvalOrder": null, - "employees": { - "kerberos": null, - "firstName": null, - "lastName": null, - "approvalOrder": null - } - } - ]; + let args = { id: [2, 5, 10,151, 174, 175, 176, 248, 249, 252, 255, 256]}; + let approvers = await this.AdminApproverTypeModel.query(args); + let approverArray = approvers.payload.filter(function (el) { + return el.archived == false && + el.hideFromFundAssignment == false; + }); approverArray.map((emp) => { if(!Array.isArray(emp.employees)) emp.employees = [emp.employees] diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index 147d12c..8fdbacd 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -47,7 +47,7 @@ return html` function renderApproverItem(approver) { let ap = approver; - const approverId = approver.approverTypeID; + const approverId = approver.approverTypeId; const itemIdLabel = `item-label-${approverId}`; const itemIdDescription = `item-description-${approverId}`; const itemIdEmployees = `item-employee-${approverId}`; @@ -62,20 +62,24 @@ function renderApproverItem(approver) { ${ap.description}
-
- ${ap.employees.map((employee) => html` - -  ${!ap.systemGenerated ? html`${employee.firstName} ${employee.lastName}`:html`System Generated`}
-
- `)} -
+ ${ap.systemGenerated || ap.employees[0] != null ? html` +
+ ${ap.employees.map((employee) => html` + +  ${!ap.systemGenerated ? html`${employee.firstName} ${employee.lastName}`:html`System Generated`}
+
+ `)} +
+ + `:html``} +
` } function renderApproverForm(approver) { // if ( !approver || Object.keys(approver).length === 0 ) return html``; - const approverId = approver.approverTypeID || 'new'; + const approverId = approver.approverTypeId || 'new'; const inputIdLabel = `approver-label-${approverId}`; const inputIdDescription = `approver-description-${approverId}`; From 0a09cde65c89e7e79916b2149dfda386d6a73aa5 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Thu, 16 May 2024 13:01:48 -0700 Subject: [PATCH 063/274] fix PR #3 --- src/lib/cork/models/AdminApproverTypeModel.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/cork/models/AdminApproverTypeModel.js b/src/lib/cork/models/AdminApproverTypeModel.js index 0d6f62a..53d7952 100644 --- a/src/lib/cork/models/AdminApproverTypeModel.js +++ b/src/lib/cork/models/AdminApproverTypeModel.js @@ -48,14 +48,16 @@ class AdminApproverTypeModel extends BaseModel { let state = this.store.data.create[data];; await this.service.create(data); - if (state.state === 'loaded') this.store.data.create = {} + if ( state && state.state === 'loaded' ) { + this.store.data.create = {} + } } catch(e) {} const out = this.store.data.create; if ( !data ) { - this.store.data.update = {}; + this.store.data.create = {}; } return out; @@ -71,7 +73,10 @@ class AdminApproverTypeModel extends BaseModel { let state = this.store.data.update[data]; await this.service.update(data); - if (state.state === 'loaded') this.store.data.update = {} + if ( state && state.state === 'loaded' ) { + this.store.data.update = {} + } + } catch(e) {} const out = this.store.data.update; From bb47c829bb1ff65f4cd3877951394596d0324753 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 16 May 2024 16:22:28 -0400 Subject: [PATCH 064/274] #42 --- deploy/db-entrypoint/001-tables.sql | 18 +- src/lib/db-models/approvalRequest.js | 139 +++++++++ .../db-models/approvalRequestValidations.js | 280 ++++++++++++++++++ src/lib/db-models/employeeAllocation.js | 5 +- src/lib/db-models/expenditureOptions.js | 4 +- src/lib/utils/EntityFields.js | 23 +- src/lib/utils/urlUtils.js | 25 ++ 7 files changed, 473 insertions(+), 21 deletions(-) create mode 100644 src/lib/db-models/approvalRequest.js create mode 100644 src/lib/db-models/approvalRequestValidations.js diff --git a/deploy/db-entrypoint/001-tables.sql b/deploy/db-entrypoint/001-tables.sql index 40da493..91ad56e 100644 --- a/deploy/db-entrypoint/001-tables.sql +++ b/deploy/db-entrypoint/001-tables.sql @@ -104,20 +104,21 @@ CREATE TABLE approval_request ( is_current BOOLEAN NOT NULL DEFAULT TRUE, approval_status VARCHAR(100) NOT NULL, reimbursement_status VARCHAR(100) NOT NULL, - employee VARCHAR(100) REFERENCES employee(kerberos), - name VARCHAR(100) NOT NULL, - organization VARCHAR(100) NOT NULL, - business_purpose VARCHAR(500) NOT NULL, - location VARCHAR(100) NOT NULL, + employee_kerberos VARCHAR(100) REFERENCES employee(kerberos), + label VARCHAR(100), + organization VARCHAR(100), + business_purpose VARCHAR(500), + location VARCHAR(100), location_details VARCHAR(100), - program_start_date DATE NOT NULL, - program_end_date DATE NOT NULL, + program_start_date DATE, + program_end_date DATE, travel_required BOOLEAN NOT NULL DEFAULT FALSE, has_custom_travel_dates BOOLEAN NOT NULL DEFAULT FALSE, travel_start_date DATE, travel_end_date DATE, comments VARCHAR(500), - submitted timestamp DEFAULT NOW() + no_expenditures BOOLEAN NOT NULL DEFAULT FALSE, + submitted_at timestamp DEFAULT NOW() ); COMMENT ON TABLE approval_request IS 'Table for storing travel approval requests.'; COMMENT ON COLUMN approval_request.is_current IS 'Whether or not this is the current revision of the request.'; @@ -127,6 +128,7 @@ CREATE TABLE approval_request_funding_source ( approval_request_revision_id INTEGER REFERENCES approval_request(approval_request_revision_id), funding_source_id INTEGER REFERENCES funding_source(funding_source_id), amount NUMERIC NOT NULL, + description VARCHAR(500), accounting_code VARCHAR(100) ); COMMENT ON TABLE approval_request_funding_source IS 'Mapping table for travel approval requests and funding source amounts.'; diff --git a/src/lib/db-models/approvalRequest.js b/src/lib/db-models/approvalRequest.js new file mode 100644 index 0000000..ee8d016 --- /dev/null +++ b/src/lib/db-models/approvalRequest.js @@ -0,0 +1,139 @@ +import pg from "./pg.js"; +import EntityFields from "../utils/EntityFields.js"; +import validations from "./approvalRequestValidations.js"; + +class ApprovalRequest(){ + + constructor(){ + + this.entityFields = new EntityFields([ + { + dbName: 'approval_request_revision_id', + jsonName: 'approvalRequestRevisionId', + validateType: 'integer' + }, + { + dbName: 'approval_request_id', + jsonName: 'approvalRequestId', + validateType: 'integer' + }, + { + dbName: 'is_current', + jsonName: 'isCurrent', + validateType: 'boolean' + }, + { + dbName: 'approval_status', + jsonName: 'approvalStatus', + required: true, + customValidation: validations.approvalStatus.bind(this) + }, + { + dbName: 'reimbursement_status', + jsonName: 'reimbursementStatus', + required: true, + customValidation: validations.reimbursementStatus.bind(this) + }, + { + dbName: 'employee_kerberos', + jsonName: 'employeeKerberos' + }, + { + dbName: 'employee', + jsonName: 'employee' + }, + { + dbName: 'label', + jsonName: 'label', + charLimit: 100, + customValidation: validations.requireIfNotDraft.bind(this) + }, + { + dbName: 'organization', + jsonName: 'organization' + charLimit: 100, + customValidation: validations.requireIfNotDraft.bind(this) + }, + { + dbName: 'business_purpose', + jsonName: 'businessPurpose', + charLimit: 500, + customValidation: validations.requireIfNotDraft.bind(this) + }, + { + dbName: 'location', + jsonName: 'location', + customValidation: validations.location.bind(this) + }, + { + dbName: 'location_details', + jsonName: 'locationDetails', + charLimit: 100, + customValidation: validations.locationDetails.bind(this) + } + { + dbName: 'program_start_date', + jsonName: 'programStartDate', + validateType: 'date', + customValidation: validations.programDate.bind(this) + }, + { + dbName: 'program_end_date', + jsonName: 'programEndDate', + validateType: 'date', + customValidation: validations.programDate.bind(this) + }, + { + dbName: 'travel_required', + jsonName: 'travelRequired', + validateType: 'boolean' + }, + { + dbName: 'has_custom_travel_dates', + jsonName: 'hasCustomTravelDates', + validateType: 'boolean' + }, + { + dbName: 'travel_start_date', + jsonName: 'travelStartDate', + validateType: 'date', + customValidation: validations.travelDate.bind(this) + }, + { + dbName: 'travel_end_date', + jsonName: 'travelEndDate', + validateType: 'date', + customValidation: validations.travelDate.bind(this) + }, + { + dbName: 'comments', + jsonName: 'comments', + charLimit: 500 + }, + { + dbName: 'submitted_at', + jsonName: 'submittedAt', + }, + { + dbName: 'no_expenditures', + jsonName: 'noExpenditures', + validateType: 'boolean' + }, + { + dbName: 'expenditures', + jsonName: 'expenditures', + validateType: 'array', + customValidationAsync: validations.expenditures.bind(this) + }, + { + dbName: 'funding_sources', + jsonName: 'fundingSources', + validateType: 'array', + customValidationAsync: validations.fundingSources.bind(this) + } + ]); + } + +} + +export default new ApprovalRequest(); diff --git a/src/lib/db-models/approvalRequestValidations.js b/src/lib/db-models/approvalRequestValidations.js new file mode 100644 index 0000000..59117de --- /dev/null +++ b/src/lib/db-models/approvalRequestValidations.js @@ -0,0 +1,280 @@ +import pg from "./pg.js"; + +/** + * @class ApprovalRequestValidations + * @classdesc Methods for validating the fields of the ApprovalRequest model + * should be passed to the customValidation property of the EntityFields field definition + */ +class ApprovalRequestValidations(){ + + /** + * @method approvalStatus + * @description Custom validation for the approvalStatus field + * See EntityFields.validate method for property definitions. + */ + approvalStatus(field, value, out, payload){ + const validStatuses = ['draft', 'submitted', 'in-progress', 'approved', 'canceled', 'denied', 'revision-requested']; + let error = {errorType: 'invalid-value', message: `Invalid approval status: ${value}`}; + if ( !validStatuses.includes(value) ) { + this.entityFields.pushError(out, field, error); + } + } + + /** + * @method reimbursementStatus + * @description Custom validation for the reimbursementStatus field + * See EntityFields.validate method for property definitions. + */ + reimbursementStatus(field, value, out, payload){ + const validStatuses = ['not-required', 'reimbursment-pending', 'partially-reimbursed', 'fully-reimbursed']; + let error = {errorType: 'invalid-value', message: `Invalid reimbursement status: ${value}`}; + if ( !validStatuses.includes(value) ) { + this.entityFields.pushError(out, field, error); + } + } + + /** + * @method requireIfNotDraft + * @description Returns error if value is empty and approvalStatus is not draft + */ + requireIfNotDraft(field, value, out, payload){ + if ( payload.approval_status === 'draft' ) return; + const error = {errorType: 'required', message: 'This field is required.'}; + if ( value === undefined || value === null || value === '') { + this.entityFields.pushError(out, field, error); + } + } + + /** + * @method location + * @description Custom validation for the location field + * See EntityFields.validate method for property definitions. + */ + location(field, value, out, payload){ + if ( payload.approval_status === 'draft' ) return; + let error = {errorType: 'required', message: 'This field is required.'}; + if ( value === undefined || value === null || value === '') { + this.entityFields.pushError(out, field, error); + return; + } + + const validLocations = ['in-state', 'out-of-state', 'foreign', 'virtual']; + error = {errorType: 'invalid-value', message: `Invalid location: ${value}`}; + if ( !validLocations.includes(value) ) { + this.entityFields.pushError(out, field, error); + } + } + + /** + * @method locationDetails + * @description Custom validation for the locationDetails field + * See EntityFields.validate method for property definitions. + */ + locationDetails(field, value, out, payload){ + if ( payload.approval_status === 'draft' ) return; + if ( payload.location === 'virtual' ) return; + const error = {errorType: 'required', message: 'This field is required.'}; + if ( value === undefined || value === null || value === '') { + this.entityFields.pushError(out, field, error); + } + } + + /** + * @method programDate + * @description Custom validation for the programStartDate and programEndDate fields + * See EntityFields.validate method for property definitions. + */ + programDate(field, value, out, payload){ + if ( payload.approval_status === 'draft' ) return; + + // verify field is not empty + let error = {errorType: 'required', message: 'This field is required.'}; + if ( value === undefined || value === null || value === '') { + this.entityFields.pushError(out, field, error); + return; + } + + // verify field doesnt already have an error + if ( this.entityFields.fieldHasError(out, 'programStartDate') || this.entityFields.fieldHasError(out, 'programEndDate') ) return; + + // verify program start date is before or equal to program end date + try { + const startDate = new Date(payload.program_start_date); + const endDate = new Date(payload.program_end_date); + if ( startDate < endDate ) return; + const error = {errorType: 'invalid', message: 'Program start date must be before the end date'}; + if ( field.jsonName === 'programEndDate' ) { + error.message = 'Program end date must be after start date'; + } + this.entityFields.pushError(out, field, error); + } catch () { + // one of the dates is invalid and will be caught by the date validation + } + + } + + /** + * @method travelDate + * @description Custom validation for the travelStartDate and travelEndDate fields + * See EntityFields.validate method for property definitions. + */ + travelDate(field, value, out, payload){ + if ( payload.approval_status === 'draft' ) return; + if ( !payload.travel_required ) return; + + // verify field is not empty + let error = {errorType: 'required', message: 'This field is required.'}; + if ( value === undefined || value === null || value === '') { + this.entityFields.pushError(out, field, error); + return; + } + + // verify field doesnt already have an error + if ( this.entityFields.fieldHasError(out, 'travelStartDate') || this.entityFields.fieldHasError(out, 'travelEndDate') ) return; + + // verify travel start date is before or equal to travel end date + try { + const startDate = new Date(payload.travel_start_date); + const endDate = new Date(payload.travel_end_date); + if ( startDate < endDate ) return; + const error = {errorType: 'invalid', message: 'Travel start date must be before the end date'}; + if ( field.jsonName === 'travelEndDate' ) { + error.message = 'Travel end date must be after start date'; + } + this.entityFields.pushError(out, field, error); + } catch () { + // one of the dates is invalid and will be caught by the date validation + } + } + + /** + * @method expenditures + * @description Custom validation for the expenditures field + * See EntityFields.validate method for property definitions. + */ + async expenditures(field, value, out, payload){ + if ( payload.approval_status === 'draft' ) return; + if ( payload.no_expenditures ) return; + + // verify is array and not empty + error = {errorType: 'required', message: 'This field is required.'}; + if ( !Array.isArray(value) || value.length === 0 ) { + this.entityFields.pushError(out, field, error); + return; + } + + // verify expenditureOptionId is an integer + error = {errorType: 'invalid', message: 'All expenditure option ids must be integers'}; + for (const expenditure of value) { + if ( !Number.isInteger(expenditure.expenditureOptionId) ) { + this.entityFields.pushError(out, field, error); + return; + } + } + + // verify expenditureOptionId exists in the database + const expenditureOptionIds = value.map(expenditure => expenditure.expenditureOptionId); + let query = `SELECT expenditure_option_id FROM expenditure_option WHERE expenditure_option_id = ANY($1)`; + let res = await pg.query(query, [expenditureOptionIds]); + if ( res.error ) { + this.entityFields.pushError(out, field, {errorType: 'database', message: 'Error querying the database for expenditure options'}); + console.error(res.error); + return; + } + if ( res.res.rowCount !== expenditureOptionIds.length ) { + this.entityFields.pushError(out, field, {errorType: 'invalid', message: 'An expenditure option does not exist in the database'}); + return; + } + + // check that amount for each expenditure is a number + error = {errorType: 'invalid', message: 'All expenditure amounts must be numbers'}; + for (const expenditure of value) { + if ( isNaN(Number(expenditure.amount)) ) { + this.entityFields.pushError(out, field, error); + return; + } + } + + // verify that total amount is greater than 0 + const totalAmount = value.reduce((acc, expenditure) => acc + Number(expenditure.amount), 0); + if ( totalAmount <= 0 ) { + this.entityFields.pushError(out, field, {errorType: 'invalid', message: 'The total expenditure amount must be greater than 0'}); + } + } + + /** + * @method fundingSources + * @description Custom validation for the fundingSources field + * See EntityFields.validate method for property definitions. + */ + async fundingSources(field, value, out, payload){ + if ( payload.approval_status === 'draft' ) return; + if ( payload.no_expenditures ) return; + + // verify is array and not empty + error = {errorType: 'required', message: 'This field is required.'}; + if ( !Array.isArray(value) || value.length === 0 ) { + this.entityFields.pushError(out, field, error); + return; + } + + // verify fundingSourceId is an integer + error = {errorType: 'invalid', message: 'All funding source ids must be integers'}; + for (const fundingSource of value) { + if ( !Number.isInteger(fundingSource.fundingSourceId) ) { + this.entityFields.pushError(out, field, error); + return; + } + } + + // verify fundingSourceId exists in the database + const fundingSourceIds = value.map(fundingSource => fundingSource.fundingSourceId); + let query = `SELECT funding_source_id, require_description FROM funding_sources WHERE funding_source_id = ANY($1)`; + let res = await pg.query(query, [fundingSourceIds]); + if ( res.error ) { + this.entityFields.pushError(out, field, {errorType: 'database', message: 'Error querying the database for funding sources'}); + console.error(res.error); + return; + } + if ( res.res.rowCount !== fundingSourceIds.length ) { + this.entityFields.pushError(out, field, {errorType: 'invalid', message: 'A funding source does not exist in the database'}); + return; + } + + // check for description if required by funding source + // and character limit + const fsNeedsDesc = res.res.rows.filter(fundingSource => fundingSource.require_description).map(fundingSource => fundingSource.funding_source_id); + for (const fundingSource of value) { + if ( fsNeedsDesc.includes(fundingSource.fundingSourceId) && !fundingSource.description ) { + this.entityFields.pushError(out, field, {errorType: 'required', message: 'A funding source requires a description'}); + return; + } + + if ( fundingSource.description && fundingSource.description.length > 500 ) { + this.entityFields.pushError(out, field, {errorType: 'charLimit', message: 'Description must be less than 500 characters'}); + return; + } + } + + // check that amount for each funding source is a number + error = {errorType: 'invalid', message: 'All funding source amounts must be numbers'}; + for (const fundingSource of value) { + if ( isNaN(Number(fundingSource.amount)) ) { + this.entityFields.pushError(out, field, error); + return; + } + } + + // check that total amount matches the total amount of the expenditures + const fundingTotal = value.reduce((acc, fundingSource) => acc + Number(fundingSource.amount), 0); + const expenditureTotal = (Array.isArray(payload.expenditures) ? payload.expenditures : []).reduce((acc, expenditure) => acc + Number(expenditure.amount), 0); + if ( fundingTotal !== expenditureTotal ) { + this.entityFields.pushError(out, field, {errorType: 'invalid', message: 'The total funding amount must match the total expenditure amount'}); + } + + + } + +} + +export default new ApprovalRequestValidations(); diff --git a/src/lib/db-models/employeeAllocation.js b/src/lib/db-models/employeeAllocation.js index 3e4d951..28d207b 100644 --- a/src/lib/db-models/employeeAllocation.js +++ b/src/lib/db-models/employeeAllocation.js @@ -87,7 +87,7 @@ class EmployeeAllocation { async create(data, submittedBy={}){ data = this.entityFields.toDbObj(data); - const validation = this.entityFields.validate(data, ['employee_allocation_id']); + const validation = await this.entityFields.validate(data, ['employee_allocation_id']); if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; } @@ -184,14 +184,12 @@ class EmployeeAllocation { validateEmployeeList(field, value, out) { let error = {errorType: 'required', message: 'At least one employee is required'}; if ( !Array.isArray(value) || value.length === 0 ) { - out.valid = false; this.entityFields.pushError(out, field, error); return; } error = {errorType: 'invalid', message: 'Invalid employee object'}; for (const employee of value) { if ( !employee || !employee.kerberos ) { - out.valid = false; this.entityFields.pushError(out, field, error); return; } @@ -207,7 +205,6 @@ class EmployeeAllocation { const startDate = new Date(payload.start_date); const endDate = new Date(payload.end_date); if ( startDate < endDate ) return; - out.valid = false; const error = {errorType: 'invalid', message: 'Start date must be before end date'}; if ( field.jsonName === 'endDate' ) { error.message = 'End date must be after start date'; diff --git a/src/lib/db-models/expenditureOptions.js b/src/lib/db-models/expenditureOptions.js index ebed3a4..93cad1b 100644 --- a/src/lib/db-models/expenditureOptions.js +++ b/src/lib/db-models/expenditureOptions.js @@ -50,7 +50,7 @@ class ExpenditureOptions { */ async create(data){ data = this.entityFields.toDbObj(data); - const validation = this.entityFields.validate(data, ['expenditure_option_id']); + const validation = await this.entityFields.validate(data, ['expenditure_option_id']); if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; } @@ -69,7 +69,7 @@ class ExpenditureOptions { */ async update(data){ data = this.entityFields.toDbObj(data); - const validation = this.entityFields.validate(data); + const validation = await this.entityFields.validate(data); if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; } diff --git a/src/lib/utils/EntityFields.js b/src/lib/utils/EntityFields.js index 3d00231..ac2c95a 100644 --- a/src/lib/utils/EntityFields.js +++ b/src/lib/utils/EntityFields.js @@ -8,6 +8,7 @@ * - required {Boolean} OPTIONAL - if the field is required * - charLimit {Number} OPTIONAL - maximum number of characters allowed * - customValidation {Function} OPTIONAL - custom validation function + * - customValidationAsync {Function} OPTIONAL - custom async validation function */ export default class EntityFields { constructor(fields = []){ @@ -78,7 +79,7 @@ export default class EntityFields { * - errorType {String} - type of error * - message {String} - human readable error message for printing on a form */ - validate(record, skipFields=[], namingScheme='dbName') { + async validate(record, skipFields=[], namingScheme='dbName') { const out = {valid: true, fieldsWithErrors: []}; for (const field of this.fields) { @@ -95,6 +96,10 @@ export default class EntityFields { if ( field.customValidation ) { field.customValidation(field, value, out, record); } + + if ( field.customValidationAsync ) { + await field.customValidationAsync(field, value, out, record); + } } return out; @@ -110,7 +115,6 @@ export default class EntityFields { if ( !field.required ) return; const error = {errorType: 'required', message: 'This field is required'}; if ( value === undefined || value === null || value === '') { - out.valid = false; this.pushError(out, field, error); } } @@ -126,7 +130,6 @@ export default class EntityFields { value = value ? value.toString() : ''; const error = {errorType: 'charLimit', message: `This field must be less than ${field.charLimit} characters`}; if (value && value.length > field.charLimit) { - out.valid = false; this.pushError(out, field, error); } } @@ -142,25 +145,30 @@ export default class EntityFields { if (field.validateType == 'integer' ) { value = value || value === '0' || value === 0 ? value : NaN; if ( !Number.isInteger(Number(value)) ) { - out.valid = false; this.pushError(out, field, error); } } else if (field.validateType == 'date') { // must be valid date in format YYYY-MM-DD value = value.toString(); + if ( !value ) return; if ( !value.match(/^\d{4}-\d{2}-\d{2}$/) ) { - out.valid = false; this.pushError(out, field, error); return; } const date = new Date(value); if ( isNaN(date.getTime()) ) { - out.valid = false; this.pushError(out, field, error); } } else if (field.validateType == 'number') { if ( isNaN(Number(value)) ) { - out.valid = false; + this.pushError(out, field, error); + } + } else if (field.validateType == 'boolean') { + if ( typeof value !== 'boolean' ) { + this.pushError(out, field, error); + } + } else if (field.validateType == 'array') { + if ( !Array.isArray(value) ) { this.pushError(out, field, error); } } @@ -174,6 +182,7 @@ export default class EntityFields { * @param {Object} error - error object with errorType and message properties */ pushError(out, field, error) { + out.valid = false; const fieldInOutput = out.fieldsWithErrors.find(f => f.jsonName === field.jsonName); if ( fieldInOutput ) { fieldInOutput.errors.push(error); diff --git a/src/lib/utils/urlUtils.js b/src/lib/utils/urlUtils.js index ee1537c..6ee875a 100644 --- a/src/lib/utils/urlUtils.js +++ b/src/lib/utils/urlUtils.js @@ -59,6 +59,31 @@ class UrlUtils { return out; } + /** + * @description Convert a query object to camelCase + * @param {Object} q - query object + * @returns {Object} + */ + queryToCamelCase(q){ + const out = {}; + for (const k in q) { + + // convert snake_case to camelCase + let newK = k.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); + + // convert kebab-case to camelCase + newK = newK.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); + + if ( newK !== k ) { + out[newK] = q[k]; + } else { + out[k] = q[k]; + } + + } + return out; + } + /** * @description Sort and comma join an array or single value * @param {Array|String} v - value to sort and join From 9c3da7613dc7c6078444b295769c93233695474a Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 16 May 2024 16:36:21 -0400 Subject: [PATCH 065/274] fixes to #23 cache --- src/lib/cork/models/AdminApproverTypeModel.js | 37 +++++----------- src/lib/cork/stores/AdminApproverTypeStore.js | 2 +- src/lib/db-models/approverType.js | 44 ++++++++----------- 3 files changed, 31 insertions(+), 52 deletions(-) diff --git a/src/lib/cork/models/AdminApproverTypeModel.js b/src/lib/cork/models/AdminApproverTypeModel.js index 53d7952..99f2555 100644 --- a/src/lib/cork/models/AdminApproverTypeModel.js +++ b/src/lib/cork/models/AdminApproverTypeModel.js @@ -20,11 +20,11 @@ class AdminApproverTypeModel extends BaseModel { * id(s) single or array of ids * archived - archive approvers * active - active approvers - * + * */ async query(args = {}) { - let state = this.store.data.query[args]; args = urlUtils.queryStringFromObject(args); + let state = this.store.data.query[args]; try { if( state && state.state === 'loading' ) { @@ -43,24 +43,16 @@ class AdminApproverTypeModel extends BaseModel { */ async create(data) { - // data = this.service.sort(data); try { - let state = this.store.data.create[data];; await this.service.create(data); - - if ( state && state.state === 'loaded' ) { - this.store.data.create = {} - } - } catch(e) {} - const out = this.store.data.create; - - if ( !data ) { - this.store.data.create = {}; + const state = this.store.data.create; + if ( state && state.state === 'loaded' ) { + this.store.data.query = {}; } - return out; + return state; } /** @@ -68,24 +60,17 @@ class AdminApproverTypeModel extends BaseModel { * @param {String} data - data to update for approvers */ async update(data) { - // data = this.service.sort(data); - try { - let state = this.store.data.update[data]; + try { await this.service.update(data); - - if ( state && state.state === 'loaded' ) { - this.store.data.update = {} - } - } catch(e) {} - const out = this.store.data.update; + const state = this.store.data.update; - if ( !data ) { - this.store.data.update = {}; + if ( state && state.state === 'loaded' ) { + this.store.data.query = {}; } - return out; + return state; } } diff --git a/src/lib/cork/stores/AdminApproverTypeStore.js b/src/lib/cork/stores/AdminApproverTypeStore.js index 55387f1..945a8be 100644 --- a/src/lib/cork/stores/AdminApproverTypeStore.js +++ b/src/lib/cork/stores/AdminApproverTypeStore.js @@ -65,7 +65,7 @@ class AdminApproverTypeStore extends BaseStore { } _setCreateState(state, data) { - this.data.create[data] = state; + this.data.create = state; this.emit(this.events.APPROVER_TYPE_CREATED, state); } diff --git a/src/lib/db-models/approverType.js b/src/lib/db-models/approverType.js index 64cb7a4..4014612 100644 --- a/src/lib/db-models/approverType.js +++ b/src/lib/db-models/approverType.js @@ -33,7 +33,7 @@ class AdminApproverType { * status = active: string - if status is "active", return only active (non-archived) approver Types * status = archived: string - if status is "archived", return only archived (non-active) approver Types * @returns {Object|Array} - * + * * all props in approver_type camelcased * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee */ @@ -55,7 +55,7 @@ class AdminApproverType { whereArgs['ap.archived'] = true; } else if (status == "active"){ whereArgs['ap.archived'] = false; - } + } const whereClause = pg.toWhereClause(whereArgs); @@ -71,9 +71,9 @@ class AdminApproverType { FROM employee emp WHERE ate.employee_kerberos = emp.kerberos ) AS employees - FROM + FROM approver_type ap - LEFT JOIN + LEFT JOIN approver_type_employee ate ON ap.approver_type_id = ate.approver_type_id ${whereClause.sql ? `WHERE ${whereClause.sql}` : ''} ` @@ -90,11 +90,11 @@ class AdminApproverType { /** * @description Formats the query results to combine the data based on approverTypeID * @param {Number} data - Query Results - * + * * @returns {Array} Array of Objects for the combined results - * + * */ - async queryFormat(data){ + async queryFormat(data){ const mergedData = data.reduce((acc, item) => { const existingItem = acc.find(element => element.approverTypeId === item.approverTypeId); if (existingItem) { @@ -110,7 +110,7 @@ class AdminApproverType { /** * @description Use data for employees validation * @param {Object} data - Object of Entity fields with camelcase - * + * * @returns {Object} {status: false, message:""} */ async systemValidation(field, value, out, payload){ @@ -132,14 +132,14 @@ class AdminApproverType { /** * @description Use data for kerberos validation * @param {Object} data - Object of Entity fields with camelcase - * + * * @returns {Object} {status: false, message:""} */ async kerberosValidation(field, value, out){ let error = {errorType: 'invalid', message: 'Employee Kerberos Error.'}; const noKerberos = value.every(v => ( v.kerberos && v.kerberos != '' && v.kerberos != undefined)) - + if ( !noKerberos ) { this.entityFields.pushError(out, field, error); return; @@ -149,13 +149,13 @@ class AdminApproverType { /** * @description Create the admin approver type table * @param {Object} data - Object of Entity fields with camelcase - * + * * @returns {Object} {error: false} */ async create(data){ data = this.entityFields.toDbObj(data); - const validation = this.entityFields.validate(data, ['approver_type_id']); + const validation = await this.entityFields.validate(data, ['approver_type_id']); if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; } @@ -184,17 +184,14 @@ class AdminApproverType { await employeeModel.upsertInTransaction(client, a.employee); const toEmployeeCreate = { - 'approver_type_id' : approverTypeId, + 'approver_type_id' : approverTypeId, 'employee_kerberos': a.employee.kerberos }; if ( a.approvalOrder ){ toEmployeeCreate['approval_order'] = index; } - if ( !Object.keys(toEmployeeCreate).length ){ - return pg.returnError('no valid fields to update'); - } - + let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeCreate); const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholders})`; @@ -213,21 +210,21 @@ class AdminApproverType { return out; } - + /** * @description Update the admin approver type table * @param {Object} data - Object of Entity fields with camelcase * use a transaction if changes are needed to the employee list - * + * * @returns {Object} {error: false} */ async update(data){ data = this.entityFields.toDbObj(data); - const validation = this.entityFields.validate(data); + const validation = await this.entityFields.validate(data); if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; - } + } const client = await pg.pool.connect(); let out = {}; @@ -257,16 +254,13 @@ class AdminApproverType { await employeeModel.upsertInTransaction(client, a.employee); const toEmployeeUpdate = { - 'approver_type_id' : approverTypeId, + 'approver_type_id' : approverTypeId, 'employee_kerberos': a.employee.kerberos }; if ( a.approvalOrder ){ toEmployeeUpdate['approval_order'] = index; } - if ( !Object.keys(toEmployeeUpdate).length ){ - return pg.returnError('no valid fields to update'); - } let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeUpdate); From 0dfc4d825e9cfa6870a83668ef0d8e130fd5bd8f Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Thu, 16 May 2024 15:08:22 -0700 Subject: [PATCH 066/274] Update ucdlib-employee-search-basic.tpl.js --- .../ucdlib-employee-search-basic.tpl.js | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js index 8a8b1cd..5f5351e 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -55,23 +55,22 @@ return html` * @returns {TemplateResult} */ function renderResult(result){ - let name = `${result.first_name} ${result.last_name}`.replace(//g, '>'); let department = getDepartment(result); return html` -
${name}
+
${result.first_name} ${result.last_name}
${result.title}, ${department}
`; } - /** - * @description searches for employee department - * @param {Object} result - an Employee object from the database - */ - // if item in results.group has key of type === 'Department', then return the name value of that object - function getDepartment(result){ - if ( result.groups ) { - let department = result.groups.find(group => group.type === 'Department'); - if ( department ) return department.name; - } - return ''; - } \ No newline at end of file +/** + * @description searches for employee department + * @param {Object} result - an Employee object from the database + */ +// if item in results.group has key of type === 'Department', then return the name value of that object +function getDepartment(result){ + if ( result.groups ) { + let department = result.groups.find(group => group.type === 'Department'); + if ( department ) return department.name; + } + return ''; +} \ No newline at end of file From 362d4c0fdc44b4501a21296dd9894b3a4d24e4e8 Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Thu, 16 May 2024 22:22:29 -0700 Subject: [PATCH 067/274] Update ucdlib-employee-search-basic.js --- .../ucdlib-employee-search-basic.js | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index 8c5cc9b..50ddf13 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -24,6 +24,7 @@ import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-el * @param {Object} selectedObject - Full result of search object after a result is selected. * @param {Object} iamresult - Full result of search object after a result is selected. * @param {String} department - sorted department name from the search result object + * @param {String} selectedValue - kerberos id from the search result object */ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) @@ -45,6 +46,22 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) isFocused: {state: true}, selectedText: {state: true}, selectedObject: {state: true}, + selectedValue: {type: String + // , + // async hasChanged(newVal, oldVal) { + // if (newVal !== oldVal) { + // if (newVal !== selectedObject.user_id) { + // try { + // const userObject = await this.EmployeeModel.getIamRecordById(newVal); + // this.selectedObject = userObject; + // } + // catch (e) { + // this.error = true; + // } + // } + // } + // } + } } } @@ -67,6 +84,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.selectedObject = {}; this.iamresult = {}; this.department = ''; + this.selectedValue = ''; this._injectModel('EmployeeModel'); } @@ -95,7 +113,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) * @description Disables the shadowdom * @returns */ - MainDomElement() { + createRenderRoot() { return this; } @@ -167,12 +185,9 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) * @param {Object} result - an Employee object from the database */ async _onSelect(result){ + console.log(result); this.selectedText = `${result.first_name} ${result.last_name}`; - const iamresult = await this.EmployeeModel.getIamRecordById(result.user_id) - this.selectedObject = iamresult.payload; - this.dispatchEvent(new CustomEvent('select', { - detail: iamresult - })); + this.selectedValue = result.user_id; } /** From 7e5a694a917b8b064b8a82aee0c6814408af386d Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Thu, 16 May 2024 23:53:21 -0700 Subject: [PATCH 068/274] initial changes for GUI for review without employee search --- src/api/admin/approverType.js | 1 - src/client/js/components/app-approver-type.js | 36 ++++++++++--- .../cork/services/AdminApproverTypeService.js | 1 + src/lib/db-models/approverType.js | 51 +++++++++++-------- 4 files changed, 60 insertions(+), 29 deletions(-) diff --git a/src/api/admin/approverType.js b/src/api/admin/approverType.js index b106f17..d4d80ff 100644 --- a/src/api/admin/approverType.js +++ b/src/api/admin/approverType.js @@ -22,7 +22,6 @@ export default (api) => { * @description Create an approver-type */ api.post('/approver-type', protect('hasAdminAccess'), async (req, res) => { - const approverTypeData = req.body; const data = await approverType.create(approverTypeData); diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index 17e5675..4dee668 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -89,6 +89,26 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description on submit button get the form data * + * let data = { + "approverTypeId": 0, + "label": "mkl", + "description": "wfe", + "systemGenerated": false, + "hideFromFundAssignment": false, + "archived": false, + "employees":[ + { + "employee":{kerberos: "MaybeF2", firstName:"F", lastName:"G", department:null}, + "approvalOrder": 5 + }, + { + "employee":{kerberos: "MaybeF22", firstName:"G", lastName:"H", department:null}, + "approvalOrder": 5 + } + ] + }; + * + * */ async _onNewSubmit(){ this.newApprover.label = this.label; @@ -113,9 +133,9 @@ export default class AppApproverType extends Mixin(LitElement) * @description on edit button from a approver * */ - async _onEdit(e, approver){ - approver.editing = true; - this.requestUpdate(); + async _onEdit(e, approver){ + approver.editing = true; + this.requestUpdate(); } /** @@ -126,6 +146,7 @@ export default class AppApproverType extends Mixin(LitElement) employeeFormat(approver){ if(approver.employees[0] == null) approver.employees = []; + //Format employees like this to make it work // approver.employees = [{ // employee: { // "kerberos": "EditGuiEmp", @@ -152,7 +173,7 @@ export default class AppApproverType extends Mixin(LitElement) editApprover = this.employeeFormat(editApprover); await this.AdminApproverTypeModel.update(editApprover); - + console.log("ES:",editApprover); this._refreshProperties(); } @@ -203,14 +224,15 @@ export default class AppApproverType extends Mixin(LitElement) * */ async _getApproverType(){ - let args = { id: [2, 5, 10,151, 174, 175, 176, 248, 249, 252, 255, 256]}; + // let args = {status:"active"}; //if want all active do this to see your new ones + let args = { id: [1,2,3,4,5]}; + let approvers = await this.AdminApproverTypeModel.query(args); let approverArray = approvers.payload.filter(function (el) { return el.archived == false && el.hideFromFundAssignment == false; }); - - approverArray.map((emp) => { + approverArray.map((emp) => { if(!Array.isArray(emp.employees)) emp.employees = [emp.employees] }); diff --git a/src/lib/cork/services/AdminApproverTypeService.js b/src/lib/cork/services/AdminApproverTypeService.js index 5c89326..44ed520 100644 --- a/src/lib/cork/services/AdminApproverTypeService.js +++ b/src/lib/cork/services/AdminApproverTypeService.js @@ -35,6 +35,7 @@ class AdminApproverTypeService extends BaseService { } update(data) { + return this.request({ url : `/api/admin/approver-type`, fetchOptions : { diff --git a/src/lib/db-models/approverType.js b/src/lib/db-models/approverType.js index 4014612..e2a1680 100644 --- a/src/lib/db-models/approverType.js +++ b/src/lib/db-models/approverType.js @@ -33,7 +33,7 @@ class AdminApproverType { * status = active: string - if status is "active", return only active (non-archived) approver Types * status = archived: string - if status is "archived", return only archived (non-active) approver Types * @returns {Object|Array} - * + * * all props in approver_type camelcased * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee */ @@ -55,7 +55,7 @@ class AdminApproverType { whereArgs['ap.archived'] = true; } else if (status == "active"){ whereArgs['ap.archived'] = false; - } + } const whereClause = pg.toWhereClause(whereArgs); @@ -71,9 +71,9 @@ class AdminApproverType { FROM employee emp WHERE ate.employee_kerberos = emp.kerberos ) AS employees - FROM + FROM approver_type ap - LEFT JOIN + LEFT JOIN approver_type_employee ate ON ap.approver_type_id = ate.approver_type_id ${whereClause.sql ? `WHERE ${whereClause.sql}` : ''} ` @@ -90,11 +90,11 @@ class AdminApproverType { /** * @description Formats the query results to combine the data based on approverTypeID * @param {Number} data - Query Results - * + * * @returns {Array} Array of Objects for the combined results - * + * */ - async queryFormat(data){ + async queryFormat(data){ const mergedData = data.reduce((acc, item) => { const existingItem = acc.find(element => element.approverTypeId === item.approverTypeId); if (existingItem) { @@ -110,7 +110,7 @@ class AdminApproverType { /** * @description Use data for employees validation * @param {Object} data - Object of Entity fields with camelcase - * + * * @returns {Object} {status: false, message:""} */ async systemValidation(field, value, out, payload){ @@ -132,13 +132,12 @@ class AdminApproverType { /** * @description Use data for kerberos validation * @param {Object} data - Object of Entity fields with camelcase - * + * * @returns {Object} {status: false, message:""} */ async kerberosValidation(field, value, out){ let error = {errorType: 'invalid', message: 'Employee Kerberos Error.'}; - - const noKerberos = value.every(v => ( v.kerberos && v.kerberos != '' && v.kerberos != undefined)) + const noKerberos = value.every(v => ( v.employee.kerberos && v.employee.kerberos != '' && v.employee.kerberos != undefined)); if ( !noKerberos ) { this.entityFields.pushError(out, field, error); @@ -149,16 +148,17 @@ class AdminApproverType { /** * @description Create the admin approver type table * @param {Object} data - Object of Entity fields with camelcase - * + * * @returns {Object} {error: false} */ async create(data){ - data = this.entityFields.toDbObj(data); const validation = await this.entityFields.validate(data, ['approver_type_id']); + if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; } + const client = await pg.pool.connect(); let out = {}; let approverTypeId; @@ -181,17 +181,21 @@ class AdminApproverType { if ( Object.keys(approverEmployee).length ) { for (const [index, a] of approverEmployee.entries()) { + await employeeModel.upsertInTransaction(client, a.employee); const toEmployeeCreate = { - 'approver_type_id' : approverTypeId, + 'approver_type_id' : approverTypeId, 'employee_kerberos': a.employee.kerberos }; if ( a.approvalOrder ){ toEmployeeCreate['approval_order'] = index; } - + if ( !Object.keys(toEmployeeCreate).length ){ + return pg.returnError('no valid fields to update'); + } + let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeCreate); const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholders})`; @@ -205,26 +209,28 @@ class AdminApproverType { } finally { client.release(); } + out.res = await this.query({id: approverTypeId}); out.err = false; return out; } - + /** * @description Update the admin approver type table * @param {Object} data - Object of Entity fields with camelcase * use a transaction if changes are needed to the employee list - * + * * @returns {Object} {error: false} */ async update(data){ - data = this.entityFields.toDbObj(data); + const validation = await this.entityFields.validate(data); + if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; - } + } const client = await pg.pool.connect(); let out = {}; @@ -254,13 +260,16 @@ class AdminApproverType { await employeeModel.upsertInTransaction(client, a.employee); const toEmployeeUpdate = { - 'approver_type_id' : approverTypeId, + 'approver_type_id' : approverTypeId, 'employee_kerberos': a.employee.kerberos }; if ( a.approvalOrder ){ toEmployeeUpdate['approval_order'] = index; } + if ( !Object.keys(toEmployeeUpdate).length ){ + return pg.returnError('no valid fields to update'); + } let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeUpdate); @@ -282,4 +291,4 @@ class AdminApproverType { } } -export default new AdminApproverType(); +export default new AdminApproverType(); \ No newline at end of file From 195efbd4f08a417ac49050333fd8d53a3a85f644 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Fri, 17 May 2024 16:31:13 -0400 Subject: [PATCH 069/274] #42 --- ...ee-allocation.js => employeeAllocation.js} | 0 .../{funding-source.js => fundingSource.js} | 0 src/api/admin/index.js | 10 +- src/api/admin/{line-items.js => lineItems.js} | 0 src/api/approvalRequest.js | 39 ++++ src/api/index.js | 3 + src/lib/db-models/approvalRequest.js | 153 +++++++++++++-- .../db-models/approvalRequestValidations.js | 180 ++++++++++++++---- 8 files changed, 320 insertions(+), 65 deletions(-) rename src/api/admin/{employee-allocation.js => employeeAllocation.js} (100%) rename src/api/admin/{funding-source.js => fundingSource.js} (100%) rename src/api/admin/{line-items.js => lineItems.js} (100%) create mode 100644 src/api/approvalRequest.js diff --git a/src/api/admin/employee-allocation.js b/src/api/admin/employeeAllocation.js similarity index 100% rename from src/api/admin/employee-allocation.js rename to src/api/admin/employeeAllocation.js diff --git a/src/api/admin/funding-source.js b/src/api/admin/fundingSource.js similarity index 100% rename from src/api/admin/funding-source.js rename to src/api/admin/fundingSource.js diff --git a/src/api/admin/index.js b/src/api/admin/index.js index 029a56e..49dafae 100644 --- a/src/api/admin/index.js +++ b/src/api/admin/index.js @@ -1,21 +1,21 @@ import express from 'express'; -import employeeAllocation from './employee-allocation.js'; -import fundingSource from './funding-source.js'; -import lineItems from './line-items.js'; -import settings from './settings.js'; import approverType from './approverType.js'; +import employeeAllocation from './employeeAllocation.js'; +import fundingSource from './fundingSource.js'; +import lineItems from './lineItems.js'; +import settings from './settings.js'; const router = express.Router(); // admin api routes +approverType(router); employeeAllocation(router); fundingSource(router); lineItems(router); settings(router); -approverType(router); export default (app) => { app.use('/admin', router); diff --git a/src/api/admin/line-items.js b/src/api/admin/lineItems.js similarity index 100% rename from src/api/admin/line-items.js rename to src/api/admin/lineItems.js diff --git a/src/api/approvalRequest.js b/src/api/approvalRequest.js new file mode 100644 index 0000000..5b98e59 --- /dev/null +++ b/src/api/approvalRequest.js @@ -0,0 +1,39 @@ +import protect from "../lib/protect.js"; +import approvalRequest from "../lib/db-models/approvalRequest.js"; +import employee from "../lib/db-models/employee.js"; +import IamEmployeeObjectAccessor from "../lib/utils/iamEmployeeObjectAccessor.js"; + +export default (api) => { + + api.post('/approval-request', protect('hasBasicAccess'), async (req, res) => { + const data = req.body || {}; + + // todo: check if is revision of existing request, and validate that the user has permission to do so + + // get full employee object (with department) for logged in user + const kerberos = req.auth.token.id; + let employeeObj = await employee.getIamRecordById(kerberos); + if ( employeeObj.error ) { + console.error('Error getting employee object in POST /approval-request', employeeObj.error); + return res.status(500).json({error: true, message: 'Error creating approval request.'}); + } + employeeObj = (new IamEmployeeObjectAccessor(employeeObj.res)).travelAppObject; + + // set status fields + data.approvalStatus = data.approvalStatus === 'draft' ? 'draft' : 'submitted'; + data.reimbursementStatus = data.noExpenditures ? 'not-required' : 'not-submitted'; + + // create approval request revision + const result = await approvalRequest.createRevision(data, employeeObj); + if ( result.error && result.is400 ) { + return res.status(400).json(result); + } + if ( result.error ) { + console.error('Error in POST /approval-request', result.error); + return res.status(500).json({error: true, message: 'Error creating approval request.'}); + } + + res.json(result); + }); + +}; diff --git a/src/api/index.js b/src/api/index.js index 399ae11..18faf0e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -2,7 +2,9 @@ import express from 'express'; import config from '../lib/serverConfig.js'; import auth from './auth.js'; + import admin from './admin/index.js'; +import approvalRequest from './approvalRequest.js'; import department from './department.js'; import employee from './employee.js'; @@ -14,6 +16,7 @@ if ( config.auth.requireAuth ) { // routes admin(router); +approvalRequest(router); department(router); employee(router); diff --git a/src/lib/db-models/approvalRequest.js b/src/lib/db-models/approvalRequest.js index ee8d016..3023521 100644 --- a/src/lib/db-models/approvalRequest.js +++ b/src/lib/db-models/approvalRequest.js @@ -1,11 +1,14 @@ import pg from "./pg.js"; import EntityFields from "../utils/EntityFields.js"; import validations from "./approvalRequestValidations.js"; +import employeeModel from "./employee.js"; -class ApprovalRequest(){ +class ApprovalRequest { constructor(){ + this.validations = new validations(this); + this.entityFields = new EntityFields([ { dbName: 'approval_request_revision_id', @@ -15,7 +18,8 @@ class ApprovalRequest(){ { dbName: 'approval_request_id', jsonName: 'approvalRequestId', - validateType: 'integer' + validateType: 'integer', + customValidationAsync: this.validations.approvalRequestId.bind(this.validations) }, { dbName: 'is_current', @@ -25,14 +29,13 @@ class ApprovalRequest(){ { dbName: 'approval_status', jsonName: 'approvalStatus', - required: true, - customValidation: validations.approvalStatus.bind(this) + customValidation: this.validations.approvalStatus.bind(this.validations), }, { dbName: 'reimbursement_status', jsonName: 'reimbursementStatus', required: true, - customValidation: validations.reimbursementStatus.bind(this) + customValidation: this.validations.reimbursementStatus.bind(this.validations) }, { dbName: 'employee_kerberos', @@ -40,48 +43,49 @@ class ApprovalRequest(){ }, { dbName: 'employee', - jsonName: 'employee' + jsonName: 'employee', + customValidation: this.validations.employee.bind(this.validations) }, { dbName: 'label', jsonName: 'label', charLimit: 100, - customValidation: validations.requireIfNotDraft.bind(this) + customValidation: this.validations.requireIfNotDraft.bind(this.validations) }, { dbName: 'organization', - jsonName: 'organization' + jsonName: 'organization', charLimit: 100, - customValidation: validations.requireIfNotDraft.bind(this) + customValidation: this.validations.requireIfNotDraft.bind(this.validations) }, { dbName: 'business_purpose', jsonName: 'businessPurpose', charLimit: 500, - customValidation: validations.requireIfNotDraft.bind(this) + customValidation: this.validations.requireIfNotDraft.bind(this.validations) }, { dbName: 'location', jsonName: 'location', - customValidation: validations.location.bind(this) + customValidation: this.validations.location.bind(this.validations) }, { dbName: 'location_details', jsonName: 'locationDetails', charLimit: 100, - customValidation: validations.locationDetails.bind(this) - } + customValidation: this.validations.locationDetails.bind(this.validations) + }, { dbName: 'program_start_date', jsonName: 'programStartDate', validateType: 'date', - customValidation: validations.programDate.bind(this) + customValidation: this.validations.programDate.bind(this.validations) }, { dbName: 'program_end_date', jsonName: 'programEndDate', validateType: 'date', - customValidation: validations.programDate.bind(this) + customValidation: this.validations.programDate.bind(this.validations) }, { dbName: 'travel_required', @@ -97,13 +101,13 @@ class ApprovalRequest(){ dbName: 'travel_start_date', jsonName: 'travelStartDate', validateType: 'date', - customValidation: validations.travelDate.bind(this) + customValidation: this.validations.travelDate.bind(this.validations) }, { dbName: 'travel_end_date', jsonName: 'travelEndDate', validateType: 'date', - customValidation: validations.travelDate.bind(this) + customValidation: this.validations.travelDate.bind(this.validations) }, { dbName: 'comments', @@ -123,17 +127,128 @@ class ApprovalRequest(){ dbName: 'expenditures', jsonName: 'expenditures', validateType: 'array', - customValidationAsync: validations.expenditures.bind(this) + customValidationAsync: this.validations.expenditures.bind(this.validations) }, { dbName: 'funding_sources', jsonName: 'fundingSources', validateType: 'array', - customValidationAsync: validations.fundingSources.bind(this) + customValidationAsync: this.validations.fundingSources.bind(this.validations) + } + ]); + + this.fundingSourceFields = new EntityFields([ + { + dbName: 'approval_request_funding_source_id', + jsonName: 'approvalRequestFundingSourceId' + }, + { + dbName: 'approval_request_revision_id', + jsonName: 'approvalRequestRevisionId' + }, + { + dbName: 'funding_source_id', + jsonName: 'fundingSourceId' + }, + { + dbName: 'amount', + jsonName: 'amount' + }, + { + dbName: 'accounting_code', + jsonName: 'accountingCode' + }, + { + dbName: 'description', + jsonName: 'description' } ]); } + async createRevision(data, submittedBy){ + + // if submittedBy is provided, assign approval request revision to that employee + if ( submittedBy ){ + data.employee = submittedBy; + delete data.employeeKerberos; + } + + data = this.entityFields.toDbObj(data); + + // remove system generated fields + delete data.approval_request_revision_id; + delete data.is_current; + delete data.submitted_at + + // do validation + const validation = await this.entityFields.validate(data, ['employee_allocation_id']); + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + + // extract employee object from data + data.employee_kerberos = data.employee.kerberos || data.employee.kerberos; + const employee = data.employee.kerberos ? {kerberos: data.employee.kerberos} : data.employee; + delete data.employee; + + // start transaction + let out = {}; + let approvalRequestRevisionId; + const fundingSources = data.funding_sources || []; + delete data.funding_sources; + const client = await pg.pool.connect(); + try { + await client.query('BEGIN'); + + // upsert employee and department + await employeeModel.upsertInTransaction(client, employee); + + // mark any previous revisions as not current + if ( data.approval_request_id ){ + const sql = `UPDATE approval_request SET is_current = false WHERE approval_request_id = $1`; + await client.query(sql, [data.approval_request_id]); + } + + // insert approval request revision + data = pg.prepareObjectForInsert(data); + const sql = `INSERT INTO approval_request (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING approval_request_revision_id`; + const res = await client.query(sql, data.values); + approvalRequestRevisionId = res.rows[0].approval_request_revision_id; + + // insert funding sources + if ( !data.no_expenditures ){ + for (let fs of fundingSources){ + fs.approvalRequestRevisionId = approvalRequestRevisionId; + fs = this.fundingSourceFields.toDbObj(fs); + fs = pg.prepareObjectForInsert(fs); + const sql = `INSERT INTO approval_request_funding_source (${fs.keysString}) VALUES (${fs.placeholdersString})`; + await client.query(sql, fs.values); + } + } + + // insert expenditures + if ( !data.no_expenditures ){ + // todo + } + + + await client.query('COMMIT'); + + } catch (e) { + await client.query('ROLLBACK'); + out = {error: e}; + } finally { + client.release(); + } + + if ( out.error ) return out; + + out = approvalRequestRevisionId; + + return out; + + } + } export default new ApprovalRequest(); diff --git a/src/lib/db-models/approvalRequestValidations.js b/src/lib/db-models/approvalRequestValidations.js index 59117de..617a969 100644 --- a/src/lib/db-models/approvalRequestValidations.js +++ b/src/lib/db-models/approvalRequestValidations.js @@ -4,8 +4,31 @@ import pg from "./pg.js"; * @class ApprovalRequestValidations * @classdesc Methods for validating the fields of the ApprovalRequest model * should be passed to the customValidation property of the EntityFields field definition + * @property {Object} model - the ApprovalRequest model object */ -class ApprovalRequestValidations(){ +export default class ApprovalRequestValidations { + + constructor(model){ + this.model = model; + + this.validApprovalStatuses = [ + {value: 'draft', label: 'Draft'}, + {value: 'submitted', label: 'Submitted'}, + {value: 'in-progress', label: 'In Progress'}, + {value: 'approved', label: 'Approved'}, + {value: 'canceled', label: 'Canceled'}, + {value: 'denied', label: 'Denied'}, + {value: 'revision-requested', label: 'Revision Requested'} + ]; + + this.validReimbursementStatuses = [ + {value: 'not-required', label: 'Not Required'}, + {value: 'not-submitted', label: 'Not Submitted'}, + {value: 'reimbursment-pending', label: 'Reimbursement Pending'}, + {value: 'partially-reimbursed', label: 'Partially Reimbursed'}, + {value: 'fully-reimbursed', label: 'Fully Reimbursed'} + ]; + } /** * @method approvalStatus @@ -13,10 +36,18 @@ class ApprovalRequestValidations(){ * See EntityFields.validate method for property definitions. */ approvalStatus(field, value, out, payload){ - const validStatuses = ['draft', 'submitted', 'in-progress', 'approved', 'canceled', 'denied', 'revision-requested']; - let error = {errorType: 'invalid-value', message: `Invalid approval status: ${value}`}; + let error; + + if ( value === undefined || value === null || value === '') { + error = {errorType: 'required', message: 'This field is required.'}; + this.model.entityFields.pushError(out, field, error); + return; + } + + const validStatuses = this.validApprovalStatuses.map(status => status.value); + error = {errorType: 'invalid-value', message: `Invalid approval status: ${value}`}; if ( !validStatuses.includes(value) ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); } } @@ -26,10 +57,18 @@ class ApprovalRequestValidations(){ * See EntityFields.validate method for property definitions. */ reimbursementStatus(field, value, out, payload){ - const validStatuses = ['not-required', 'reimbursment-pending', 'partially-reimbursed', 'fully-reimbursed']; - let error = {errorType: 'invalid-value', message: `Invalid reimbursement status: ${value}`}; + let error; + + if ( value === undefined || value === null || value === '') { + error = {errorType: 'required', message: 'This field is required.'}; + this.model.entityFields.pushError(out, field, error); + return; + } + + const validStatuses = this.validReimbursementStatuses.map(status => status.value); + error = {errorType: 'invalid-value', message: `Invalid reimbursement status: ${value}`}; if ( !validStatuses.includes(value) ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); } } @@ -38,10 +77,12 @@ class ApprovalRequestValidations(){ * @description Returns error if value is empty and approvalStatus is not draft */ requireIfNotDraft(field, value, out, payload){ + let error; + if ( payload.approval_status === 'draft' ) return; - const error = {errorType: 'required', message: 'This field is required.'}; + error = {errorType: 'required', message: 'This field is required.'}; if ( value === undefined || value === null || value === '') { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); } } @@ -51,17 +92,20 @@ class ApprovalRequestValidations(){ * See EntityFields.validate method for property definitions. */ location(field, value, out, payload){ + let error; + if ( payload.approval_status === 'draft' ) return; - let error = {errorType: 'required', message: 'This field is required.'}; + + error = {errorType: 'required', message: 'This field is required.'}; if ( value === undefined || value === null || value === '') { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } const validLocations = ['in-state', 'out-of-state', 'foreign', 'virtual']; error = {errorType: 'invalid-value', message: `Invalid location: ${value}`}; if ( !validLocations.includes(value) ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); } } @@ -71,11 +115,13 @@ class ApprovalRequestValidations(){ * See EntityFields.validate method for property definitions. */ locationDetails(field, value, out, payload){ + let error; if ( payload.approval_status === 'draft' ) return; if ( payload.location === 'virtual' ) return; - const error = {errorType: 'required', message: 'This field is required.'}; + + error = {errorType: 'required', message: 'This field is required.'}; if ( value === undefined || value === null || value === '') { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); } } @@ -85,17 +131,19 @@ class ApprovalRequestValidations(){ * See EntityFields.validate method for property definitions. */ programDate(field, value, out, payload){ + let error; + if ( payload.approval_status === 'draft' ) return; // verify field is not empty - let error = {errorType: 'required', message: 'This field is required.'}; + error = {errorType: 'required', message: 'This field is required.'}; if ( value === undefined || value === null || value === '') { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } // verify field doesnt already have an error - if ( this.entityFields.fieldHasError(out, 'programStartDate') || this.entityFields.fieldHasError(out, 'programEndDate') ) return; + if ( this.model.entityFields.fieldHasError(out, 'programStartDate') || this.model.entityFields.fieldHasError(out, 'programEndDate') ) return; // verify program start date is before or equal to program end date try { @@ -106,11 +154,10 @@ class ApprovalRequestValidations(){ if ( field.jsonName === 'programEndDate' ) { error.message = 'Program end date must be after start date'; } - this.entityFields.pushError(out, field, error); - } catch () { + this.model.entityFields.pushError(out, field, error); + } catch (e) { // one of the dates is invalid and will be caught by the date validation } - } /** @@ -119,18 +166,20 @@ class ApprovalRequestValidations(){ * See EntityFields.validate method for property definitions. */ travelDate(field, value, out, payload){ + let error; + if ( payload.approval_status === 'draft' ) return; if ( !payload.travel_required ) return; // verify field is not empty - let error = {errorType: 'required', message: 'This field is required.'}; + error = {errorType: 'required', message: 'This field is required.'}; if ( value === undefined || value === null || value === '') { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } // verify field doesnt already have an error - if ( this.entityFields.fieldHasError(out, 'travelStartDate') || this.entityFields.fieldHasError(out, 'travelEndDate') ) return; + if ( this.model.entityFields.fieldHasError(out, 'travelStartDate') || this.model.entityFields.fieldHasError(out, 'travelEndDate') ) return; // verify travel start date is before or equal to travel end date try { @@ -141,8 +190,8 @@ class ApprovalRequestValidations(){ if ( field.jsonName === 'travelEndDate' ) { error.message = 'Travel end date must be after start date'; } - this.entityFields.pushError(out, field, error); - } catch () { + this.model.entityFields.pushError(out, field, error); + } catch (e) { // one of the dates is invalid and will be caught by the date validation } } @@ -153,13 +202,15 @@ class ApprovalRequestValidations(){ * See EntityFields.validate method for property definitions. */ async expenditures(field, value, out, payload){ + let error; + if ( payload.approval_status === 'draft' ) return; if ( payload.no_expenditures ) return; // verify is array and not empty error = {errorType: 'required', message: 'This field is required.'}; if ( !Array.isArray(value) || value.length === 0 ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } @@ -167,7 +218,7 @@ class ApprovalRequestValidations(){ error = {errorType: 'invalid', message: 'All expenditure option ids must be integers'}; for (const expenditure of value) { if ( !Number.isInteger(expenditure.expenditureOptionId) ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } } @@ -177,12 +228,12 @@ class ApprovalRequestValidations(){ let query = `SELECT expenditure_option_id FROM expenditure_option WHERE expenditure_option_id = ANY($1)`; let res = await pg.query(query, [expenditureOptionIds]); if ( res.error ) { - this.entityFields.pushError(out, field, {errorType: 'database', message: 'Error querying the database for expenditure options'}); + this.model.entityFields.pushError(out, field, {errorType: 'database', message: 'Error querying the database for expenditure options'}); console.error(res.error); return; } if ( res.res.rowCount !== expenditureOptionIds.length ) { - this.entityFields.pushError(out, field, {errorType: 'invalid', message: 'An expenditure option does not exist in the database'}); + this.model.entityFields.pushError(out, field, {errorType: 'invalid', message: 'An expenditure option does not exist in the database'}); return; } @@ -190,7 +241,7 @@ class ApprovalRequestValidations(){ error = {errorType: 'invalid', message: 'All expenditure amounts must be numbers'}; for (const expenditure of value) { if ( isNaN(Number(expenditure.amount)) ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } } @@ -198,7 +249,7 @@ class ApprovalRequestValidations(){ // verify that total amount is greater than 0 const totalAmount = value.reduce((acc, expenditure) => acc + Number(expenditure.amount), 0); if ( totalAmount <= 0 ) { - this.entityFields.pushError(out, field, {errorType: 'invalid', message: 'The total expenditure amount must be greater than 0'}); + this.model.entityFields.pushError(out, field, {errorType: 'invalid', message: 'The total expenditure amount must be greater than 0'}); } } @@ -210,11 +261,12 @@ class ApprovalRequestValidations(){ async fundingSources(field, value, out, payload){ if ( payload.approval_status === 'draft' ) return; if ( payload.no_expenditures ) return; + let error; // verify is array and not empty error = {errorType: 'required', message: 'This field is required.'}; if ( !Array.isArray(value) || value.length === 0 ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } @@ -222,7 +274,7 @@ class ApprovalRequestValidations(){ error = {errorType: 'invalid', message: 'All funding source ids must be integers'}; for (const fundingSource of value) { if ( !Number.isInteger(fundingSource.fundingSourceId) ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } } @@ -232,12 +284,12 @@ class ApprovalRequestValidations(){ let query = `SELECT funding_source_id, require_description FROM funding_sources WHERE funding_source_id = ANY($1)`; let res = await pg.query(query, [fundingSourceIds]); if ( res.error ) { - this.entityFields.pushError(out, field, {errorType: 'database', message: 'Error querying the database for funding sources'}); + this.model.entityFields.pushError(out, field, {errorType: 'database', message: 'Error querying the database for funding sources'}); console.error(res.error); return; } if ( res.res.rowCount !== fundingSourceIds.length ) { - this.entityFields.pushError(out, field, {errorType: 'invalid', message: 'A funding source does not exist in the database'}); + this.model.entityFields.pushError(out, field, {errorType: 'invalid', message: 'A funding source does not exist in the database'}); return; } @@ -246,12 +298,12 @@ class ApprovalRequestValidations(){ const fsNeedsDesc = res.res.rows.filter(fundingSource => fundingSource.require_description).map(fundingSource => fundingSource.funding_source_id); for (const fundingSource of value) { if ( fsNeedsDesc.includes(fundingSource.fundingSourceId) && !fundingSource.description ) { - this.entityFields.pushError(out, field, {errorType: 'required', message: 'A funding source requires a description'}); + this.model.entityFields.pushError(out, field, {errorType: 'required', message: 'A funding source requires a description'}); return; } if ( fundingSource.description && fundingSource.description.length > 500 ) { - this.entityFields.pushError(out, field, {errorType: 'charLimit', message: 'Description must be less than 500 characters'}); + this.model.entityFields.pushError(out, field, {errorType: 'charLimit', message: 'Description must be less than 500 characters'}); return; } } @@ -260,7 +312,7 @@ class ApprovalRequestValidations(){ error = {errorType: 'invalid', message: 'All funding source amounts must be numbers'}; for (const fundingSource of value) { if ( isNaN(Number(fundingSource.amount)) ) { - this.entityFields.pushError(out, field, error); + this.model.entityFields.pushError(out, field, error); return; } } @@ -269,12 +321,58 @@ class ApprovalRequestValidations(){ const fundingTotal = value.reduce((acc, fundingSource) => acc + Number(fundingSource.amount), 0); const expenditureTotal = (Array.isArray(payload.expenditures) ? payload.expenditures : []).reduce((acc, expenditure) => acc + Number(expenditure.amount), 0); if ( fundingTotal !== expenditureTotal ) { - this.entityFields.pushError(out, field, {errorType: 'invalid', message: 'The total funding amount must match the total expenditure amount'}); + this.model.entityFields.pushError(out, field, {errorType: 'invalid', message: 'The total funding amount must match the total expenditure amount'}); } + } + + /** + * @method approvalRequestId + * @description Custom validation for the approvalRequestId field + * See EntityFields.validate method for property definitions. + */ + async approvalRequestId(field, value, out, payload){ + let error; + + if ( !value ) return; + + // this is a revision, check that the approvalRequestId is an integer and exists in the database + error = {errorType: 'invalid', message: 'Invalid approval request id'}; + if ( !Number.isInteger(value) ) { + this.model.entityFields.pushError(out, field, error); + return; + } + + let query = `SELECT approval_request_id, approval_status FROM approval_request WHERE approval_request_id = $1`; + let res = await pg.query(query, [value]); + if ( res.error ) { + this.model.entityFields.pushError(out, field, {errorType: 'database', message: 'Error querying the database for approval request'}); + console.error(res.error); + return; + } + if ( !res.res.rowCount ) { + this.model.entityFields.pushError(out, field, error); + } + + error = {errorType: 'invalid', message: "Approval request must be in 'draft' or 'revision-requested' status to be revised"}; + if ( !['draft', 'revision-requested'].includes(res.res.rows[0].approval_status) ) { + this.model.entityFields.pushError(out, field, error); + } + } + /** + * @method employee + * @description Custom validation for the employee field + * See EntityFields.validate method for property definitions. + */ + employee(field, value, out, payload){ + let error; + if ( typeof employee_kerberos === 'string' && employee_kerberos.length > 0 ) return; + if ( !value?.kerberos ) { + error = {errorType: 'required', message: 'This field is required.'}; + this.model.entityFields.pushError(out, field, error); + return; + } } } - -export default new ApprovalRequestValidations(); From 2a99c77748e92d1b9c82a3a1bd7885994a573524 Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Sun, 19 May 2024 19:23:09 -0700 Subject: [PATCH 070/274] Update ucdlib-employee-search-basic.js --- .../ucdlib-employee-search-basic.js | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index 50ddf13..f5a076d 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -46,24 +46,9 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) isFocused: {state: true}, selectedText: {state: true}, selectedObject: {state: true}, - selectedValue: {type: String - // , - // async hasChanged(newVal, oldVal) { - // if (newVal !== oldVal) { - // if (newVal !== selectedObject.user_id) { - // try { - // const userObject = await this.EmployeeModel.getIamRecordById(newVal); - // this.selectedObject = userObject; - // } - // catch (e) { - // this.error = true; - // } - // } - // } - // } + selectedValue: {type: String} } } - } constructor() { super(); @@ -116,6 +101,21 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) createRenderRoot() { return this; } + + /** + * @description Listens for changes in the selectedValue property. If the property changes, the selectedObject is updated. + */ + async shouldUpdate(changedProperties) { + if (changedProperties.has('selectedValue')) { + try { + let iamobject = await this.EmployeeModel.getIamRecordById(this.selectedValue); + this.selectedObject = iamobject.payload; + } + catch (e) { + this.error = true; + } + } + } /** * @description Searches for employees by name. Fires when query property changes. @@ -185,7 +185,6 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) * @param {Object} result - an Employee object from the database */ async _onSelect(result){ - console.log(result); this.selectedText = `${result.first_name} ${result.last_name}`; this.selectedValue = result.user_id; } From d0a7b6adcfe9669ac3c577676c59db48aa4e0aa5 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Mon, 20 May 2024 10:22:17 -0700 Subject: [PATCH 071/274] wiring up error push --- src/client/js/components/app-approver-type.js | 145 +++++++++++------- .../js/components/app-approver-type.tpl.js | 96 ++++++------ 2 files changed, 146 insertions(+), 95 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index 4dee668..6a2d1ae 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -11,6 +11,8 @@ export default class AppApproverType extends Mixin(LitElement) static get properties() { return { existingApprovers:{type: Array, attribute: 'existingApprovers'}, + newApproverType:{type: Object, attribute: 'newApproverType'}, + } } @@ -20,9 +22,8 @@ export default class AppApproverType extends Mixin(LitElement) constructor() { super(); - this.systemGenerated = true; this.existingApprovers = []; - + this.newApproverType = {}; this.render = render.bind(this); this._injectModel('AppStateModel', 'AdminApproverTypeModel'); @@ -30,19 +31,39 @@ export default class AppApproverType extends Mixin(LitElement) } - connectedCallback() { + + /** + * @description lit lifecycle method + */ + willUpdate(changedProps) { + if ( changedProps.has('newApproverType') ) { + this.requestUpdate(); + } + } + + /** + * @description bound to AppStateModel app-state-update event + * @param {Object} state - AppStateModel state + */ + async _onAppStateUpdate(state) { + if ( state.page != 'admin-approvers' ) return; + this.AppStateModel.showLoading(); + + this.AppStateModel.showLoaded('admin-approvers'); this._getApproverType(); - super.connectedCallback() + + this.requestUpdate(); } + /** * @description runs the refresh properties after edit/create/delete function runs * */ async _refreshProperties(){ - this._getApproverType(); + // this._getApproverType(); this._resetProperties(); - this.requestUpdate(); + this.AppStateModel.refresh(); } /** @@ -53,10 +74,10 @@ export default class AppApproverType extends Mixin(LitElement) this.label = ""; this.description = ""; this.employees = []; - this.newApprover = { + this.newApproverType = { approverTypeId: 0, - label: {}, - description: {}, + label: "", + description: "", systemGenerated:false, hideFromFundAssignment:false, archived: false, @@ -70,7 +91,7 @@ export default class AppApproverType extends Mixin(LitElement) */ // _onBasicEmployeesFetched(e){ // if ( e.state !== 'loaded' ) return; - // this.newApprover = e.payload.map(at => { + // this.newApproverType = e.payload.map(at => { // at = {...at}; // at.editing = false; // // at.validationHandler = new ValidationHandler(); @@ -78,12 +99,12 @@ export default class AppApproverType extends Mixin(LitElement) // }); // } - async _setLabel(value){ - this.label = value; + async _setLabel(value, approver){ + approver.label = value; } - async _setDescription(value){ - this.description = value; + async _setDescription(value, approver){ + approver.description = value; } /** @@ -107,23 +128,37 @@ export default class AppApproverType extends Mixin(LitElement) } ] }; + * * */ - async _onNewSubmit(){ - this.newApprover.label = this.label; - this.newApprover.description = this.description; - - // this.newApprover.employees = this.employees; + async _onNewSubmit(e){ + e.preventDefault(); + this.lastScrollPosition = window.scrollY; + + const approverTypeId = e.target.getAttribute('approver-type-id'); + if ( approverTypeId != 0 && approverTypeId) { + let approverType = this.existingApprovers.find(a => a.approverTypeId == approverTypeId); + delete approverType.editing; + approverType = this.employeeFormat(approverType); + console.log(approverType); + + await this.AdminApproverTypeModel.update(approverType); + } else { + this.newApproverType = this.employeeFormat(this.newApproverType); + await this.AdminApproverTypeModel.create(this.newApproverType); + - this.newApprover = this.employeeFormat(this.newApprover); + console.log("Done Creating..."); - document.querySelector(".inputLabel").value = ""; - document.querySelector(".textDescription").value = ""; + } - await this.AdminApproverTypeModel.create(this.newApprover); + //this will be deleted + // document.querySelector(".inputLabel").value = ""; + // document.querySelector(".textDescription").value = ""; this._refreshProperties(); + this.requestUpdate(); @@ -138,50 +173,56 @@ export default class AppApproverType extends Mixin(LitElement) this.requestUpdate(); } + + + + + + + /** * @description on edit button from a approver * @returns {Array} array of objects with updated employees * */ employeeFormat(approver){ - if(approver.employees[0] == null) approver.employees = []; - - //Format employees like this to make it work - // approver.employees = [{ - // employee: { - // "kerberos": "EditGuiEmp", - // "firstName": "emp1", - // "lastName": "emp2", - // "department": null - // }, - // approvalOrder: 5 - // }]; - // approver.employees = this.employees; + let employeeFormat = []; + + if(approver.employees[0] == null) { + approver.employees = []; + return approver; + } + + for (let [index, a] of approver.employees.entries()){ + let samp = {employee:a, approvalOrder: index} + employeeFormat.push(samp); + } + approver.employees = employeeFormat; return approver; } - /** - * @description on edit Save button from a approver - * - */ - async _onEditSave(e, approver){ - let editApprover = approver; - editApprover.editing = false; - editApprover.label = this.label; - editApprover.description = this.description; - - editApprover = this.employeeFormat(editApprover); + // /** + // * @description on edit Save button from a approver + // * + // */ + // async _onEditSave(e, editApprover){ + // editApprover.editing = false; + // editApprover = this.employeeFormat(editApprover); - await this.AdminApproverTypeModel.update(editApprover); - console.log("ES:",editApprover); - this._refreshProperties(); - } + // await this.AdminApproverTypeModel.update(editApprover); + // this._refreshProperties(); + // } /** * @description on edit Cancel button from a approver * */ async _onEditCancel(e, approver){ + if (!approver.approverTypeId) { + newApproverType = {}; + return; + } + approver.editing = false; this.requestUpdate(); } @@ -225,7 +266,7 @@ export default class AppApproverType extends Mixin(LitElement) */ async _getApproverType(){ // let args = {status:"active"}; //if want all active do this to see your new ones - let args = { id: [1,2,3,4,5]}; + let args = { id: [1,2,3,4,5,117,118]}; let approvers = await this.AdminApproverTypeModel.query(args); let approverArray = approvers.payload.filter(function (el) { diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index 8fdbacd..8595b84 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -15,52 +15,55 @@ export function styles() { export function render() { return html` -
-

Approvers

- -
-

When a request is submitted to this application, approval is required from a list - of employees determined by the funding source. Employees must be registered as an - approver before they can be added to the approval chain of a funding source. Some - approver types are automatically generated by this system and cannot be removed - in this section. -

-
- -
- ${this.existingApprovers.map((approver) => { - if(approver.editing) return renderApproverForm.call(this, approver); - return renderApproverItem.call(this, approver); - })} -
+
+

Approvers

+ +
+

When a request is submitted to this application, approval is required from a list + of employees determined by the funding source. Employees must be registered as an + approver before they can be added to the approval chain of a funding source. Some + approver types are automatically generated by this system and cannot be removed + in this section. +

+
- ${renderApproverForm.call(this, this.newApprover)} - -

- - -
+
+ ${this.existingApprovers.map((approver) => { + if(approver.editing) return renderApproverForm.call(this, approver); + return renderApproverItem.call(this, approver); + })} +
+ ${renderApproverForm.call(this, this.newApproverType)} + +
`;} -function renderApproverItem(approver) { - let ap = approver; - const approverId = approver.approverTypeId; - const itemIdLabel = `item-label-${approverId}`; - const itemIdDescription = `item-description-${approverId}`; - const itemIdEmployees = `item-employee-${approverId}`; +function renderApproverItem(ap) { + const approverId = ap.approverTypeId; + const itemIdLabel = `approver-type-label-${approverId}`; + const itemIdDescription = `approver-type-description-${approverId}`; + const itemIdEmployees = `approver-type-employee-${approverId}`; return html` -
+
-

${ap.label} - this._onEdit(e, ap)} class="user-icon"> - ${!ap.systemGenerated ? html` this._onDelete(ap)} class="trash-icon">`:html``} -

+

${ap.label}

+
this._onEdit(e, ap)} class='icon-link admin-blue'> + + + + ${!ap.systemGenerated ? html` + this._onDelete(ap)} class='icon-link double-decker'> + + + `:html``} + + - ${ap.description}
+
${ap.description}
${ap.systemGenerated || ap.employees[0] != null ? html`
@@ -70,30 +73,30 @@ function renderApproverItem(approver) { `)}
- - `:html``} - + `:html``}
` } function renderApproverForm(approver) { - // if ( !approver || Object.keys(approver).length === 0 ) return html``; + if ( !approver || Object.keys(approver).length === 0 ) return html``; const approverId = approver.approverTypeId || 'new'; + const inputIdLabel = `approver-label-${approverId}`; const inputIdDescription = `approver-description-${approverId}`; return html` +

Edit Approver
- this._setLabel(e.target.value)} type="text" placeholder="Position Title"> + this._setLabel(e.target.value, approver)} type="text" placeholder="Position Title">
- +
@@ -102,12 +105,19 @@ function renderApproverForm(approver) { ${approver.editing ? html`

- +

`:html``} + + ${approverId == 'new' ? + html`

` + :html`` + }

+
+ ` } \ No newline at end of file From f523bb98331d3598b3cd1ef1355556818b266bff Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Mon, 20 May 2024 14:51:59 -0400 Subject: [PATCH 072/274] #42 --- src/lib/db-models/approvalRequest.js | 186 +++++++++++++++++++++++- src/lib/db-models/employeeAllocation.js | 2 +- src/lib/utils/apiUtils.js | 5 +- 3 files changed, 184 insertions(+), 9 deletions(-) diff --git a/src/lib/db-models/approvalRequest.js b/src/lib/db-models/approvalRequest.js index 3023521..fb8a425 100644 --- a/src/lib/db-models/approvalRequest.js +++ b/src/lib/db-models/approvalRequest.js @@ -163,6 +163,169 @@ class ApprovalRequest { jsonName: 'description' } ]); + + this.expenditureFields = new EntityFields([ + { + dbName: 'approval_request_expenditure_id', + jsonName: 'approvalRequestExpenditureId' + }, + { + dbName: 'approval_request_revision_id', + jsonName: 'approvalRequestRevisionId' + }, + { + dbName: 'expenditure_option_id', + jsonName: 'expenditureOptionId' + }, + { + dbName: 'amount', + jsonName: 'amount' + } + ]); + } + + /** + * @description Get an array of approval request revisions + * @param {Object} kwargs - query parameters including: + * - revisionIds {Array} OPTIONAL - array of approval request revision ids + * - requestIds {Array} OPTIONAL - array of approval request ids + * - isCurrent {Boolean} OPTIONAL - whether to get only the current revision + * - isNotCurrent {Boolean} OPTIONAL - whether to get only revisions that are not current + * - employees {Array} OPTIONAL - array of employee kerberos + * - page {Integer} OPTIONAL - page number + * - pageSize {Integer} OPTIONAL - number of records per page + */ + async get(kwargs={}){ + + // pagination + const page = Number(kwargs.page) || 1; + const pageSize = Number(kwargs.pageSize) || 10; + const noPaging = pageSize === -1; + + // construct where clause conditions for query + const whereArgs = {}; + + if ( Array.isArray(kwargs.revisionIds) && kwargs.revisionIds.length ){ + whereArgs['ar.approval_request_revision_id'] = kwargs.revisionIds; + } + + if ( Array.isArray(kwargs.requestIds) && kwargs.requestIds.length ){ + whereArgs['ar.approval_request_id'] = kwargs.requestIds; + } + + if ( kwargs.isCurrent ){ + whereArgs['ar.is_current'] = true; + } else if ( kwargs.isNotCurrent ){ + whereArgs['ar.is_current'] = false; + } + + if ( Array.isArray(kwargs.employees) && kwargs.employees.length ){ + whereArgs['ar.employee_kerberos'] = kwargs.employees; + } + const whereClause = pg.toWhereClause(whereArgs); + + const countQuery = ` + SELECT + COUNT(*) as total + FROM + approval_request ar + ${whereClause.sql ? `WHERE ${whereClause.sql}` : ''} + `; + const countRes = await pg.query(countQuery, whereClause.values); + if( countRes.error ) return countRes; + const total = Number(countRes.res.rows[0].total); + + const query = ` + WITH funding_sources AS ( + SELECT + arfs.approval_request_revision_id, + json_agg( + json_build_object( + 'approvalRequestFundingSourceId', arfs.approval_request_funding_source_id, + 'fundingSourceId', arfs.funding_source_id, + 'fundingSourceLabel', fs.label, + 'amount', arfs.amount, + 'accountingCode', arfs.accounting_code, + 'description', arfs.description + ) + ) AS funding_sources + FROM + approval_request_funding_source arfs + LEFT JOIN + funding_source fs ON arfs.funding_source_id = fs.funding_source_id + GROUP BY + arfs.approval_request_revision_id + ), + expenditures AS ( + SELECT + are.approval_request_revision_id, + json_agg( + json_build_object( + 'approvalRequestExpenditureId', are.approval_request_expenditure_id, + 'expenditureOptionId', are.expenditure_option_id, + 'expenditureOptionLabel', eo.label, + 'amount', are.amount + ) + ) AS expenditures + FROM + approval_request_expenditure are + LEFT JOIN + expenditure_option eo ON are.expenditure_option_id = eo.expenditure_option_id + GROUP BY + are.approval_request_revision_id + ) + SELECT + ar.*, + json_build_object( + 'kerberos', e.kerberos, + 'firstName', e.first_name, + 'lastName', e.last_name + ) AS employee, + COALESCE(fs.funding_sources, '[]'::json) AS funding_sources, + COALESCE(ex.expenditures, '[]'::json) AS expenditures + FROM + approval_request ar + LEFT JOIN + employee e ON ar.employee_kerberos = e.kerberos + LEFT JOIN + funding_sources fs ON ar.approval_request_revision_id = fs.approval_request_revision_id + LEFT JOIN + expenditures ex ON ar.approval_request_revision_id = ex.approval_request_revision_id + ${whereClause.sql ? `WHERE ${whereClause.sql}` : ''} + ORDER BY + ar.submitted_at DESC + ${noPaging ? '' : `LIMIT ${pageSize} OFFSET ${(page-1)*pageSize}`} + `; + + const res = await pg.query(query, whereClause.values); + if( res.error ) return res; + const data = this._prepareResults(res.res.rows); + const totalPages = noPaging ? 1 : Math.ceil(total / pageSize); + return {data, total, page, pageSize, totalPages}; + + } + + _prepareResults(results){ + results = this.entityFields.toJsonArray(results); + for (const result of results){ + + // ensure array fields are arrays + const arrayFields = ['fundingSources', 'expenditures']; + for (const field of arrayFields){ + if ( !result[field] ){ + result[field] = []; + } + } + + // ensure date fields are YYYY-MM-DD + const dateFields = ['programStartDate', 'programEndDate', 'travelStartDate', 'travelEndDate']; + for (const field of dateFields){ + if ( result[field] ){ + result[field] = result[field].toISOString().split('T')[0]; + } + } + } + return results; } async createRevision(data, submittedBy){ @@ -187,15 +350,19 @@ class ApprovalRequest { } // extract employee object from data - data.employee_kerberos = data.employee.kerberos || data.employee.kerberos; - const employee = data.employee.kerberos ? {kerberos: data.employee.kerberos} : data.employee; + const employee = data.employee_kerberos ? {kerberos: data.employee.kerberos} : data.employee; + data.employee_kerberos = data.employee_kerberos || data.employee.kerberos; delete data.employee; - // start transaction + // prep data for transaction let out = {}; let approvalRequestRevisionId; const fundingSources = data.funding_sources || []; delete data.funding_sources; + const expenditures = data.expenditures || []; + delete data.expenditures; + + // start transaction const client = await pg.pool.connect(); try { await client.query('BEGIN'); @@ -219,6 +386,7 @@ class ApprovalRequest { if ( !data.no_expenditures ){ for (let fs of fundingSources){ fs.approvalRequestRevisionId = approvalRequestRevisionId; + delete fs.approvalRequestFundingSourceId; fs = this.fundingSourceFields.toDbObj(fs); fs = pg.prepareObjectForInsert(fs); const sql = `INSERT INTO approval_request_funding_source (${fs.keysString}) VALUES (${fs.placeholdersString})`; @@ -228,10 +396,16 @@ class ApprovalRequest { // insert expenditures if ( !data.no_expenditures ){ - // todo + for (let expenditure of expenditures) { + expenditure.approvalRequestRevisionId = approvalRequestRevisionId; + delete expenditure.approvalRequestExpenditureId; + expenditure = this.expenditureFields.toDbObj(expenditure); + expenditure = pg.prepareObjectForInsert(expenditure); + const sql = `INSERT INTO approval_request_expenditure (${expenditure.keysString}) VALUES (${expenditure.placeholdersString})`; + await client.query(sql, expenditure.values); + } } - await client.query('COMMIT'); } catch (e) { @@ -243,7 +417,7 @@ class ApprovalRequest { if ( out.error ) return out; - out = approvalRequestRevisionId; + out = await this.get({revisionIds: [approvalRequestRevisionId]}); return out; diff --git a/src/lib/db-models/employeeAllocation.js b/src/lib/db-models/employeeAllocation.js index 28d207b..270b461 100644 --- a/src/lib/db-models/employeeAllocation.js +++ b/src/lib/db-models/employeeAllocation.js @@ -223,7 +223,7 @@ class EmployeeAllocation { * - page: number - page number for pagination (optional) - default 1 */ async get(kwargs={}){ - const page = Number(kwargs.page) ? Number(kwargs.page) : 1; + const page = Number(kwargs.page) || 1; const pageSize = 10; const whereArgs = {}; diff --git a/src/lib/utils/apiUtils.js b/src/lib/utils/apiUtils.js index 600b800..1128278 100644 --- a/src/lib/utils/apiUtils.js +++ b/src/lib/utils/apiUtils.js @@ -9,8 +9,9 @@ class ApiUtils { * @returns {Number} */ getPageNumber(req){ - const page = parseInt(req.query.page); - return isNaN(page) ? 1 : page; + let page = parseInt(req.query.page); + if ( isNaN(page) || page < 1 ) return 1; + return page; } /** From 77de6bd8d7ff7d284fa8d573ce9308b20ffc92bb Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Mon, 20 May 2024 12:32:09 -0700 Subject: [PATCH 073/274] more PR issues resolved still reset error --- src/client/js/components/app-approver-type.js | 98 +++++++++++++++++-- src/client/js/utils/ValidationHandler.js | 1 + 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index 6a2d1ae..f44e53a 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -3,6 +3,7 @@ import {render, styles} from "./app-approver-type.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; import "./ucdlib-employee-search-basic.js" +import ValidationHandler from "../utils/ValidationHandler.js"; export default class AppApproverType extends Mixin(LitElement) @@ -22,6 +23,7 @@ export default class AppApproverType extends Mixin(LitElement) constructor() { super(); + this.id = 'admin-approvers'; this.existingApprovers = []; this.newApproverType = {}; this.render = render.bind(this); @@ -46,16 +48,31 @@ export default class AppApproverType extends Mixin(LitElement) * @param {Object} state - AppStateModel state */ async _onAppStateUpdate(state) { - if ( state.page != 'admin-approvers' ) return; + if ( state.page != this.id ) return; this.AppStateModel.showLoading(); - this.AppStateModel.showLoaded('admin-approvers'); + this.AppStateModel.showLoaded(this.id); this._getApproverType(); this.requestUpdate(); } + /** + * @description bound to AdminApproverTypeModel APPROVER_TYPE_QUERIED event + * fires when active approver type are fetched from the server + */ + _onApproverTypeFetched(e){ + if ( e.state !== 'loaded' ) return; + this.existingApprovers = e.payload.map(approver => { + approver = {...approver}; + approver.editing = false; + approver.validationHandler = new ValidationHandler(); + return approver; + }); + } + + /** * @description runs the refresh properties after edit/create/delete function runs * @@ -71,9 +88,6 @@ export default class AppApproverType extends Mixin(LitElement) * */ async _resetProperties(){ - this.label = ""; - this.description = ""; - this.employees = []; this.newApproverType = { approverTypeId: 0, label: "", @@ -81,7 +95,8 @@ export default class AppApproverType extends Mixin(LitElement) systemGenerated:false, hideFromFundAssignment:false, archived: false, - employees: [] + employees: [], + validationHandler : new ValidationHandler() }; } @@ -107,6 +122,69 @@ export default class AppApproverType extends Mixin(LitElement) approver.description = value; } + /** + * @description Returns a approver type from the element's approverType array by approverTypeId + */ + getApproverTypeId(id){ + return this.existingApprovers.find(item => item.approverTypeId == id); + } + /** + * @description bound to AdminApproverTypeModel APPROVER_TYPE_UPDATED event + */ + async _onApproverTypeUpdated(e){ + if ( e.state === 'error' ) { + if ( e.error?.payload?.is400 ) { + const getApproverTypeId = e?.payload?.getApproverTypeId; + const approverType = this.getApproverTypeId(getApproverTypeId); + approverType.validationHandler = new ValidationHandler(e); + this.AppStateModel.showLoaded(this.id) + this.requestUpdate(); + this.AppStateModel.showToast({message: 'Error when updating the approver type. Form data needs fixing.', type: 'error'}) + + } else { + this.AppStateModel.showToast({message: 'An unknown error ocurred when updating the approver type', type: 'error'}) + this.AppStateModel.showLoaded(this.id) + } + await this.waitController.waitForFrames(3); + window.scrollTo(0, this.lastScrollPosition); + } else if ( e.state === 'loading' ) { + this.AppStateModel.showLoading(); + + } else if ( e.state === 'loaded' ) { + this.AppStateModel.refresh(); + if ( e.payload?.archived ) { + this.AppStateModel.showToast({message: 'Approver Type deleted successfully', type: 'success'}); + } else { + this.AppStateModel.showToast({message: 'Approver Type updated successfully', type: 'success'}); + } + } + } + + /** + * @description bound to AdminApproverTypeModel APPROVER_TYPE_CREATED event + */ + async _onApproverTypeCreated(e){ + if ( e.state === 'error' ) { + if ( e.error?.payload?.is400 ) { + this.newApproverType.validationHandler = new ValidationHandler(e); + this.AppStateModel.showLoaded(this.id) + this.requestUpdate(); + this.AppStateModel.showToast({message: 'Error when creating the approver type. Form data needs fixing.', type: 'error'}) + } else { + this.AppStateModel.showToast({message: 'An unknown error ocurred when creating the approver type', type: 'error'}) + this.AppStateModel.showLoaded(this.id) + } + await this.waitController.waitForFrames(3); + window.scrollTo(0, this.lastScrollPosition); + } else if ( e.state === 'loading' ) { + this.AppStateModel.showLoading(); + } else if ( e.state === 'loaded' ) { + this._refreshProperties(); + this.AppStateModel.showToast({message: 'Approver Type created successfully', type: 'success'}); + } + } + + /** * @description on submit button get the form data * @@ -141,7 +219,7 @@ export default class AppApproverType extends Mixin(LitElement) let approverType = this.existingApprovers.find(a => a.approverTypeId == approverTypeId); delete approverType.editing; approverType = this.employeeFormat(approverType); - console.log(approverType); + console.log("S:",approverType); await this.AdminApproverTypeModel.update(approverType); } else { @@ -157,8 +235,8 @@ export default class AppApproverType extends Mixin(LitElement) // document.querySelector(".inputLabel").value = ""; // document.querySelector(".textDescription").value = ""; - this._refreshProperties(); - this.requestUpdate(); + // this._refreshProperties(); + // this.requestUpdate(); @@ -224,6 +302,8 @@ export default class AppApproverType extends Mixin(LitElement) } approver.editing = false; + approver.validationHandler = new ValidationHandler(); + this.requestUpdate(); } diff --git a/src/client/js/utils/ValidationHandler.js b/src/client/js/utils/ValidationHandler.js index bf4793f..45b36e9 100644 --- a/src/client/js/utils/ValidationHandler.js +++ b/src/client/js/utils/ValidationHandler.js @@ -31,6 +31,7 @@ export default class ValidationHandler { * @description Get the error class (if any) for a field */ errorClass(field){ + console.log(field); return this.errorsByField[field] ? this._errorClass : ''; } From 5aeef8e4a1a2f0374aec100c6a7776ec734e18ea Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Mon, 20 May 2024 16:31:45 -0400 Subject: [PATCH 074/274] #44 --- deploy/db-entrypoint/004-settings.sql | 4 ++ .../app-page-approval-request-new.js | 56 ++++++++++++++++++- .../app-page-approval-request-new.tpl.js | 13 +++++ src/client/scss/global.scss | 21 +++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index 821d188..1875c2e 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -1,5 +1,9 @@ INSERT INTO settings(key, value, label, description, input_type, categories) VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-request-form", "admin-settings"}'); +-- approval requests +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approval_request_form_intro', '', NULL, 'Shown on the top of the approval request form.', 'Use the following form to request approval for your travel, training, or professional development.', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); + -- admin line items page INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index 6760c06..a54cb22 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -3,12 +3,15 @@ import {render} from "./app-page-approval-request-new.tpl.js"; import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +import ValidationHandler from "../../utils/ValidationHandler.js"; + export default class AppPageApprovalRequestNew extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { static get properties() { return { - + revisionId: {type: Number}, + label: {type: String} } } @@ -16,7 +19,10 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) super(); this.render = render.bind(this); - this._injectModel('AppStateModel'); + this.revisionId = 0; + this.settingsCategory = 'approval-requests'; + + this._injectModel('AppStateModel', 'SettingsModel'); } /** @@ -25,6 +31,7 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) */ async _onAppStateUpdate(state) { if ( this.id !== state.page ) return; + this.AppStateModel.showLoading(); this.AppStateModel.setTitle('New Approval Request'); @@ -34,6 +41,51 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) this.AppStateModel.store.breadcrumbs[this.id] ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); + + this._setRevisionId(state); + + const d = await this.getPageData(); + const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + if ( hasError ) { + this.AppStateModel.showError(d); + return; + } + + this.AppStateModel.showLoaded(this.id); + this.requestUpdate(); + } + + /** + * @description Get all data required for rendering this page + */ + async getPageData(){ + const promises = []; + promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); + const resolvedPromises = await Promise.allSettled(promises); + return resolvedPromises; + } + + async _onSubmit(e){ + e.preventDefault(); + console.log('submit'); + } + + /** + * @description Reset form state + */ + resetForm(){ + this.label = ''; + this.validationHandler = new ValidationHandler(); + this.requestUpdate(); + } + + /** + * @description Set revisionId property from App State location (the url) + * @param {Object} state - AppStateModel state + */ + _setRevisionId(state) { + let revisionId = Number(state?.location?.path?.[2]); + this.revisionId = Number.isInteger(revisionId) && revisionId > 0 ? revisionId : 0; } } diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index ae6a6b9..457c7ba 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -1,6 +1,19 @@ import { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; export function render() { return html` +
+
+
+

${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_intro'))}

+
+

hello world

+
+
+ +
+
`;} diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 8410de5..0cc2834 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -17,6 +17,27 @@ font-weight: 700; } +.skinny-form { + padding: .5rem; + border: 2px solid#ffbf00; +} +@media (min-width: 768px) { + .skinny-form { + padding: 1rem; + width: 70%; + margin: 0 auto; + } +} +@media (min-width: 992px) { + .l-basic--flipped .skinny-form { + width: 90%; + } +} +@media (min-width: 1200px) { + .skinny-form { + width: 70%; + } +} .flex { display: flex; From bde5a229b3fb68d7c8eb0d04449025a37077d24f Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Mon, 20 May 2024 13:35:55 -0700 Subject: [PATCH 075/274] finished PR requests --- src/client/js/components/app-approver-type.js | 27 +++++++------------ .../js/components/app-approver-type.tpl.js | 10 ++----- .../pages/admin/app-page-admin-approvers.js | 3 +-- src/client/js/utils/ValidationHandler.js | 1 - 4 files changed, 13 insertions(+), 28 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index f44e53a..e5e8c28 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -27,8 +27,8 @@ export default class AppApproverType extends Mixin(LitElement) this.existingApprovers = []; this.newApproverType = {}; this.render = render.bind(this); - this._injectModel('AppStateModel', 'AdminApproverTypeModel'); - + this._injectModel('AppStateModel', 'AdminApproverTypeModel', 'SettingsModel'); + this.SettingsModel.getByCategory('admin-approver-form'); this._resetProperties(); } @@ -116,10 +116,12 @@ export default class AppApproverType extends Mixin(LitElement) async _setLabel(value, approver){ approver.label = value; + this.requestUpdate(); } async _setDescription(value, approver){ approver.description = value; + this.requestUpdate(); } /** @@ -151,8 +153,10 @@ export default class AppApproverType extends Mixin(LitElement) this.AppStateModel.showLoading(); } else if ( e.state === 'loaded' ) { - this.AppStateModel.refresh(); - if ( e.payload?.archived ) { + this._refreshProperties(); + let archived = e.payload.data.res[0].archived; + + if ( archived ) { this.AppStateModel.showToast({message: 'Approver Type deleted successfully', type: 'success'}); } else { this.AppStateModel.showToast({message: 'Approver Type updated successfully', type: 'success'}); @@ -219,27 +223,16 @@ export default class AppApproverType extends Mixin(LitElement) let approverType = this.existingApprovers.find(a => a.approverTypeId == approverTypeId); delete approverType.editing; approverType = this.employeeFormat(approverType); - console.log("S:",approverType); + console.log(`Done Updating ${approverTypeId} ...`); await this.AdminApproverTypeModel.update(approverType); } else { this.newApproverType = this.employeeFormat(this.newApproverType); await this.AdminApproverTypeModel.create(this.newApproverType); - - console.log("Done Creating..."); } - //this will be deleted - // document.querySelector(".inputLabel").value = ""; - // document.querySelector(".textDescription").value = ""; - - // this._refreshProperties(); - // this.requestUpdate(); - - - } /** @@ -346,7 +339,7 @@ export default class AppApproverType extends Mixin(LitElement) */ async _getApproverType(){ // let args = {status:"active"}; //if want all active do this to see your new ones - let args = { id: [1,2,3,4,5,117,118]}; + let args = { id: [1,2,3,4,5,112,113,114,115,116,117,118]}; let approvers = await this.AdminApproverTypeModel.query(args); let approverArray = approvers.payload.filter(function (el) { diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index 8595b84..f76f2d4 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -1,5 +1,5 @@ import { html, css } from 'lit'; - +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import '@ucd-lib/theme-elements/brand/ucd-theme-brand-textbox/ucd-theme-brand-textbox.js' @@ -17,14 +17,8 @@ export function render() { return html`

Approvers

-
-

When a request is submitted to this application, approval is required from a list - of employees determined by the funding source. Employees must be registered as an - approver before they can be added to the approval chain of a funding source. Some - approver types are automatically generated by this system and cannot be removed - in this section. -

+

${unsafeHTML(this.SettingsModel.getByKey('approver_type_description'))}

diff --git a/src/client/js/pages/admin/app-page-admin-approvers.js b/src/client/js/pages/admin/app-page-admin-approvers.js index 68de856..7ac55d1 100644 --- a/src/client/js/pages/admin/app-page-admin-approvers.js +++ b/src/client/js/pages/admin/app-page-admin-approvers.js @@ -16,7 +16,7 @@ export default class AppPageAdminApprovers extends Mixin(LitElement) super(); this.render = render.bind(this); - this._injectModel('AppStateModel'); + this._injectModel('AppStateModel', 'SettingsModel'); } /** @@ -27,7 +27,6 @@ export default class AppPageAdminApprovers extends Mixin(LitElement) if ( this.id !== state.page ) return; this.AppStateModel.setTitle('Approvers and Funding Sources'); - const breadcrumbs = [ this.AppStateModel.store.breadcrumbs.home, this.AppStateModel.store.breadcrumbs.admin, diff --git a/src/client/js/utils/ValidationHandler.js b/src/client/js/utils/ValidationHandler.js index 45b36e9..bf4793f 100644 --- a/src/client/js/utils/ValidationHandler.js +++ b/src/client/js/utils/ValidationHandler.js @@ -31,7 +31,6 @@ export default class ValidationHandler { * @description Get the error class (if any) for a field */ errorClass(field){ - console.log(field); return this.errorsByField[field] ? this._errorClass : ''; } From 8b1dca846ca52c87c9f7dd45d3662243ad2cea6f Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 21 May 2024 15:48:05 -0400 Subject: [PATCH 076/274] #44 --- deploy/db-entrypoint/004-settings.sql | 11 +- src/api/approvalRequest.js | 53 ++++++++- src/client/js/app-main.js | 3 +- .../app-page-approval-request-new.js | 94 +++++++++++++-- .../app-page-approval-request-new.tpl.js | 112 +++++++++++++++++- src/client/scss/global.scss | 14 ++- src/lib/cork/models/ApprovalRequestModel.js | 40 +++++++ .../cork/services/ApprovalRequestService.js | 24 ++++ src/lib/cork/stores/ApprovalRequestStore.js | 48 ++++++++ src/lib/db-models/approvalRequest.js | 12 ++ src/lib/utils/urlUtils.js | 8 ++ 11 files changed, 396 insertions(+), 23 deletions(-) create mode 100644 src/lib/cork/models/ApprovalRequestModel.js create mode 100644 src/lib/cork/services/ApprovalRequestService.js create mode 100644 src/lib/cork/stores/ApprovalRequestStore.js diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index 1875c2e..700f10e 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -2,7 +2,16 @@ INSERT INTO settings(key, value, label, description, input_type, categories) VAL -- approval requests INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") -VALUES ('approval_request_form_intro', '', NULL, 'Shown on the top of the approval request form.', 'Use the following form to request approval for your travel, training, or professional development.', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); +VALUES ('approval_request_form_intro', '', 'Approval Request Form Intro', 'Shown on the top of the approval request form.', 'Use the following form to request approval for your travel, training, or professional development.', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); + +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approval_request_form_location_in-state', '', 'Location: In-state desciption', 'Description below the in-state option of the location radio on the approval request form', NULL, '0', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); + +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approval_request_form_location_out-of-state', '', 'Location: Out of State Description', 'Description below the out-of-state option of the location radio on the approval request form', 'IMPORTANT: All out-of-state trips must be registered using the UC Away form.', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); + +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approval_request_form_location_virtual', '', 'Location: Virtual desciption', 'Description below the virtual option of the location radio on the approval request form', NULL, '0', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); -- admin line items page INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") diff --git a/src/api/approvalRequest.js b/src/api/approvalRequest.js index 5b98e59..e6bbbe9 100644 --- a/src/api/approvalRequest.js +++ b/src/api/approvalRequest.js @@ -1,14 +1,63 @@ -import protect from "../lib/protect.js"; import approvalRequest from "../lib/db-models/approvalRequest.js"; import employee from "../lib/db-models/employee.js"; + +import protect from "../lib/protect.js"; import IamEmployeeObjectAccessor from "../lib/utils/iamEmployeeObjectAccessor.js"; +import urlUtils from "../lib/utils/urlUtils.js"; +import apiUtils from "../lib/utils/apiUtils.js"; export default (api) => { + /** + * @api {get} /approval-request Get approval requests + * @description Query for approval requests. See approval request "get" db model method for query options. + * + */ + api.get('/approval-request', protect('hasBasicAccess'), async (req, res) => { + const kerberos = req.auth.token.id; + + // convert query string to camel case object + const query = urlUtils.queryToCamelCase(req.query); + + // pagination + query.page = apiUtils.getPageNumber(req); + delete query.pageSize; + + // query args that need to be arrays + query.revisionIds = apiUtils.explode(query.revisionIds, true); + query.requestIds = apiUtils.explode(query.requestIds, true); + query.employees = apiUtils.explode(query.employees); + + // do query + const results = await approvalRequest.get(query); + if ( results.error ) { + console.error('Error in GET /approval-request', results.error); + return res.status(500).json({error: true, message: 'Error getting approval requests.'}); + } + + // check if user is authorized to view all results + if ( req.auth.token.hasAdminAccess ) return res.json(results); + for (const approvalRequest of results.data) { + const isOwnRequest = approvalRequest.employeeKerberos === kerberos; + + // todo check if in approval chain + const inApprovalChain = false; + + if ( !isOwnRequest && !inApprovalChain ) return apiUtils.do403(res); + } + + res.json(results); + }); + api.post('/approval-request', protect('hasBasicAccess'), async (req, res) => { const data = req.body || {}; - // todo: check if is revision of existing request, and validate that the user has permission to do so + /** + * TODO: authorize user and ensure they have permission + * 1.Check if is revision of existing request + * a. if they are not the employee_kerberos, return 403 error + * b. if existing request does not have a status of 'draft', return 400 error + */ // get full employee object (with department) for logged in user const kerberos = req.auth.token.id; diff --git a/src/client/js/app-main.js b/src/client/js/app-main.js index 38ceb73..11e51ed 100644 --- a/src/client/js/app-main.js +++ b/src/client/js/app-main.js @@ -23,11 +23,12 @@ import AppStateModel from "../../lib/cork/models/AppStateModel.js"; AppStateModel.init(appConfig.routes); // import cork models +import "../../lib/cork/models/AdminApproverTypeModel.js"; +import "../../lib/cork/models/ApprovalRequestModel.js"; import "../../lib/cork/models/DepartmentModel.js"; import "../../lib/cork/models/EmployeeAllocationModel.js"; import "../../lib/cork/models/EmployeeModel.js"; import "../../lib/cork/models/SettingsModel.js"; -import "../../lib/cork/models/AdminApproverTypeModel.js"; import "../../lib/cork/models/FundingSourceModel.js"; import "../../lib/cork/models/LineItemsModel.js"; diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index a54cb22..977279c 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -4,14 +4,16 @@ import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; import ValidationHandler from "../../utils/ValidationHandler.js"; +import urlUtils from "../../../../lib/utils/urlUtils.js"; export default class AppPageApprovalRequestNew extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { static get properties() { return { - revisionId: {type: Number}, - label: {type: String} + approvalFormId: {type: Number}, + approvalRequest: {type: Object}, + userCantSubmit: {type: Boolean}, } } @@ -19,10 +21,29 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) super(); this.render = render.bind(this); - this.revisionId = 0; + this.approvalFormId = 0; this.settingsCategory = 'approval-requests'; - this._injectModel('AppStateModel', 'SettingsModel'); + this._injectModel('AppStateModel', 'SettingsModel', 'ApprovalRequestModel', 'AuthModel'); + this.resetForm(); + } + + /** + * @description Lit lifecycle method + */ + willUpdate(props){ + if ( props.has('approvalRequest') ){ + + // set userCantSubmit property, which is used to determine if the form can be submitted + // server side validation will also check this + let userCantSubmit = false; + if ( this.approvalRequest.employeeKerberos && this.approvalRequest.employeeKerberos !== this.AuthModel.getToken().id ){ + userCantSubmit = true; + } else if ( !['draft', 'revision-requested'].includes(this.approvalRequest.approvalStatus) ){ + userCantSubmit = true; + } + this.userCantSubmit = userCantSubmit; + } } /** @@ -42,7 +63,7 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); - this._setRevisionId(state); + this._setApprovalFormId(state); const d = await this.getPageData(); const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); @@ -61,6 +82,9 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) async getPageData(){ const promises = []; promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); + if ( this.approvalFormId ) { + promises.push(this.ApprovalRequestModel.query({requestIds: this.approvalFormId})); + } const resolvedPromises = await Promise.allSettled(promises); return resolvedPromises; } @@ -71,21 +95,69 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) } /** - * @description Reset form state + * @description bound to form input events + * @param {String} property - the property to update + * @param {String} value - the new value for the property + */ + _onFormInput(property, value){ + this.approvalRequest[property] = value; + this.requestUpdate(); + } + + /** + * @description bound to ApprovalRequestModel approval-requests-fetched event + * Handles setting the form state based on a previously saved (or submitted and rejected) approval request + */ + _onApprovalRequestsRequested(e){ + if ( e.state !== 'loaded' ) return; + + // check that request was issue by this element + const elementQueryString = urlUtils.queryObjectToKebabString({requestIds: this.approvalFormId}); + if ( e.query !== elementQueryString ) return; + + if ( !e.payload.total ){ + this.resetForm(); + setTimeout(() => { + this.AppStateModel.showError('This approval request does not exist.'); + }, 100); + return; + } + + // get the current instance of the approval request + const currentInstance = e.payload.data.find(r => r.isCurrent) + if ( !currentInstance ) { + this.resetForm(); + console.error('No current instance found for approval request', e.payload.data); + setTimeout(() => { + this.AppStateModel.showError(); + }, 100); + return; + } + + this.validationHandler = new ValidationHandler(); + this.approvalRequest = { ...currentInstance }; + + console.log(e); + } + + /** + * @description Reset form properties. */ resetForm(){ - this.label = ''; + this.approvalRequest = { + approvalStatus: 'draft' + }; this.validationHandler = new ValidationHandler(); this.requestUpdate(); } /** - * @description Set revisionId property from App State location (the url) + * @description Set approvalFormId property from App State location (the url) * @param {Object} state - AppStateModel state */ - _setRevisionId(state) { - let revisionId = Number(state?.location?.path?.[2]); - this.revisionId = Number.isInteger(revisionId) && revisionId > 0 ? revisionId : 0; + _setApprovalFormId(state) { + let approvalFormId = Number(state?.location?.path?.[2]); + this.approvalFormId = Number.isInteger(approvalFormId) && approvalFormId > 0 ? approvalFormId : 0; } } diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index 457c7ba..3f8386f 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -6,14 +6,116 @@ return html`
-

${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_intro'))}

-
-

hello world

-
+

${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_intro'))}

+
+ This form cannot be submitted because either: +
+
    +
  • This approval request has already been submitted.
  • +
  • You are not the original submitter of this request.
  • +
+
+
+ ${renderForm.call(this)}
- `;} + +export function renderForm(){ + const page = 'app-page-approval-request-new'; + + return html` +
+
+ + this._onFormInput('label', e.target.value)} + > +
${this.validationHandler.renderErrorMessages('label')}
+
+
+ + this._onFormInput('organization', e.target.value)} + > +
${this.validationHandler.renderErrorMessages('organization')}
+
+
+ + +
${this.validationHandler.renderErrorMessages('businessPurpose')}
+
+
+ +
+
+ this._onFormInput('location', 'in-state')} + > + +
+
+ ${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_location_in-state'))} +
+
+ this._onFormInput('location', 'out-of-state')} + > + +
+
+ ${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_location_out-of-state'))} +
+
+ this._onFormInput('location', 'foreign')} + > + +
+
+ ${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_location_foreign'))} +
+
+ this._onFormInput('location', 'virtual')} + > + +
+
+ ${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_location_virtual'))} +
+
+
${this.validationHandler.renderErrorMessages('location')}
+
+
+ `; +} diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 0cc2834..f0c9577 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -18,12 +18,12 @@ } .skinny-form { - padding: .5rem; - border: 2px solid#ffbf00; + padding: 1rem .5rem; + border: 1px solid #ffbf00; } @media (min-width: 768px) { .skinny-form { - padding: 1rem; + padding: 2rem 1rem; width: 70%; margin: 0 auto; } @@ -76,6 +76,14 @@ fieldset.field-error { font-size: .875rem; color: #13639e; } +.radio .option-description { + padding-left: 2.5rem; + font-weight: 400; + font-size: .875rem; + margin-bottom: .5rem; +} + + .pad-icon-top { padding-top: .3rem; } diff --git a/src/lib/cork/models/ApprovalRequestModel.js b/src/lib/cork/models/ApprovalRequestModel.js new file mode 100644 index 0000000..6112ad1 --- /dev/null +++ b/src/lib/cork/models/ApprovalRequestModel.js @@ -0,0 +1,40 @@ +import {BaseModel} from '@ucd-lib/cork-app-utils'; +import ApprovalRequestService from '../services/ApprovalRequestService.js'; +import ApprovalRequestStore from '../stores/ApprovalRequestStore.js'; + +import urlUtils from '../../utils/urlUtils.js'; + +class ApprovalRequestModel extends BaseModel { + + constructor() { + super(); + + this.store = ApprovalRequestStore; + this.service = ApprovalRequestService; + + this.register('ApprovalRequestModel'); + } + + async query(query={}) { + + const queryString = urlUtils.queryObjectToKebabString(query); + + let state = this.store.data.fetched[queryString]; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.query(queryString); + } + } catch(e) {} + + this.store.emit(this.store.events.APPROVAL_REQUESTS_REQUESTED, this.store.data.fetched[queryString]); + + return this.store.data.fetched[queryString]; + + } + +} + +const model = new ApprovalRequestModel(); +export default model; diff --git a/src/lib/cork/services/ApprovalRequestService.js b/src/lib/cork/services/ApprovalRequestService.js new file mode 100644 index 0000000..0ab75eb --- /dev/null +++ b/src/lib/cork/services/ApprovalRequestService.js @@ -0,0 +1,24 @@ +import BaseService from './BaseService.js'; +import ApprovalRequestStore from '../stores/ApprovalRequestStore.js'; + +class ApprovalRequestService extends BaseService { + + constructor() { + super(); + this.store = ApprovalRequestStore; + } + + query(query) { + return this.request({ + url : `/api/approval-request${query ? '?' + query : ''}`, + checkCached: () => this.store.data.fetched[query], + onLoading : request => this.store.approvalRequestsFetchedLoading(request, query), + onLoad : result => this.store.approvalRequestsFetchedLoaded(result.body, query), + onError : e => this.store.approvalRequestsFetchedError(e, query) + }); + } + +} + +const service = new ApprovalRequestService(); +export default service; diff --git a/src/lib/cork/stores/ApprovalRequestStore.js b/src/lib/cork/stores/ApprovalRequestStore.js new file mode 100644 index 0000000..8c8fd40 --- /dev/null +++ b/src/lib/cork/stores/ApprovalRequestStore.js @@ -0,0 +1,48 @@ +import {BaseStore} from '@ucd-lib/cork-app-utils'; + +class ApprovalRequestStore extends BaseStore { + + constructor() { + super(); + + this.data = { + fetched: {}, + }; + this.events = { + APPROVAL_REQUESTS_FETCHED: 'approval-requests-fetched', + APPROVAL_REQUESTS_REQUESTED: 'approval-requests-requested' + }; + } + + approvalRequestsFetchedLoading(query) { + this._setApprovalRequestsFetchedState({ + state : this.STATE.LOADING, + query + }); + } + + approvalRequestsFetchedLoaded(payload, query) { + this._setApprovalRequestsFetchedState({ + state : this.STATE.LOADED, + payload, + query + }); + } + + approvalRequestsFetchedError(error, query) { + this._setApprovalRequestsFetchedState({ + state : this.STATE.ERROR, + error, + query + }); + } + + _setApprovalRequestsFetchedState(state) { + this.data.fetched[state.query] = state; + this.emit(this.events.APPROVAL_REQUESTS_FETCHED, state); + } + +} + +const store = new ApprovalRequestStore(); +export default store; diff --git a/src/lib/db-models/approvalRequest.js b/src/lib/db-models/approvalRequest.js index fb8a425..228173d 100644 --- a/src/lib/db-models/approvalRequest.js +++ b/src/lib/db-models/approvalRequest.js @@ -305,6 +305,11 @@ class ApprovalRequest { } + /** + * @description Prepare array of approval request revision objects for return + * @param {Array} results - array of approval request revision objects + * @returns {Array} + */ _prepareResults(results){ results = this.entityFields.toJsonArray(results); for (const result of results){ @@ -328,6 +333,13 @@ class ApprovalRequest { return results; } + /** + * @description Create a new approval request revision + * @param {Object} data - the approval request revision data - see entityFields for expected fields (json names) + * @param {Object} submittedBy - the employee object of the employee submitting the request + * - if data.employeeKerberos is not set, this object will be used to set the employeeKerberos field + * @returns {Object} + */ async createRevision(data, submittedBy){ // if submittedBy is provided, assign approval request revision to that employee diff --git a/src/lib/utils/urlUtils.js b/src/lib/utils/urlUtils.js index 6ee875a..afbed19 100644 --- a/src/lib/utils/urlUtils.js +++ b/src/lib/utils/urlUtils.js @@ -4,6 +4,14 @@ */ class UrlUtils { + /** + * @description Convert a query object to a kebab-case query string + */ + queryObjectToKebabString(q){ + if ( !q || ! typeof q === 'object' ) return ''; + return this.queryStringFromObject(this.queryToKebabCase(q)); + } + /** * @description Get the sorted query string from an object * @param {Object} q - query object From ca301245b6d667478da6dac133edb49081080b1c Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Tue, 21 May 2024 20:47:31 -0700 Subject: [PATCH 077/274] more edits --- .../ucdlib-employee-search-basic.js | 43 +++++++++++-------- .../ucdlib-employee-search-basic.tpl.js | 3 +- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index f5a076d..1aecd81 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -39,6 +39,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) totalResults: {state: true}, resultCtNotShown: {state: true}, noResults: {state: true}, + noIam: {state: true}, error: {state: true}, status: {state: true}, isSearching: {state: true}, @@ -65,6 +66,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.showDropdown = false; this.isFocused = false; this.noResults = false; + this.noIam = false; this.selectedText = ''; this.selectedObject = {}; this.iamresult = {}; @@ -78,7 +80,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) * @description LitElement lifecycle called when element is updated * @param {*} p - Changed properties */ - willUpdate(p) { + async willUpdate(p) { if ( p.has('query') && this.query.length > 2 ){ if ( this.searchTimeout ) clearTimeout(this.searchTimeout); this.searchTimeout = setTimeout(() => { @@ -89,6 +91,22 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) if ( p.has('results') || p.has('totalResults') ) { this.resultCtNotShown = this.totalResults - this.results.length; } + + if (p.has('selectedValue') && this.selectedValue !== this.selectedObject.user_id && this.selectedValue.length > 0) { + try { + let iamobject = await this.EmployeeModel.getIamRecordById(this.selectedValue); + if (iamobject.payload.total) { // if multiple results are returned + this.noIam = true; + } + else { + this.selectedObject = iamobject.payload; + this.selectedText = `${iamobject.payload.first_name} ${iamobject.payload.last_name}`; + } + } + catch (e) { + this.noIam = true; + } + } this._setStatus(p); this._setShowDropdown(p); @@ -101,21 +119,6 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) createRenderRoot() { return this; } - - /** - * @description Listens for changes in the selectedValue property. If the property changes, the selectedObject is updated. - */ - async shouldUpdate(changedProperties) { - if (changedProperties.has('selectedValue')) { - try { - let iamobject = await this.EmployeeModel.getIamRecordById(this.selectedValue); - this.selectedObject = iamobject.payload; - } - catch (e) { - this.error = true; - } - } - } /** * @description Searches for employees by name. Fires when query property changes. @@ -125,6 +128,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.noResults = false; this.selectedText = ''; this.selectedObject = {}; + this.selectedValue = ''; if ( !this.query ) { this.results = []; this.totalResults = 0; @@ -167,6 +171,12 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) detail: detail })); } + if (this.noIam) { + this.status = 'no-iam'; + this.dispatchEvent(new CustomEvent('status-change', { + detail: detail + })); + } } @@ -185,7 +195,6 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) * @param {Object} result - an Employee object from the database */ async _onSelect(result){ - this.selectedText = `${result.first_name} ${result.last_name}`; this.selectedValue = result.user_id; } diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js index 5f5351e..7f878c6 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -25,7 +25,7 @@ return html`
- +
@@ -46,6 +46,7 @@ return html`
No results matched your search!
+
Employee is not in Library directory system!
`;} From 171eb0761765842686b834c54f6672e68db89041 Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Wed, 22 May 2024 11:36:13 -0700 Subject: [PATCH 078/274] update error handling on getIamRecordById --- .../ucdlib-employee-search-basic.js | 19 ++++++++----------- .../ucdlib-employee-search-basic.tpl.js | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index 1aecd81..66fa9e5 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -16,6 +16,7 @@ import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-el * @param {Number} resultCtNotShown - difference of payload.total and payload.data.length. Checks that all results are shown. * @param {Boolean} noResults - total number of pages of search results. true is payload.data.length is 0 * @param {Boolean} error - true if component state is 'error' + * @param {Boolean} userFetchError - true if user fetch error on request to getIamRecordById * @param {String} status - status of input component. 'idle', 'searching', 'no-results', 'selected' bound to setStatus method * @param {Boolean} isSearching - text is entered into the search input field and searching for results * @param {Boolean} showDropdown - component initialization status. True if search results are shown and more than three charecters are entered into the search input field @@ -39,8 +40,8 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) totalResults: {state: true}, resultCtNotShown: {state: true}, noResults: {state: true}, - noIam: {state: true}, error: {state: true}, + userFetchError: {state: true}, status: {state: true}, isSearching: {state: true}, showDropdown: {state: true}, @@ -59,6 +60,7 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.totalResults = 0; this.resultCtNotShown = 0; this.error = false; + this.userFetchError = false; this.labelText = 'Search for a UC Davis Library Employee'; this.hideLabel = false; this.status = 'idle'; @@ -66,7 +68,6 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.showDropdown = false; this.isFocused = false; this.noResults = false; - this.noIam = false; this.selectedText = ''; this.selectedObject = {}; this.iamresult = {}; @@ -95,8 +96,9 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) if (p.has('selectedValue') && this.selectedValue !== this.selectedObject.user_id && this.selectedValue.length > 0) { try { let iamobject = await this.EmployeeModel.getIamRecordById(this.selectedValue); - if (iamobject.payload.total) { // if multiple results are returned - this.noIam = true; + if (iamobject.error) { + this.userFetchError = true; + this.status = 'no-iam'; } else { this.selectedObject = iamobject.payload; @@ -104,7 +106,8 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) } } catch (e) { - this.noIam = true; + this.userFetchError = true; + this.status = 'no-iam'; } } @@ -171,12 +174,6 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) detail: detail })); } - if (this.noIam) { - this.status = 'no-iam'; - this.dispatchEvent(new CustomEvent('status-change', { - detail: detail - })); - } } diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js index 7f878c6..4050ede 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -46,7 +46,7 @@ return html`
No results matched your search!
-
Employee is not in Library directory system!
+
Employee is not in Library directory system!
`;} From cfdf4d4c7314c0e923d8bfa30e8437365ababdae Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 22 May 2024 16:13:13 -0400 Subject: [PATCH 079/274] #44 dates section of form --- deploy/db-entrypoint/004-settings.sql | 8 +- .../app-page-approval-request-new.js | 23 +++- .../app-page-approval-request-new.tpl.js | 103 +++++++++++++++++- src/client/scss/global.scss | 5 +- .../scss/pages/approval-request-new.scss | 11 ++ src/client/scss/style.scss | 3 + 6 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 src/client/scss/pages/approval-request-new.scss diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index 700f10e..216c4ff 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -1,4 +1,4 @@ -INSERT INTO settings(key, value, label, description, input_type, categories) VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-request-form", "admin-settings"}'); +INSERT INTO settings(key, value, label, description, input_type, categories) VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-requests", "admin-settings"}'); -- approval requests INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") @@ -13,6 +13,12 @@ VALUES ('approval_request_form_location_out-of-state', '', 'Location: Out of Sta INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('approval_request_form_location_virtual', '', 'Location: Virtual desciption', 'Description below the virtual option of the location radio on the approval request form', NULL, '0', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approval_request_form_custom_travel', '', 'Custom Travel Dates Explanatory Text', 'Placed below "Custom Travel Dates" checkbox on new approval form.', 'Travel dates are different from program dates entered above', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); + +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approval_request_form_travel-required', '', 'Travel Required Explanatory Text', 'Placed below "Travel Required" checkbox on new approval form.', 'Travel is required for this trip, training, or professional development opportunity.', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); + -- admin line items page INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index 977279c..283becc 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -91,7 +91,28 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) async _onSubmit(e){ e.preventDefault(); - console.log('submit'); + const ar = this.approvalRequest + + + // set conditional request dates + if ( ar.programStartDate && !ar.programEndDate ) { + ar.programEndDate = ar.programStartDate; + } + if ( ar.travelRequired){ + if ( ar.hasCustomTravelDates ){ + if ( ar.travelStartDate && !ar.travelEndDate ) { + ar.travelEndDate = ar.travelStartDate; + } + } else { + ar.travelStartDate = ar.programStartDate; + ar.travelEndDate = ar.programEndDate + } + } else { + delete ar.travelStartDate; + delete ar.travelEndDate + } + + console.log('submit', this.approvalRequest); } /** diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index 3f8386f..c2be1dc 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -28,7 +28,7 @@ export function renderForm(){ const page = 'app-page-approval-request-new'; return html` -
+
${this.validationHandler.renderErrorMessages('label')}
+
${this.validationHandler.renderErrorMessages('organization')}
+
+ >
${this.validationHandler.renderErrorMessages('businessPurpose')}
+
@@ -116,6 +120,101 @@ export function renderForm(){
${this.validationHandler.renderErrorMessages('location')}
+ +
+ Dates * +
+
+
+ + this._onFormInput('programStartDate', e.target.value)} + > +
${this.validationHandler.renderErrorMessages('programStartDate')}
+
+
+
+
+ + this._onFormInput('programEndDate', e.target.value)} + > +
${this.validationHandler.renderErrorMessages('programEndDate')}
+
+
+
+
+
+
+ this._onFormInput('travelRequired', e.target.checked)} + > + +
+
${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_travel-required'))}
+
${this.validationHandler.renderErrorMessages('travelRequired')}
+
+
+
+
+
+ this._onFormInput('hasCustomTravelDates', e.target.checked)} + > + +
+
${unsafeHTML(this.SettingsModel.getByKey('approval_request_form_custom_travel'))}
+
${this.validationHandler.renderErrorMessages('hasCustomTravelDates')}
+
+
+
+
+
+ + this._onFormInput('travelStartDate', e.target.value)} + > + ${this.validationHandler.renderErrorMessages('travelStartDate')} +
+
+
+
+ + this._onFormInput('travelEndDate', e.target.value)} + > + ${this.validationHandler.renderErrorMessages('travelEndDate')} +
+
+
+
+ +
+ +
`; } diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index f0c9577..489d22c 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -76,12 +76,15 @@ fieldset.field-error { font-size: .875rem; color: #13639e; } -.radio .option-description { +.radio .option-description, .checkbox .option-description { padding-left: 2.5rem; font-weight: 400; font-size: .875rem; margin-bottom: .5rem; } +.field-container { + margin-bottom: 1rem !important; +} .pad-icon-top { diff --git a/src/client/scss/pages/approval-request-new.scss b/src/client/scss/pages/approval-request-new.scss new file mode 100644 index 0000000..30cee75 --- /dev/null +++ b/src/client/scss/pages/approval-request-new.scss @@ -0,0 +1,11 @@ +app-page-approval-request-new { + .approval-request-form { + --l-gap-override: 1rem; + + @media (max-width: 991px) { + .l-2col > :where(* + *) { + margin-top: 1rem; + } + } + } +} diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index df823a9..1e34f13 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -7,6 +7,9 @@ @use './pages//admin/line-items.scss' as adminLineItems; @use './pages/admin/allocations.scss' as adminAllocations; +// pages +@use './pages/approval-request-new.scss' as approvalRequestNew; + // components @use './components/dialog-modal.scss' as dialogModal; @use './components/employee-search-advanced.scss' as employeeSearchAdvanced; From 84f85dc2e7cf5a0669f1253af3f3c3a6b09376a5 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 23 May 2024 09:57:49 -0400 Subject: [PATCH 080/274] fixes to #14 --- .../ucdlib-employee-search-basic.js | 122 ++++++++++++------ .../ucdlib-employee-search-basic.tpl.js | 57 +++----- .../components/employee-search-basic.scss | 22 ++-- 3 files changed, 111 insertions(+), 90 deletions(-) diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index 66fa9e5..e6161fd 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -1,5 +1,5 @@ -import { LitElement, html } from 'lit'; -import {render, styles} from "./ucdlib-employee-search-basic.tpl.js"; +import { LitElement } from 'lit'; +import {render } from "./ucdlib-employee-search-basic.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; @@ -16,16 +16,17 @@ import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-el * @param {Number} resultCtNotShown - difference of payload.total and payload.data.length. Checks that all results are shown. * @param {Boolean} noResults - total number of pages of search results. true is payload.data.length is 0 * @param {Boolean} error - true if component state is 'error' - * @param {Boolean} userFetchError - true if user fetch error on request to getIamRecordById * @param {String} status - status of input component. 'idle', 'searching', 'no-results', 'selected' bound to setStatus method * @param {Boolean} isSearching - text is entered into the search input field and searching for results * @param {Boolean} showDropdown - component initialization status. True if search results are shown and more than three charecters are entered into the search input field * @param {Boolean} isFocused - form is focused. True if search input field is focused. Triggered by @focus event. * @param {String} selectedText - Text in dropdown after a result is selected. * @param {Object} selectedObject - Full result of search object after a result is selected. - * @param {Object} iamresult - Full result of search object after a result is selected. * @param {String} department - sorted department name from the search result object * @param {String} selectedValue - kerberos id from the search result object + * + * @emits status-change - Fires when user updates the status of the search element + * @emits employee-selected - Fires when user selects an employee from the search results */ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) @@ -36,19 +37,19 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) query: {type: String}, labelText: {type: String, attribute: 'label-text'}, hideLabel: {type: Boolean, attribute: 'hide-label'}, + selectedValue: {type: String, attribute: 'selected-value'}, results: {state: true}, totalResults: {state: true}, resultCtNotShown: {state: true}, noResults: {state: true}, error: {state: true}, - userFetchError: {state: true}, status: {state: true}, isSearching: {state: true}, showDropdown: {state: true}, isFocused: {state: true}, selectedText: {state: true}, selectedObject: {state: true}, - selectedValue: {type: String} + iamRecordMissing: {state: true} } } @@ -60,7 +61,6 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.totalResults = 0; this.resultCtNotShown = 0; this.error = false; - this.userFetchError = false; this.labelText = 'Search for a UC Davis Library Employee'; this.hideLabel = false; this.status = 'idle'; @@ -70,7 +70,6 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.noResults = false; this.selectedText = ''; this.selectedObject = {}; - this.iamresult = {}; this.department = ''; this.selectedValue = ''; @@ -88,47 +87,35 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.search(); }, 500); } - + if ( p.has('results') || p.has('totalResults') ) { this.resultCtNotShown = this.totalResults - this.results.length; } - if (p.has('selectedValue') && this.selectedValue !== this.selectedObject.user_id && this.selectedValue.length > 0) { - try { - let iamobject = await this.EmployeeModel.getIamRecordById(this.selectedValue); - if (iamobject.error) { - this.userFetchError = true; - this.status = 'no-iam'; - } - else { - this.selectedObject = iamobject.payload; - this.selectedText = `${iamobject.payload.first_name} ${iamobject.payload.last_name}`; - } - } - catch (e) { - this.userFetchError = true; - this.status = 'no-iam'; - } - } - + await this._getEmployeeRecordByAttribute(p); + this._setStatus(p); this._setShowDropdown(p); } - + /** - * @description Disables the shadowdom - * @returns - */ - createRenderRoot() { - return this; + * @description Attached to input event on main search input field + * @param {Event} e - input event + */ + _onInput(e) { + this.query = e.target.value; + this.selectedText = ''; + this.selectedObject = {}; + this.selectedValue = ''; } - + /** * @description Searches for employees by name. Fires when query property changes. * @returns */ async search(){ this.noResults = false; + this.iamRecordMissing = false; this.selectedText = ''; this.selectedObject = {}; this.selectedValue = ''; @@ -151,7 +138,36 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.error = true; } } - + + /** + * @description Fetches the employee record by the selectedValue attribute + * Attached to willUpdate lifecycle callback + * Will activate when selectedValue element attribute changes + * @param {Map} p - Properties that have changed in current update cycle + */ + async _getEmployeeRecordByAttribute(p){ + if (p.has('selectedValue') && + this.selectedValue !== this.selectedObject.user_id && + this.selectedValue) { + + let iamObject = await this.EmployeeModel.getIamRecordById(this.selectedValue); + this.iamRecordMissing = false; + if ( iamObject.state === 'loaded' ){ + this._onSelect(iamObject.payload); + this.error = false; + } else if ( iamObject.state === 'error' ){ + this.error = true; + this.selectedObject = {}; + this.selectedText = ''; + + if ( iamObject.error?.response?.status == 404 ){ + this.iamRecordMissing = true; + } + } + } + + } + /** * @description Sets the status of the element based on the updated properties * @param {*} p - Changed properties @@ -174,9 +190,8 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) detail: detail })); } - } - + /** * @description Shows/hides the results dropdown based on the element's updated properties * @param {*} p @@ -186,15 +201,29 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) this.showDropdown = this.isFocused && this.results.length && this.query && !this.selectedText; } } - + /** * @description Fires when a result is clicked from the dropdown * @param {Object} result - an Employee object from the database */ - async _onSelect(result){ + async _onSelect(result, emitEvent){ + if ( !result ){ + this.selectedValue = ''; + this.selectedObject = {}; + this.selectedText = ''; + return; + } this.selectedValue = result.user_id; + this.selectedObject = result; + this.selectedText = `${result.first_name} ${result.last_name}`; + + if ( emitEvent ) { + this.dispatchEvent(new CustomEvent('employee-selected', { + detail: {employee: result} + })); + } } - + /** * @description Hides dropdown box on input blur. * Must give time for click event to fire on dropdown item. @@ -205,6 +234,19 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) }, 250); } + + /** + * @description searches for employee department + * @param {Object} result - an Employee object from the database + */ + getDepartment(result){ + if ( result.groups ) { + let department = result.groups.find(group => group.type === 'Department'); + if ( department ) return department.name; + } + return ''; + } + } customElements.define('ucdlib-employee-search-basic', UcdlibEmployeeSearchBasic); diff --git a/src/client/js/components/ucdlib-employee-search-basic.tpl.js b/src/client/js/components/ucdlib-employee-search-basic.tpl.js index 4050ede..12ddcd7 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.tpl.js +++ b/src/client/js/components/ucdlib-employee-search-basic.tpl.js @@ -1,5 +1,9 @@ -import { html, css } from 'lit'; +import { html } from 'lit'; +/** + * @description Main render function for this element + * @returns {TemplateResult} + */ export function render() { return html`
@@ -8,25 +12,14 @@ return html`
@@ -35,8 +28,9 @@ return html`
${this.results.map(result => html` -
this._onSelect(result)}> - ${renderResult(result)} +
this._onSelect(result, true)}> +
${result.first_name} ${result.last_name}
+
${result.title}, ${this.getDepartment(result)}
`)}
@@ -46,32 +40,17 @@ return html`
No results matched your search!
-
Employee is not in Library directory system!
+
Employee is not in Library directory system!
`;} /** - * @description Renders a single result item in the results dropdown - * @param {Object} result - an Employee object from the database + * @description Render the icon in the input field * @returns {TemplateResult} */ -function renderResult(result){ - let department = getDepartment(result); - return html` -
${result.first_name} ${result.last_name}
-
${result.title}, ${department}
- `; +function renderInputIcon(){ + if ( this.error || this.status === 'no-results' ) return html``; + if ( this.status === 'searching' ) return html``; + if ( this.status === 'selected' ) return html``; + return html``; } - -/** - * @description searches for employee department - * @param {Object} result - an Employee object from the database - */ -// if item in results.group has key of type === 'Department', then return the name value of that object -function getDepartment(result){ - if ( result.groups ) { - let department = result.groups.find(group => group.type === 'Department'); - if ( department ) return department.name; - } - return ''; -} \ No newline at end of file diff --git a/src/client/scss/components/employee-search-basic.scss b/src/client/scss/components/employee-search-basic.scss index 952ea2a..bcfac9a 100644 --- a/src/client/scss/components/employee-search-basic.scss +++ b/src/client/scss/components/employee-search-basic.scss @@ -1,16 +1,16 @@ ucdlib-employee-search-basic { .emp-search-top.field-container { - margin-bottom: 0 + margin-bottom: 0 !important; } .emp-search-bar { - position: relative + position: relative; } .emp-search-bar input { flex-grow: 1; - padding-right: 2rem + padding-right: 2rem; } .emp-search-bar .emp-search-icon { @@ -24,11 +24,11 @@ ucdlib-employee-search-basic { justify-content: center; right: 0; top: 0; - color: #022851 + color: #022851; } .emp-search-results-parent { - position: relative + position: relative; } .emp-search-results { @@ -43,28 +43,28 @@ ucdlib-employee-search-basic { border-bottom: 1px solid #ffbf00; color: #13639e; max-height: 250px; - overflow-y: scroll + overflow-y: scroll; } .emp-search-results .emp-search-result { - padding: .5rem .75rem + padding: .5rem .75rem; } .emp-search-results .emp-search-result:hover { - background-color: #fde9ac + background-color: #fde9ac; } .emp-search-results .highlight { - font-weight: 700 + font-weight: 700; } .emp-search-results .muted { font-weight: 400; - color: #545454 + color: #545454; } .emp-search-results .emp-search-more { - padding: .5rem .75rem + padding: .5rem .75rem; } max-width: 500px; From 02f0e36ef08b1605845f7c681857d7a472d754fa Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 23 May 2024 12:24:23 -0400 Subject: [PATCH 081/274] #45 --- src/api/approvalRequest.js | 21 +++++++ .../app-page-approval-request-new.js | 53 ++++++++++++++++- .../app-page-approval-request-new.tpl.js | 9 ++- .../app-page-approval-requests.js | 3 + .../app-page-approval-requests.tpl.js | 6 +- src/lib/cork/models/ApprovalRequestModel.js | 16 +++++ .../cork/services/ApprovalRequestService.js | 12 ++++ src/lib/cork/stores/ApprovalRequestStore.js | 33 ++++++++++- src/lib/db-models/approvalRequest.js | 58 +++++++++++++++++++ src/lib/utils/typeTransform.js | 33 +++++++++++ 10 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 src/lib/utils/typeTransform.js diff --git a/src/api/approvalRequest.js b/src/api/approvalRequest.js index e6bbbe9..81ab3f7 100644 --- a/src/api/approvalRequest.js +++ b/src/api/approvalRequest.js @@ -85,4 +85,25 @@ export default (api) => { res.json(result); }); + api.delete('/approval-request/:id', protect('hasBasicAccess'), async (req, res) => { + + // try to delete draft + const result = await approvalRequest.deleteDraft(req.params.id, req.auth.token.id); + + // handle errors + if ( result.error && result.is400 ) { + return res.status(400).json(result); + } + if ( result.error && result.is403 ) { + return apiUtils.do403(res); + } + if ( result.error ) { + console.error('Error in DELETE /approval-request/:id', result.error); + return res.status(500).json({error: true, message: 'Error deleting approval request.'}); + } + + res.json(result); + + }); + }; diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index 283becc..83a89cf 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -14,6 +14,7 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) approvalFormId: {type: Number}, approvalRequest: {type: Object}, userCantSubmit: {type: Boolean}, + canBeDeleted: {type: Boolean}, } } @@ -33,9 +34,15 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) */ willUpdate(props){ if ( props.has('approvalRequest') ){ + this._setUserCantSubmit(); + } + } - // set userCantSubmit property, which is used to determine if the form can be submitted - // server side validation will also check this + /** + * @description Sets the userCantSubmit property based on the current approval request + * Used to determine if the form can be submitted, but server side validation will also check this + */ + _setUserCantSubmit(){ let userCantSubmit = false; if ( this.approvalRequest.employeeKerberos && this.approvalRequest.employeeKerberos !== this.AuthModel.getToken().id ){ userCantSubmit = true; @@ -43,7 +50,6 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) userCantSubmit = true; } this.userCantSubmit = userCantSubmit; - } } /** @@ -155,6 +161,7 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) return; } + this.canBeDeleted = e.payload.data.find(r => r.approvalStatus !== 'draft') ? false : true; this.validationHandler = new ValidationHandler(); this.approvalRequest = { ...currentInstance }; @@ -169,9 +176,49 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) approvalStatus: 'draft' }; this.validationHandler = new ValidationHandler(); + this.canBeDeleted = false; this.requestUpdate(); } + _onDeleteButtonClick(){ + if ( !this.canBeDeleted || this.userCantSubmit ) return; + this.AppStateModel.showDialogModal({ + title : 'Delete Approval Request', + content : 'Are you sure you want to delete this approval request? This action cannot be undone.', + actions : [ + {text: 'Delete', value: 'delete-approval-request', color: 'double-decker'}, + {text: 'Cancel', value: 'cancel', invert: true, color: 'primary'} + ], + data : {approvalRequestId: this.approvalRequest.approvalRequestId} + }); + } + + /** + * @description Callback for dialog-action AppStateModel event + * @param {Object} e - AppStateModel dialog-action event + * @returns + */ + _onDialogAction(e){ + if ( e.action !== 'delete-approval-request' ) return; + this.ApprovalRequestModel.delete(e.data.approvalRequestId); + } + + _onApprovalRequestDeleted(e){ + if ( e.state === 'loading' ){ + this.AppStateModel.showLoading(); + return; + } + + if ( e.state === 'loaded' ){ + this.AppStateModel.setLocation(this.AppStateModel.store.breadcrumbs['approval-requests'].link); + this.AppStateModel.showToast({message: 'Approval request deleted.', type: 'success'}); + return; + } + + this.AppStateModel.showLoaded(this.id); + this.AppStateModel.showToast({message: 'Error deleting approval request.', type: 'error'}); + } + /** * @description Set approvalFormId property from App State location (the url) * @param {Object} state - AppStateModel state diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index c2be1dc..d0027a1 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -207,13 +207,20 @@ export function renderForm(){
-
+
+
`; diff --git a/src/client/js/pages/approval-requests/app-page-approval-requests.js b/src/client/js/pages/approval-requests/app-page-approval-requests.js index 9ad70ce..7eef7be 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-requests.js +++ b/src/client/js/pages/approval-requests/app-page-approval-requests.js @@ -33,6 +33,9 @@ export default class AppPageApprovalRequests extends Mixin(LitElement) this.AppStateModel.store.breadcrumbs[this.id] ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); + + + this.AppStateModel.showLoaded(this.id); } } diff --git a/src/client/js/pages/approval-requests/app-page-approval-requests.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-requests.tpl.js index 6b4d94e..3447799 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-requests.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-requests.tpl.js @@ -1,7 +1,5 @@ import { html } from 'lit'; -export function render() { +export function render() { return html` - - -`;} \ No newline at end of file +`;} diff --git a/src/lib/cork/models/ApprovalRequestModel.js b/src/lib/cork/models/ApprovalRequestModel.js index 6112ad1..0fd0e37 100644 --- a/src/lib/cork/models/ApprovalRequestModel.js +++ b/src/lib/cork/models/ApprovalRequestModel.js @@ -34,6 +34,22 @@ class ApprovalRequestModel extends BaseModel { } + /** + * @description Delete an approval request by id - must have always been in a draft state + * @param {String} approvalRequestId - id of approval request to delete + */ + async delete(approvalRequestId) { + let timestamp = Date.now(); + try { + await this.service.delete(approvalRequestId, timestamp); + } catch(e) {} + const state = this.store.data.deleted[timestamp]; + if ( state && state.state === 'loaded' ) { + this.store.data.fetched = {}; + } + return state; + } + } const model = new ApprovalRequestModel(); diff --git a/src/lib/cork/services/ApprovalRequestService.js b/src/lib/cork/services/ApprovalRequestService.js index 0ab75eb..210264e 100644 --- a/src/lib/cork/services/ApprovalRequestService.js +++ b/src/lib/cork/services/ApprovalRequestService.js @@ -18,6 +18,18 @@ class ApprovalRequestService extends BaseService { }); } + delete(id, timestamp ){ + return this.request({ + url : `/api/approval-request/${id}`, + fetchOptions : { + method : 'DELETE' + }, + onLoading : request => this.store.approvalRequestDeletedLoading(request, timestamp), + onLoad : result => this.store.approvalRequestDeletedLoaded(result.body, timestamp), + onError : e => this.store.approvalRequestDeletedError(e, timestamp) + }); + } + } const service = new ApprovalRequestService(); diff --git a/src/lib/cork/stores/ApprovalRequestStore.js b/src/lib/cork/stores/ApprovalRequestStore.js index 8c8fd40..1b0b1d4 100644 --- a/src/lib/cork/stores/ApprovalRequestStore.js +++ b/src/lib/cork/stores/ApprovalRequestStore.js @@ -7,13 +7,44 @@ class ApprovalRequestStore extends BaseStore { this.data = { fetched: {}, + deleted: {} }; this.events = { APPROVAL_REQUESTS_FETCHED: 'approval-requests-fetched', - APPROVAL_REQUESTS_REQUESTED: 'approval-requests-requested' + APPROVAL_REQUESTS_REQUESTED: 'approval-requests-requested', + APPROVAL_REQUEST_DELETED: 'approval-request-deleted' }; } + approvalRequestDeletedLoading(request, timestamp) { + this._setApprovalRequestDeletedState({ + state : this.STATE.LOADING, + request, + timestamp + }); + } + + approvalRequestDeletedLoaded(payload, timestamp) { + this._setApprovalRequestDeletedState({ + state : this.STATE.LOADED, + payload, + timestamp + }); + } + + approvalRequestDeletedError(error, timestamp) { + this._setApprovalRequestDeletedState({ + state : this.STATE.ERROR, + error, + timestamp + }); + } + + _setApprovalRequestDeletedState(state) { + this.data.deleted[state.timestamp] = state; + this.emit(this.events.APPROVAL_REQUEST_DELETED, state); + } + approvalRequestsFetchedLoading(query) { this._setApprovalRequestsFetchedState({ state : this.STATE.LOADING, diff --git a/src/lib/db-models/approvalRequest.js b/src/lib/db-models/approvalRequest.js index 228173d..2a67712 100644 --- a/src/lib/db-models/approvalRequest.js +++ b/src/lib/db-models/approvalRequest.js @@ -2,6 +2,7 @@ import pg from "./pg.js"; import EntityFields from "../utils/EntityFields.js"; import validations from "./approvalRequestValidations.js"; import employeeModel from "./employee.js"; +import typeTransform from "../utils/typeTransform.js"; class ApprovalRequest { @@ -435,6 +436,63 @@ class ApprovalRequest { } + /** + * @description Deletes an approval request (and all its revisions) + * Approval request must has only ever been in draft status + * @param {Number} approvalRequestId + * @param {String} authorizeAgainstKerberos - kerberos of user authorizing deletion + * if included, will be used to check if user is approval request owner + * @returns {Object} - {success: true} or {error: true||errorobject, message: 'error message'} + */ + async deleteDraft(approvalRequestId, authorizeAgainstKerberos){ + + // cast to positive integer, return 400 if invalid + approvalRequestId = typeTransform.toPositiveInt(approvalRequestId); + if ( !approvalRequestId ) return {error: true, message: 'Invalid approval request id', is400: true}; + + // check if approval request exists + const existingRecords = await this.get({requestIds: [approvalRequestId]}); + if ( existingRecords.error ) return existingRecords; + if ( !existingRecords.total ) return {error: true, message: 'Approval request not found', is400: true}; + + // check if user is authorized to delete + if ( authorizeAgainstKerberos ){ + const currentRecord = existingRecords.data.find(r => r.isCurrent); + if ( !currentRecord ) return {error: true, message: 'Approval request not found', is400: true}; + if ( currentRecord.employeeKerberos !== authorizeAgainstKerberos ) return {error: true, message: 'Forbidden', is403: true}; + } + + // check if any records are not in draft status + const hasNonDraft = existingRecords.data.some(r => r.approvalStatus !== 'draft'); + if ( hasNonDraft ) return {error: true, message: 'Cannot delete approval request with non-draft status', is400: true}; + + const revisionIds = existingRecords.data.map(r => r.approvalRequestRevisionId); + + // do transaction + const client = await pg.pool.connect(); + try { + await client.query('BEGIN'); + + let sql = `DELETE FROM approval_request_expenditure WHERE approval_request_revision_id = ANY($1)`; + await client.query(sql, [revisionIds]); + + sql = `DELETE FROM approval_request_funding_source WHERE approval_request_revision_id = ANY($1)`; + await client.query(sql, [revisionIds]); + + sql = `DELETE FROM approval_request WHERE approval_request_id = $1`; + await client.query(sql, [approvalRequestId]); + + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + return {error: e}; + } finally { + client.release(); + } + + return {success: true, approvalRequestId}; + } + } export default new ApprovalRequest(); diff --git a/src/lib/utils/typeTransform.js b/src/lib/utils/typeTransform.js new file mode 100644 index 0000000..732baa2 --- /dev/null +++ b/src/lib/utils/typeTransform.js @@ -0,0 +1,33 @@ +/** + * @class TypeTransform + * @description Transform/validation functions for types + */ +class TypeTransform { + + /** + * @description Convert value to a positive integer + * @param {*} value - value to convert + * @param {Boolean} convertFloat - if value is a float, convert to int - otherwise, return null + * @returns {Number|null} - positive integer or null + */ + toPositiveInt(value, convertFloat = false) { + if ( convertFloat ) { + value = parseInt(value); + } else { + value = Number(value); + } + + if ( + isNaN(value) || + value < 1 || + !Number.isInteger(value) + ) { + return null; + } + + return value; + } + +} + +export default new TypeTransform(); From f79af5505d9a03abc2f754899aeace4ba59d66b1 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Thu, 23 May 2024 16:28:40 -0400 Subject: [PATCH 082/274] #44 --- src/api/approvalRequest.js | 25 +++++++++++++------ .../app-page-approval-request-new.js | 16 ++++++++++++ .../app-page-approval-request-new.tpl.js | 7 ++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/api/approvalRequest.js b/src/api/approvalRequest.js index 81ab3f7..4f0b4b3 100644 --- a/src/api/approvalRequest.js +++ b/src/api/approvalRequest.js @@ -5,6 +5,7 @@ import protect from "../lib/protect.js"; import IamEmployeeObjectAccessor from "../lib/utils/iamEmployeeObjectAccessor.js"; import urlUtils from "../lib/utils/urlUtils.js"; import apiUtils from "../lib/utils/apiUtils.js"; +import typeTransform from "../lib/utils/typeTransform.js"; export default (api) => { @@ -51,16 +52,26 @@ export default (api) => { api.post('/approval-request', protect('hasBasicAccess'), async (req, res) => { const data = req.body || {}; + const kerberos = req.auth.token.id; - /** - * TODO: authorize user and ensure they have permission - * 1.Check if is revision of existing request - * a. if they are not the employee_kerberos, return 403 error - * b. if existing request does not have a status of 'draft', return 400 error - */ + // check if this is a revision of an existing request and if so, ensure user is authorized + if ( data.approvalRequestId ) { + const approvalRequestId = typeTransform.toPositiveInt(data.approvalRequestId); + if ( !approvalRequestId ) { + return res.status(400).json({error: true, message: 'Invalid approvalRequestId.'}); + } + const existingRequest = await approvalRequest.get({requestIds: [approvalRequestId]}); + if ( existingRequest.error ) { + console.error('Error in POST /approval-request', existingRequest.error); + return res.status(500).json({error: true, message: 'Error creating approval request.'}); + } + const existingKerberos = (existingRequest.data.find(r => r.isCurrent) || {}).employeeKerberos; + if ( existingKerberos !== kerberos ) { + return apiUtils.do403(res); + } + } // get full employee object (with department) for logged in user - const kerberos = req.auth.token.id; let employeeObj = await employee.getIamRecordById(kerberos); if ( employeeObj.error ) { console.error('Error getting employee object in POST /approval-request', employeeObj.error); diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index 83a89cf..68e94d4 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -15,6 +15,7 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) approvalRequest: {type: Object}, userCantSubmit: {type: Boolean}, canBeDeleted: {type: Boolean}, + canBeSaved: {type: Boolean} } } @@ -35,6 +36,7 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) willUpdate(props){ if ( props.has('approvalRequest') ){ this._setUserCantSubmit(); + this.canBeSaved = ['draft', 'revision-requested'].includes(this.approvalRequest.approvalStatus); } } @@ -180,6 +182,15 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) this.requestUpdate(); } + _onSaveButtonClick(){ + if ( this.userCantSubmit || !this.canBeSaved ) return; + //this._onSubmit({preventDefault: () => {}}); + } + + /** + * @description Bound to delete button click event + * Calls confirmation dialog to delete the approval request + */ _onDeleteButtonClick(){ if ( !this.canBeDeleted || this.userCantSubmit ) return; this.AppStateModel.showDialogModal({ @@ -203,6 +214,11 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) this.ApprovalRequestModel.delete(e.data.approvalRequestId); } + /** + * @description Bound to ApprovalRequestModel approval-request-deleted event + * @param {Object} e - ApprovalRequestModel approval-request-deleted event + * @returns + */ _onApprovalRequestDeleted(e){ if ( e.state === 'loading' ){ this.AppStateModel.showLoading(); diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index d0027a1..1ecd832 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -214,6 +214,13 @@ export function renderForm(){ @click=${this._onSubmit} ?disabled=${this.userCantSubmit} >Review and Submit +
+
+ Estimated Expenses +
+
+
+ this._onFormInput('noExpenditures', e.target.checked)} + > + +
+
${this.validationHandler.renderErrorMessages('noExpenditures')}
+
+
+ +
+
+ +
+ ${this.expenditureOptions.map(expenditure => renderExpenditureItem.call(this, expenditure))} +
+
+
+
+
+ +
+ + this._onFormInput('mileage', e.target.value)} + > +
${unsafeHTML(this.SettingsModel.getByKey('mileage_rate_description'))}
+
+ +
+
${this.validationHandler.renderErrorMessages('expenditures')}
${this.expenditureOptions.map(expenditure => renderExpenditureItem.call(this, expenditure))}
+
+
Total Estimated Expenses
+
$${this.totalExpenditures.toFixed(2)}
+
@@ -262,17 +279,42 @@ export function renderForm(){ /** * @description Render a single expenditure item - * @param {*} expenditure + * @param {Object} expenditure - expenditure option object * @returns */ function renderExpenditureItem(expenditure){ + + let value = this.approvalRequest.expenditures.find(e => e.expenditureOptionId === expenditure.expenditureOptionId)?.amount || ''; + + // personal car mileage - needs special handling because it's a calculated field + if ( expenditure.expenditureOptionId == 6 ){ + value = value ? value.toFixed(2) : '0.00' + return html` +
+
+
${expenditure.label}
+
Amount is computed using mileage field above
+
+
$${value}
+
+ `; + } + + return html`
${expenditure.label}
${unsafeHTML(expenditure.description)}
-
+
+ this._onExpenditureInput(expenditure.expenditureOptionId, e.target.value)} + > +
`; } diff --git a/src/client/scss/global-form.scss b/src/client/scss/global-form.scss new file mode 100644 index 0000000..de05381 --- /dev/null +++ b/src/client/scss/global-form.scss @@ -0,0 +1,68 @@ +.field-error { + input { + border: 1px solid #c10230; + } + textarea { + border: 1px solid #c10230; + } + .field-error-message { + color: #c10230; + display: block; + } + label { + color: #c10230; + } +} +fieldset.field-error { + border-top: 3px solid #c10230; + + > legend { + color: #c10230; + } +} +.field-error-message { + display: none; +} +.field-help { + font-size: .875rem; + color: #13639e; +} +.radio .option-description, .checkbox .option-description { + padding-left: 2.5rem; + font-weight: 400; + font-size: .875rem; + margin-bottom: .5rem; +} +.field-container { + margin-bottom: 1rem !important; +} + +.input--dollar { + + position: relative; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset; + border: 1px solid #999; + max-width: 150px; + + &::before { + content: '$'; + position: absolute; + left: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + min-width: 2rem; + color: #022851; + background-color: #DBEAF7 + } + + input { + padding-left: 2.5rem; + border: none; + box-shadow: none; + } +} + diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index a509c9b..b5a93bc 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -50,45 +50,6 @@ } -.field-error { - input { - border: 1px solid #c10230; - } - textarea { - border: 1px solid #c10230; - } - .field-error-message { - color: #c10230; - display: block; - } - label { - color: #c10230; - } -} -fieldset.field-error { - border-top: 3px solid #c10230; - - > legend { - color: #c10230; - } -} -.field-error-message { - display: none; -} -.field-help { - font-size: .875rem; - color: #13639e; -} -.radio .option-description, .checkbox .option-description { - padding-left: 2.5rem; - font-weight: 400; - font-size: .875rem; - margin-bottom: .5rem; -} -.field-container { - margin-bottom: 1rem !important; -} - .pad-icon-top { padding-top: .3rem; diff --git a/src/client/scss/pages/approval-request-new.scss b/src/client/scss/pages/approval-request-new.scss index 8339027..8a0763e 100644 --- a/src/client/scss/pages/approval-request-new.scss +++ b/src/client/scss/pages/approval-request-new.scss @@ -11,12 +11,31 @@ app-page-approval-request-new { .expenditure-item { display: flex; align-items: center; - margin-bottom: 1rem; + margin-bottom: 2rem; .text { flex: 1; margin-right: 1rem; } } + + .expenditures-total { + display: flex; + justify-content: space-between; + font-weight: 700; + color: #022851; + border-top: 1px solid #DBEAF7; + padding: 1rem 0; + } + + @media (max-width: 480px){ + .expenditure-item { + display: block; + + .text { + margin-bottom: 1rem; + } + } + } } } diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index 98789f4..dad003c 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -1,6 +1,10 @@ +// global styles @use '@ucd-lib/theme-sass/style-ucdlib.scss' as style; @use './fonts.scss' as fonts; @use './global.scss' as global; +@use './global-form.scss' as globalForm; + +// scoped styles // admin pages @use './pages/admin/settings.scss' as adminSettings; From cec183a54c30eecc8cc55064622920d3a4e4aed5 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Wed, 29 May 2024 11:49:25 -0700 Subject: [PATCH 087/274] stash --- deploy/db-entrypoint/004-settings.sql | 5 + src/client/js/components/app-approver-type.js | 230 +++++++++++++++--- .../js/components/app-approver-type.tpl.js | 101 +++++--- .../pages/admin/app-page-admin-approvers.js | 4 + .../pages/admin/app-page-admin-line-items.js | 27 +- src/client/scss/components/approver-type.scss | 35 ++- src/lib/cork/models/AdminApproverTypeModel.js | 3 + src/lib/cork/stores/AdminApproverTypeStore.js | 1 + 8 files changed, 323 insertions(+), 83 deletions(-) diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index 216c4ff..5860a83 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -24,3 +24,8 @@ INSERT INTO "settings" ("key", "value", "label", "description", "default_value", VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('admin_line_items_form_order_help', '', 'Admin - Line Items Order Help Text', NULL, 'Changes the order in which the line item is displayed on the approval request form.', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '0'); + +-- admin approver type and funding sources page +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approver_type_description', '', 'Admin - Approver Type Description', 'Displays on top of approver type admin settings page', 'When a request is submitted to this application, approval is required from a list of employees determined by the funding source. Employees must be registered as an approver before they can be added to the approval chain of a funding source. Some approver types are automatically generated by this system and cannot be removed in this section.', '1', NULL, '10', 'textarea', '{admin-approver-form,admin-settings}', '1'); + diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index e5e8c28..b4f6f75 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -1,9 +1,10 @@ -import { LitElement } from 'lit'; +import { html, LitElement } from 'lit'; import {render, styles} from "./app-approver-type.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; import "./ucdlib-employee-search-basic.js" import ValidationHandler from "../utils/ValidationHandler.js"; +import IamEmployeeObjectAccessor from '../../../lib/utils/iamEmployeeObjectAccessor.js'; export default class AppApproverType extends Mixin(LitElement) @@ -13,6 +14,7 @@ export default class AppApproverType extends Mixin(LitElement) return { existingApprovers:{type: Array, attribute: 'existingApprovers'}, newApproverType:{type: Object, attribute: 'newApproverType'}, + approver: {type: Object, attribute: 'approver'}, } } @@ -26,22 +28,30 @@ export default class AppApproverType extends Mixin(LitElement) this.id = 'admin-approvers'; this.existingApprovers = []; this.newApproverType = {}; + + + this.approver = {}; + this.new = false + + + //may delete + this.employeeIndex = []; + this.newEmployees = []; + this.index = 0; + + + + this.render = render.bind(this); this._injectModel('AppStateModel', 'AdminApproverTypeModel', 'SettingsModel'); - this.SettingsModel.getByCategory('admin-approver-form'); this._resetProperties(); } - - /** - * @description lit lifecycle method - */ - willUpdate(changedProps) { - if ( changedProps.has('newApproverType') ) { - this.requestUpdate(); - } - } + _newForm(e) { + this.new = true; + this._refreshProperties(); + } /** * @description bound to AppStateModel app-state-update event @@ -51,11 +61,36 @@ export default class AppApproverType extends Mixin(LitElement) if ( state.page != this.id ) return; this.AppStateModel.showLoading(); + const d = await this.getPageData(); + const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + if ( hasError ) { + this.AppStateModel.showError(d); + return; + } + + let category = await this.SettingsModel.getByCategory('admin-approver-form'); + this.description = category.payload[0].value; + + let args = {status:"active"}; //if want all active do this to see your new ones + + await this.AdminApproverTypeModel.query(args); + + this.AppStateModel.showLoaded(this.id); - this._getApproverType(); this.requestUpdate(); } + + + /** + * @description Get all data required for rendering this page + */ + async getPageData(){ + const promises = []; + promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); + const resolvedPromises = await Promise.allSettled(promises); + return resolvedPromises; + } /** @@ -78,7 +113,6 @@ export default class AppApproverType extends Mixin(LitElement) * */ async _refreshProperties(){ - // this._getApproverType(); this._resetProperties(); this.AppStateModel.refresh(); } @@ -114,6 +148,72 @@ export default class AppApproverType extends Mixin(LitElement) // }); // } + + /** + * @description Event handler for when employees are selected from the employee search component + * @param {CustomEvent} e - status-change event from ucdlib-employee-search-basic + */ + _onEmployeeSelect(e, index) { + let emp = e.detail.employee; + console.log("E:", emp); + if(emp){ + const newEmployees = []; + emp = (new IamEmployeeObjectAccessor(emp)).travelAppObject; + + if( this.employeeIsSelected(emp) ) return; //not yet wait for submit + let empForm = { "employee" : emp, "approvalOrder": index} + this.newEmployees.push(empForm); + } + //Plan for if it is taken out of employee form + // this.newEmployees = [...this.newEmployees, ...newEmployees]; + } + + + /** + * @description Check if an employee is already in the selected list + * @param {Object} employee - employee object + * @returns + */ + employeeIsSelected(employee) { + if(this.newEmployees == null) return; + return this.newEmployees.find(e => e.employee.kerberos === employee.kerberos); + } + + _onAddBar(e, approver){ + approver.employees.push({}); + this.index = this.index + 1; + // if(!this.index){ + // this.employeeIndex.push('employee-bar-0'); + // }else { + // this.employeeIndex.push(`employee-bar-${this.index}`); + // } + + // this.index = this.index + 1; + // this.employeeIndex = this.employeeIndex.filter((item, index) => this.employeeIndex.indexOf(item) === index) + this.requestUpdate(); + } + _onDeleteBar(e, index){ + + if(this.index != 0) this.index = this.index - 1; + + + + + // let parentID = e.currentTarget.id; + // const r = /\d+/; + // let deleteID = parentID.match(r)[0]; + // let id = `employee-bar-${deleteID}`; + // let hashId = '#' + id; + + // this.renderRoot.querySelector(hashId).remove(); + + // this.employeeIndex = this.employeeIndex.filter(item => item !== hashId); + // let index = this.employeeIndex.indexOf(id); + delete this.approver.employees[index]; + this.requestUpdate(); + + } + async _setLabel(value, approver){ approver.label = value; this.requestUpdate(); @@ -124,18 +224,74 @@ export default class AppApproverType extends Mixin(LitElement) this.requestUpdate(); } + isEqual(obj1, obj2) { + var props1 = Object.getOwnPropertyNames(obj1); + var props2 = Object.getOwnPropertyNames(obj2); + if (props1.length != props2.length) { + return false; + } + for (var i = 0; i < props1.length; i++) { + let val1 = obj1[props1[i]]; + let val2 = obj2[props1[i]]; + let isObjects = this.isObject(val1) && this.isObject(val2); + if (isObjects && !this.isEqual(val1, val2) || !isObjects && val1 !== val2) { + return false; + } + } + return true; + } + isObject(object) { + return object != null && typeof object === 'object'; + } + /** * @description Returns a approver type from the element's approverType array by approverTypeId */ getApproverTypeId(id){ return this.existingApprovers.find(item => item.approverTypeId == id); } + + async _onApproverTypeQueryRequest(e){ + let query = e[1]; + e = e[0]; + + if ( e.state === 'error' ) { + if ( e.error?.payload?.is400 ) { + this.AppStateModel.showToast({message: 'Error when querying the approver types. Query needs to be fixed.', type: 'error'}) + + } else { + this.AppStateModel.showToast({message: 'An unknown error ocurred when updating the approver type', type: 'error'}) + this.AppStateModel.showLoaded(this.id) + } + await this.waitController.waitForFrames(3); + window.scrollTo(0, this.lastScrollPosition); + } else if ( e.state === 'loading' ) { + this.AppStateModel.showLoading(); + + } else if ( e.state === 'loaded' && this.isEqual(query, {'status':'active'})) { + let approverArray = e.payload.filter(function (el) { + return el.archived == false && + el.hideFromFundAssignment == false; + }); + + approverArray.map((emp) => { + if(!Array.isArray(emp.employees)) emp.employees = [emp.employees] + }); + + this.existingApprovers = approverArray; + + this.requestUpdate(); + } + + } + + /** * @description bound to AdminApproverTypeModel APPROVER_TYPE_UPDATED event */ async _onApproverTypeUpdated(e){ if ( e.state === 'error' ) { - if ( e.error?.payload?.is400 ) { + if ( e.error?.payload?.is500 ) { const getApproverTypeId = e?.payload?.getApproverTypeId; const approverType = this.getApproverTypeId(getApproverTypeId); approverType.validationHandler = new ValidationHandler(e); @@ -155,7 +311,7 @@ export default class AppApproverType extends Mixin(LitElement) } else if ( e.state === 'loaded' ) { this._refreshProperties(); let archived = e.payload.data.res[0].archived; - + console.log('E:', e.payload.data) if ( archived ) { this.AppStateModel.showToast({message: 'Approver Type deleted successfully', type: 'success'}); } else { @@ -169,7 +325,7 @@ export default class AppApproverType extends Mixin(LitElement) */ async _onApproverTypeCreated(e){ if ( e.state === 'error' ) { - if ( e.error?.payload?.is400 ) { + if ( e.error?.payload?.is500 ) { this.newApproverType.validationHandler = new ValidationHandler(e); this.AppStateModel.showLoaded(this.id) this.requestUpdate(); @@ -214,20 +370,28 @@ export default class AppApproverType extends Mixin(LitElement) * * */ - async _onNewSubmit(e){ + async _onFormSubmit(e){ e.preventDefault(); this.lastScrollPosition = window.scrollY; const approverTypeId = e.target.getAttribute('approver-type-id'); if ( approverTypeId != 0 && approverTypeId) { let approverType = this.existingApprovers.find(a => a.approverTypeId == approverTypeId); + + approverType.employees = []; + console.log("Update:", approverType); + approverType.employees = this.newEmployees; delete approverType.editing; - approverType = this.employeeFormat(approverType); + // approverType = this.employeeFormat(approverType); + console.log("Update 2:", approverType); console.log(`Done Updating ${approverTypeId} ...`); await this.AdminApproverTypeModel.update(approverType); } else { + this.newApproverType.employees = this.newEmployees; + console.log("Create:", this.newApproverType); this.newApproverType = this.employeeFormat(this.newApproverType); + console.log("Create 2:", this.newApproverType); await this.AdminApproverTypeModel.create(this.newApproverType); console.log("Done Creating..."); @@ -240,17 +404,12 @@ export default class AppApproverType extends Mixin(LitElement) * */ async _onEdit(e, approver){ + this.initial = approver; approver.editing = true; + this.requestUpdate(); } - - - - - - - /** * @description on edit button from a approver * @returns {Array} array of objects with updated employees @@ -290,13 +449,21 @@ export default class AppApproverType extends Mixin(LitElement) */ async _onEditCancel(e, approver){ if (!approver.approverTypeId) { - newApproverType = {}; + this.newApproverType = {}; return; } - + approver = this.initial; + approver.editing = false; approver.validationHandler = new ValidationHandler(); + this.new = false; + for(let i = this.index; i > 0; i--){ + approver.employees.pop() + } + this.index = 0; + + this.requestUpdate(); } @@ -338,17 +505,14 @@ export default class AppApproverType extends Mixin(LitElement) * */ async _getApproverType(){ - // let args = {status:"active"}; //if want all active do this to see your new ones - let args = { id: [1,2,3,4,5,112,113,114,115,116,117,118]}; - - let approvers = await this.AdminApproverTypeModel.query(args); + let approverArray = approvers.payload.filter(function (el) { return el.archived == false && el.hideFromFundAssignment == false; }); approverArray.map((emp) => { - if(!Array.isArray(emp.employees)) emp.employees = [emp.employees] - }); + if(!Array.isArray(emp.employees)) emp.employees = [emp.employees] + }); this.existingApprovers = approverArray; diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index f76f2d4..62857e9 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -18,7 +18,7 @@ return html`

Approvers

-

${unsafeHTML(this.SettingsModel.getByKey('approver_type_description'))}

+

${unsafeHTML(this.description)}

@@ -28,7 +28,10 @@ return html` })}
- ${renderApproverForm.call(this, this.newApproverType)} + ${this.new ? html`${renderApproverForm.call(this, this.newApproverType)}`:html` +

+ `} +
@@ -54,61 +57,99 @@ function renderApproverItem(ap) { `:html``} - - -
${ap.description}
- - ${ap.systemGenerated || ap.employees[0] != null ? html` -
+
${ap.description}
+
${ap.employees.map((employee) => html` - -  ${!ap.systemGenerated ? html`${employee.firstName} ${employee.lastName}`:html`System Generated`}
-
+
+  ${!ap.systemGenerated && ap.employees[0] ? html`${employee.firstName} ${employee.lastName}`:html`System Generated`}
+
`)}
- `:html``}
` } function renderApproverForm(approver) { if ( !approver || Object.keys(approver).length === 0 ) return html``; + const title = "Edit Approver" const approverId = approver.approverTypeId || 'new'; const inputIdLabel = `approver-label-${approverId}`; const inputIdDescription = `approver-description-${approverId}`; + this.approver = approver; return html` -
+
-

Edit Approver +

${this.approver.editing ? html`Edit Approver`:html`Add Approver`}
- this._setLabel(e.target.value, approver)} type="text" placeholder="Position Title"> + this._setLabel(e.target.value, this.approver)} type="text" placeholder="Position Title">
- -
+ +

- + + + ${this.approver.editing ? html` + ${this.approver.employees.map((emp, index) => html` +
+ + this._onDeleteBar(e, index)} class='icon-link double-decker'> + + +
+ `)} + `:html` + ${this.approver.employees.map((emp, index) => html` +
+ + this._onDeleteBar(e, index)} class='icon-link double-decker'> + + +
+ `)} + `} + + + +

+ + +

+
+ + +
- ${approver.editing ? html` - -

- - -

-
- `:html``} - - ${approverId == 'new' ? - html`

` + + diff --git a/src/client/js/pages/admin/app-page-admin-approvers.js b/src/client/js/pages/admin/app-page-admin-approvers.js index 7ac55d1..8ad0a6c 100644 --- a/src/client/js/pages/admin/app-page-admin-approvers.js +++ b/src/client/js/pages/admin/app-page-admin-approvers.js @@ -15,6 +15,7 @@ export default class AppPageAdminApprovers extends Mixin(LitElement) constructor() { super(); this.render = render.bind(this); + this.settingsCategory = 'admin-approver-type'; this._injectModel('AppStateModel', 'SettingsModel'); } @@ -34,6 +35,9 @@ export default class AppPageAdminApprovers extends Mixin(LitElement) ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); + + + } diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index 15ea476..aaa5a23 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -59,15 +59,15 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); - const d = await this.getPageData(); - const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); - if ( hasError ) { - this.AppStateModel.showError(d); - return; + try { + const d = await this.getPageData(); + const hasError = d.some(e => e.state === 'error'); + if ( !hasError ) this.AppStateModel.showLoaded(this.id); + this.requestUpdate(); + } catch(e) { + this.AppStateModel.showError(this.id); } - this.AppStateModel.showLoaded(this.id); - this.requestUpdate(); } /** @@ -182,7 +182,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) /** * @description bound to LineItemsModel LINE_ITEM_CREATED event */ - async _onLineItemCreated(e){ + async _onLineItemCreated(e){ if ( e.state === 'error' ) { if ( e.error?.payload?.is400 ) { this.newLineItem.validationHandler = new ValidationHandler(e); @@ -225,10 +225,6 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } } - /** - * @description Bound to delete button for each line item - * @param {Object} lineItem - line item object to delete - */ _onDeleteClick(lineItem){ this.AppStateModel.showDialogModal({ title : 'Delete Line Item', @@ -241,11 +237,6 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) }); } - /** - * @description Callback for dialog-action AppStateModel event - * @param {Object} e - AppStateModel dialog-action event - * @returns - */ _onDialogAction(e){ if ( e.action !== 'delete-line-item' ) return; const lineItem = e.data.lineItem; @@ -260,7 +251,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) const promises = []; promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); promises.push(this.LineItemsModel.getActiveLineItems()); - const resolvedPromises = await Promise.allSettled(promises); + const resolvedPromises = await Promise.all(promises); return resolvedPromises; } diff --git a/src/client/scss/components/approver-type.scss b/src/client/scss/components/approver-type.scss index 7396621..3d85944 100644 --- a/src/client/scss/components/approver-type.scss +++ b/src/client/scss/components/approver-type.scss @@ -19,8 +19,24 @@ app-approver-type { cursor: pointer; } - .field-container{ - font-size:19px; + .field-container { + font-size:20px; + } + + .employeeLabel{ + width:90%; + display: inline-table; + } + + + .employee-search-bar{ + width: 90%; + display: inline-table; + + } + + .icon-employee-minus { + display: inline-flex; } .textLabel{ @@ -37,5 +53,20 @@ app-approver-type { .btn { margin: 15px 10px 5px 0px; } + .approverList{ + color:#022851; + width:15px; + height:15px; + } + .employeeBlock{ + display:inline; + } + + #icon-employee-minus{ + width: 24px; + height: 24px; + text-align:center; + align-items: center; + } } diff --git a/src/lib/cork/models/AdminApproverTypeModel.js b/src/lib/cork/models/AdminApproverTypeModel.js index 99f2555..2f7192e 100644 --- a/src/lib/cork/models/AdminApproverTypeModel.js +++ b/src/lib/cork/models/AdminApproverTypeModel.js @@ -23,6 +23,7 @@ class AdminApproverTypeModel extends BaseModel { * */ async query(args = {}) { + let query = args; args = urlUtils.queryStringFromObject(args); let state = this.store.data.query[args]; @@ -34,6 +35,8 @@ class AdminApproverTypeModel extends BaseModel { } } catch(e) {} + this.store.emit(this.store.events.APPROVER_TYPE_QUERY_REQUEST, [this.store.data.query[args],query]); + return this.store.data.query[args]; } diff --git a/src/lib/cork/stores/AdminApproverTypeStore.js b/src/lib/cork/stores/AdminApproverTypeStore.js index 945a8be..9d92ed6 100644 --- a/src/lib/cork/stores/AdminApproverTypeStore.js +++ b/src/lib/cork/stores/AdminApproverTypeStore.js @@ -11,6 +11,7 @@ class AdminApproverTypeStore extends BaseStore { update: {} }; this.events = { + APPROVER_TYPE_QUERY_REQUEST: 'approverType-query-request', APPROVER_TYPE_QUERIED: 'approverType-queried', APPROVER_TYPE_CREATED: 'approverType-created', APPROVER_TYPE_UPDATED: 'approverType-updated' From 48e712b4043157b1dd9692e321ec6295de2603c8 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 29 May 2024 15:25:29 -0400 Subject: [PATCH 088/274] approval request funding source form #44 #46 --- .../js/components/funding-source-select.js | 238 ++++++++++++++++++ .../components/funding-source-select.tpl.js | 100 ++++++++ .../admin/app-page-admin-allocations-new.js | 22 +- .../app-page-approval-request-new.js | 32 ++- .../app-page-approval-request-new.tpl.js | 17 ++ src/client/js/utils/ValidationHandler.js | 13 + .../components/funding-source-select.scss | 56 +++++ src/client/scss/global-form.scss | 3 +- src/client/scss/global.scss | 3 + src/client/scss/style.scss | 3 +- src/lib/db-models/approvalRequest.js | 9 +- .../db-models/approvalRequestValidations.js | 2 +- src/lib/utils/promiseUtils.js | 33 +++ 13 files changed, 507 insertions(+), 24 deletions(-) create mode 100644 src/client/js/components/funding-source-select.js create mode 100644 src/client/js/components/funding-source-select.tpl.js create mode 100644 src/client/scss/components/funding-source-select.scss create mode 100644 src/lib/utils/promiseUtils.js diff --git a/src/client/js/components/funding-source-select.js b/src/client/js/components/funding-source-select.js new file mode 100644 index 0000000..de80982 --- /dev/null +++ b/src/client/js/components/funding-source-select.js @@ -0,0 +1,238 @@ +import { LitElement } from 'lit'; +import { render } from "./funding-source-select.tpl.js"; + +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; + +/** + * @class FundingSourceSelect + * @description Component that either + * 1. Displays a list of funding sources with amounts and descriptions + * 2. Displays a form for editing funding sources + * + * @property {Array} data - Array of funding source objects + * @property {String} label - Label for the component + * @property {Number} expenditureTotal - Total expenditure amount - used to validate funding source amounts + * @property {Boolean} formView - Whether to display the form view + * @property {Boolean} canToggleView - Whether the view can be toggled by the user + * @property {Boolean} reallocateOnly - Whether the user can only reallocate funds (not add or remove sources) + * @property {String} customError - Custom error message to display + * @property {Boolean} alwaysShowOne - Whether to always show one funding source in the form view + * @property {Array} activeFundingSources - State property - Array of active funding sources from the server + * @property {Number} fundingSourceTotal - State property - Total funding amount from the data array + * @property {Boolean} hasError - State property - Whether the form has an error + * @property {String} errorMessage - State property - Error message to display + */ +export default class FundingSourceSelect extends Mixin(LitElement) + .with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + data: {type: Array}, + label: {type: String}, + expenditureTotal: {type: Number, attribute: 'expenditure-total'}, + formView: {type: Boolean, attribute: 'form-view'}, + canToggleView: {type: Boolean, attribute: 'can-toggle-view'}, + reallocateOnly: {type: Boolean, attribute: 'reallocate-only'}, + customError: {type: String, attribute: 'custom-error'}, + alwaysShowOne: {type: Boolean, attribute: 'always-show-one'}, + activeFundingSources: {type: Array, state: true}, + fundingSourceTotal: {type: Number, state: true}, + hasError: {type: Boolean, state: true}, + errorMessage: {type: String, state: true} + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this.formView = false; + this.canToggleView = false; + this.expenditureTotal = 0; + this.reallocateOnly = false; + this.customError = ''; + this.label = 'Funding Sources'; + this.alwaysShowOne = false; + + this.activeFundingSources = []; + this.data = []; + + this._injectModel('FundingSourceModel'); + } + + /** + * @description Lit lifecycle method callback + * @param {Map} props - changed properties + */ + willUpdate(props){ + if ( props.has('data') ) { + this._setFundingSourceTotal(); + + if ( this.alwaysShowOne && this.data.length === 0 ){ + this._pushBlankFundingSource(); + } + } + + this._setErrorState(props); + } + + /** + * @description Sets the total funding amount from the data array + */ + _setFundingSourceTotal(){ + this.fundingSourceTotal = this.data.reduce((total, source) => { + return total + source.amount; + }, 0); + } + + /** + * @description Set form error state based on current properties + * @param {Map} props - changed props + * @returns + */ + _setErrorState(props){ + const watched = ['expenditureTotal', 'fundingSourceTotal', 'customError', 'formView' ]; + if ( !watched.some(prop => props.has(prop)) ) return; + + if ( !this.formView ){ + this.hasError = false; + return; + } + + if ( this.customError ){ + this.hasError = true; + this.errorMessage = this.customError; + return; + } + + if ( this.expenditureTotal !== this.fundingSourceTotal ) { + this.hasError = true; + this.errorMessage = `Total funding amount must equal total expenditure amount of $${this.expenditureTotal.toFixed(2)}`; + return; + } + + this.hasError = false; + this.errorMessage = ''; + + } + + + /** + * @description Attached to input events on funding sources when in form view + * @param {Object} fundingSource - A funding source object from the data property array + * @param {String} prop - name of property to update + * @param {*} value - value to update property with + */ + async _onFundingSourceInput(fundingSource, prop, value){ + if (prop === 'fundingSourceId') { + value = parseInt(value); + fundingSource.description = ''; + fundingSource.requireDescription = this.activeFundingSources.find(source => source.fundingSourceId === value).requireDescription; + } + if ( prop === 'amount' ) { + value = Number(value); + } + fundingSource[prop] = value; + + this._setFundingSourceTotal(); + this.requestUpdate(); + await this.updateComplete; + + this._dispatchEvent(); + } + + /** + * @description Attached to add click event on funding source + * @returns + */ + _onAddClick(){ + if ( this.reallocateOnly ) return; + this._pushBlankFundingSource(); + this.requestUpdate(); + + } + + /** + * @description Attached to delete click event on funding source + * Removes a funding source from the data array + * @param {Number} index - index of funding source to remove + */ + async _onDeleteClick(index){ + if ( this.reallocateOnly ) return; + this.data.splice(index, 1); + + this._setFundingSourceTotal(); + this.requestUpdate(); + await this.updateComplete; + + this._dispatchEvent(); + + + if ( index === 0 && this.alwaysShowOne ) { + this._pushBlankFundingSource(); + } + } + + /** + * @description Attached to toggle view click event + * @returns {Boolean} + */ + _onToggleViewClick(){ + if ( !this.canToggleView ) return; + this.formView = !this.formView; + } + + /** + * @description Add a blank funding source to the data array - for editing interface + */ + _pushBlankFundingSource(){ + this.data.push({ + fundingSourceId: null, + amount: 0, + description: '', + requireDescription: false + }); + } + + /** + * @description Dispatch funding-source-input event + */ + _dispatchEvent(){ + this.dispatchEvent(new CustomEvent('funding-source-input', { + bubbles: true, + composed: true, + detail: { + fundingSources: this.data, + hasError: this.hasError + } + })); + } + + /** + * @description Retrieve necessary data for component + * @returns {Promise} + */ + async init(){ + const promises = [ + this.FundingSourceModel.getActiveFundingSources() + ]; + + return await Promise.allSettled(promises); + } + + /** + * @description Attached to active-funding-sources-fetched event from FundingSourceModel + * Fires when active funding sources are fetched from the server + * @param {Object} e - cork-app-utils event object + * @returns + */ + _onActiveFundingSourcesFetched(e) { + if ( e.state !== 'loaded' ) return; + this.activeFundingSources = e.payload.filter(source => source.fundingSourceId !== 8); + } + +} + +customElements.define('funding-source-select', FundingSourceSelect); diff --git a/src/client/js/components/funding-source-select.tpl.js b/src/client/js/components/funding-source-select.tpl.js new file mode 100644 index 0000000..d296c9b --- /dev/null +++ b/src/client/js/components/funding-source-select.tpl.js @@ -0,0 +1,100 @@ +import { html } from 'lit'; + +export function render() { + return html` +
+
${this.label}
+ +
+ ${this.formView ? renderForm.call(this) : renderList.call(this)} +
+
Total
+
$${this.fundingSourceTotal.toFixed(2)}
+
+ `; +} + +/** + * @description Render the form view + */ +function renderForm(){ + return html` +
+
+ + ${this.errorMessage} +
+
+ ${this.data.map((fundingSource, index) => html` +
+ +
+
+
+ +
+
+
+
+ this._onFundingSourceInput(fundingSource, 'amount', e.target.value)} + > +
+
+
+
+ + +
+ + this._onDeleteClick(index)} class='double-decker small pointer' ?hidden=${this.reallocateOnly}>delete +
+ `)} +
+
+ + `; +} + + +/** + * @description Render the read-only list view + */ +function renderList(){ + return html``; +} diff --git a/src/client/js/pages/admin/app-page-admin-allocations-new.js b/src/client/js/pages/admin/app-page-admin-allocations-new.js index 8131a5e..ca89e18 100644 --- a/src/client/js/pages/admin/app-page-admin-allocations-new.js +++ b/src/client/js/pages/admin/app-page-admin-allocations-new.js @@ -1,10 +1,13 @@ import { LitElement } from 'lit'; -import {render} from "./app-page-admin-allocations-new.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { render } from "./app-page-admin-allocations-new.tpl.js"; +import { createRef } from 'lit/directives/ref.js'; + import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; import { WaitController } from "@ucd-lib/theme-elements/utils/controllers/wait.js"; -import { createRef } from 'lit/directives/ref.js'; + +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import IamEmployeeObjectAccessor from '../../../../lib/utils/iamEmployeeObjectAccessor.js'; +import promiseUtils from '../../../../lib/utils/promiseUtils.js'; import ValidationHandler from "../../utils/ValidationHandler.js"; /** @@ -76,7 +79,7 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) this.AppStateModel.setBreadcrumbs(breadcrumbs); const d = await this.getPageData(); - const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + const hasError = promiseUtils.hasError(d); if( hasError ) { this.AppStateModel.showError(d); return; @@ -225,16 +228,7 @@ export default class AppPageAdminAllocationsNew extends Mixin(LitElement) promises.push(this.FundingSourceModel.getActiveFundingSources()); const resolvedPromises = await Promise.allSettled(promises); - // flatten resolved promises - employee search returns an array of promises - const out = []; - resolvedPromises.forEach(p => { - if ( Array.isArray(p.value) ) { - out.push(...p.value); - } else { - out.push(p); - } - }); - return out; + return promiseUtils.flattenAllSettledResults(resolvedPromises); } diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index facaceb..a52cd42 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -1,10 +1,14 @@ import { LitElement } from 'lit'; -import {render} from "./app-page-approval-request-new.tpl.js"; -import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; +import { render } from "./app-page-approval-request-new.tpl.js"; +import { createRef } from 'lit/directives/ref.js'; + import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +import { WaitController } from "@ucd-lib/theme-elements/utils/controllers/wait.js"; +import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import ValidationHandler from "../../utils/ValidationHandler.js"; import urlUtils from "../../../../lib/utils/urlUtils.js"; +import promiseUtils from '../../../../lib/utils/promiseUtils.js'; export default class AppPageApprovalRequestNew extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -30,6 +34,9 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) this.settingsCategory = 'approval-requests'; this.expenditureOptions = []; + this.fundingSourceSelectRef = createRef(); + this.waitController = new WaitController(this); + this._injectModel( 'AppStateModel', 'SettingsModel', 'ApprovalRequestModel', 'AuthModel', 'LineItemsModel' @@ -111,15 +118,20 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) * @description Get all data required for rendering this page */ async getPageData(){ + + // need to ensure that funding-source-select element has been rendered before we can initialize it + await this.waitController.waitForUpdate(); + const promises = [ this.SettingsModel.getByCategory(this.settingsCategory), - this.LineItemsModel.getActiveLineItems() + this.LineItemsModel.getActiveLineItems(), + this.fundingSourceSelectRef.value.init() ]; if ( this.approvalFormId ) { promises.push(this.ApprovalRequestModel.query({requestIds: this.approvalFormId})); } const resolvedPromises = await Promise.allSettled(promises); - return resolvedPromises; + return promiseUtils.flattenAllSettledResults(resolvedPromises); } _onActiveLineItemsFetched(e){ @@ -207,6 +219,15 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) this.requestUpdate(); } + /** + * @description bound to funding-source-select input event + * @param {Object} e - funding-source-select input event + */ + _onFundingSourceInput(e){ + this.approvalRequest.fundingSources = e?.detail?.fundingSources || []; + this.requestUpdate(); + } + /** * @description bound to ApprovalRequestModel approval-requests-fetched event * Handles setting the form state based on a previously saved (or submitted and rejected) approval request @@ -250,7 +271,8 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) resetForm(){ this.approvalRequest = { approvalStatus: 'draft', - expenditures: [] + expenditures: [], + fundingSources: [] }; this.validationHandler = new ValidationHandler(); this.canBeDeleted = false; diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index 14aa1be..c8b4e67 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -1,5 +1,8 @@ import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { ref } from 'lit/directives/ref.js'; + +import "../../components/funding-source-select.js"; export function render() { return html` @@ -251,6 +254,20 @@ export function renderForm(){
+
+ Funding Sources + + +
+
${this.existingApprovers.map((approver) => { if(approver.editing) return renderApproverForm.call(this, approver); @@ -84,9 +83,10 @@ function renderApproverForm(approver) {

${this.approver.editing ? html`Edit Approver`:html`Add Approver`} -
+
this._setLabel(e.target.value, this.approver)} type="text" placeholder="Position Title"> + ${approver.validationHandler.renderErrorMessages('label')}
@@ -102,14 +102,18 @@ function renderApproverForm(approver) { ${this.approver.editing ? html` ${this.approver.employees.map((emp, index) => html` -
+
this._onDeleteBar(e, index)} class='icon-link double-decker'> diff --git a/src/lib/cork/services/AdminApproverTypeService.js b/src/lib/cork/services/AdminApproverTypeService.js index 44ed520..d2db367 100644 --- a/src/lib/cork/services/AdminApproverTypeService.js +++ b/src/lib/cork/services/AdminApproverTypeService.js @@ -45,7 +45,7 @@ class AdminApproverTypeService extends BaseService { json: true, onLoading : request => this.store.updateLoading(request), onLoad : result => this.store.updateLoaded(result.body), - onError : e => this.store.updateError(e) + onError : e => this.store.updateError(e, data) }); } diff --git a/src/lib/cork/stores/AdminApproverTypeStore.js b/src/lib/cork/stores/AdminApproverTypeStore.js index 9d92ed6..6b6e77d 100644 --- a/src/lib/cork/stores/AdminApproverTypeStore.js +++ b/src/lib/cork/stores/AdminApproverTypeStore.js @@ -87,8 +87,9 @@ class AdminApproverTypeStore extends BaseStore { updateError(error, id, data) { this._setUpdateState({ state : this.STATE.ERROR, - error - }, data); + error, + data + }); } _setUpdateState(state) { From becbeffdc444dfb8994294d1917ae4187b985061 Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Thu, 30 May 2024 17:51:57 -0700 Subject: [PATCH 090/274] add content to page --- deploy/db-entrypoint/004-settings.sql | 12 ++++- src/client/js/pages/admin/app-page-admin.js | 31 +++++------- .../js/pages/admin/app-page-admin.tpl.js | 47 ++++++++++++++----- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index 216c4ff..540c2a9 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -1,4 +1,4 @@ -INSERT INTO settings(key, value, label, description, input_type, categories) VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-requests", "admin-settings"}'); +INSERT INTO settings("key", "value", "label", "description", "input_type", "categories") VALUES ('mileage_rate', 3.50, 'Mileage Rate', 'The current mileage rate for personal car mileage reimbursement.', 'number', '{"approval-requests", "admin-settings"}'); -- approval requests INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") @@ -24,3 +24,13 @@ INSERT INTO "settings" ("key", "value", "label", "description", "default_value", VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('admin_line_items_form_order_help', '', 'Admin - Line Items Order Help Text', NULL, 'Changes the order in which the line item is displayed on the approval request form.', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '0'); +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('admin_approvers_funding_page_description', '', 'Admin - Approvers and Funding Sources Page Description', NULL, 'This is the Approvers and Funding Sources Page description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('admin_reimbursement_requests_page_description', '', 'Admin - Reimbursement Requests Page Description', NULL, 'This is the Reimbursement Requests Page description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('admin_employee_allocations_page_description', '', 'Admin - Employee Allocations Page Description', NULL, 'This is the Employee Allocations Page description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('admin_allocations_general_settings_page_description', '', 'Admin - Employee Allocations General Settings Page Description', NULL, 'This is the Employee Allocations General Settings Page description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('admin_allocations_line_items_page_description', '', 'Admin - Employee Allocations Line Items Page Description', NULL, 'This is the Employee Allocations Line Items description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); \ No newline at end of file diff --git a/src/client/js/pages/admin/app-page-admin.js b/src/client/js/pages/admin/app-page-admin.js index fbdac16..d757ef9 100644 --- a/src/client/js/pages/admin/app-page-admin.js +++ b/src/client/js/pages/admin/app-page-admin.js @@ -6,14 +6,12 @@ import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-el /** * @description Admin home page * @param {Array} adminPages - local copy of active page objects from AdminPagesModel - * @param {Object} newAdminPage - new admin page object being created */ export default class AppPageAdmin extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { static get properties() { return { adminPages: {type: Array}, - newAdminPage: {type: Object}, } } @@ -21,24 +19,12 @@ export default class AppPageAdmin extends Mixin(LitElement) constructor() { super(); this.render = render.bind(this); - this.settingsCategory = 'admin-pages'; + this.settingsCategory = 'admin-page'; this.adminPages = []; - this.newAdminPage = {}; - this.waitController = new WaitController(this); - - this._injectModel('AppStateModel','SettingsModel','AdminPagesModel'); + this._injectModel('AppStateModel','SettingsModel'); } - /** - * @description lit lifecycle method - */ - willUpdate(changedProps) { - if ( changedProps.has('newAdminPage') ) { - this.showNewAdminPageForm = this.newAdminPage && Object.keys(this.newAdminPage).length > 0; - } - } - /** * @description bound to AppStateModel app-state-update event * @param {Object} state - AppStateModel state @@ -53,15 +39,24 @@ export default class AppPageAdmin extends Mixin(LitElement) this.AppStateModel.store.breadcrumbs.admin ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); + + const d = await this.getPageData(); + const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + if ( hasError ) { + this.AppStateModel.showError(d); + return; + } + + this.AppStateModel.showLoaded(this.id); + this.requestUpdate(); } - /** + /** * @description Get all data required for rendering this page */ async getPageData(){ const promises = []; promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); - promises.push(this.LineItemsModel.getActiveLineItems()); const resolvedPromises = await Promise.allSettled(promises); return resolvedPromises; } diff --git a/src/client/js/pages/admin/app-page-admin.tpl.js b/src/client/js/pages/admin/app-page-admin.tpl.js index fbb7d5e..bffba76 100644 --- a/src/client/js/pages/admin/app-page-admin.tpl.js +++ b/src/client/js/pages/admin/app-page-admin.tpl.js @@ -1,40 +1,61 @@ import { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; export function render() { return html`
-
+
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. (72 characters)

- +

Approvers and Funding Sources

+
-
+
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. (72 characters)

- +

Reimbursement Requests

+ +
+
+
+
+ +
+
+

Employee Allocations

+ +
-
+
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. (72 characters)

- +

General Settings

+ +
+
+
+
+ +
+
+

Line Items

+ +
-View All +
From 3f24cd3ebb45721770fa9961bc181b74c3c7c776 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Fri, 31 May 2024 16:38:03 -0400 Subject: [PATCH 091/274] #44 --- deploy/db-entrypoint/001-tables.sql | 1 + src/api/approvalRequest.js | 3 +- .../app-page-approval-request-new.js | 23 +++++++++++-- .../app-page-approval-request-new.tpl.js | 32 +++++++++++++++++++ src/lib/cork/models/ApprovalRequestModel.js | 5 +-- .../cork/services/ApprovalRequestService.js | 4 +-- src/lib/db-models/approvalRequest.js | 8 +++++ .../db-models/approvalRequestValidations.js | 6 ++-- 8 files changed, 72 insertions(+), 10 deletions(-) diff --git a/deploy/db-entrypoint/001-tables.sql b/deploy/db-entrypoint/001-tables.sql index 91ad56e..c5eef61 100644 --- a/deploy/db-entrypoint/001-tables.sql +++ b/deploy/db-entrypoint/001-tables.sql @@ -118,6 +118,7 @@ CREATE TABLE approval_request ( travel_end_date DATE, comments VARCHAR(500), no_expenditures BOOLEAN NOT NULL DEFAULT FALSE, + validated_successfully BOOLEAN NOT NULL DEFAULT FALSE, submitted_at timestamp DEFAULT NOW() ); COMMENT ON TABLE approval_request IS 'Table for storing travel approval requests.'; diff --git a/src/api/approvalRequest.js b/src/api/approvalRequest.js index 6777dba..22effd7 100644 --- a/src/api/approvalRequest.js +++ b/src/api/approvalRequest.js @@ -53,6 +53,7 @@ export default (api) => { api.post('/approval-request', protect('hasBasicAccess'), async (req, res) => { const data = req.body || {}; const kerberos = req.auth.token.id; + const forceValidation = req.query.hasOwnProperty('force-validation'); // check if this is a revision of an existing request and if so, ensure user is authorized if ( data.approvalRequestId ) { @@ -85,7 +86,7 @@ export default (api) => { data.reimbursementStatus = data.noExpenditures ? 'not-required' : 'not-submitted'; // create approval request revision - const result = await approvalRequest.createRevision(data, employeeObj); + const result = await approvalRequest.createRevision(data, employeeObj, forceValidation); if ( result.error && result.is400 ) { return res.status(400).json(result); } diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index a52cd42..1eebf52 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -10,6 +10,21 @@ import ValidationHandler from "../../utils/ValidationHandler.js"; import urlUtils from "../../../../lib/utils/urlUtils.js"; import promiseUtils from '../../../../lib/utils/promiseUtils.js'; +/** + * @description Page for + * - creating a new approval request + * - editing a draft approval request + * - resubmitting a rejected approval request + * + * @property {Number} approvalFormId - The id of the approval request to edit. Extracted from the url + * @property {Object} approvalRequest - The current approval request object. Updated as form inputs change + * @property {Boolean} userCantSubmit - Whether the current user is authorized to submit the form + * @property {Boolean} canBeDeleted - Whether the current approval request can be deleted + * @property {Boolean} canBeSaved - Whether the current approval request can be saved as a draft + * @property {Boolean} isSave - Whether the form is being saved as a draft or submitted + * @property {Array} expenditureOptions - Line item options for expenditures + * @property {Number} totalExpenditures - The total amount of all expenditures + */ export default class AppPageApprovalRequestNew extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -134,6 +149,11 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) return promiseUtils.flattenAllSettledResults(resolvedPromises); } + /** + * @description bound to LineItemsModel active-line-items-fetched event + * @param {Object} e - cork-app-utils event object + * @returns + */ _onActiveLineItemsFetched(e){ if ( e.state !== 'loaded' ) return; this.expenditureOptions = e.payload; @@ -145,8 +165,6 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) const ar = this.approvalRequest ar.approvalStatus = 'draft'; - // todo: ensure to set forceValidation flag on request - // set conditional request dates if ( ar.programStartDate && !ar.programEndDate ) { @@ -167,6 +185,7 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) } console.log('submit', this.approvalRequest); + this.ApprovalRequestModel.create(this.approvalRequest, true); } /** diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index c8b4e67..2738041 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -30,6 +30,16 @@ return html` export function renderForm(){ const page = 'app-page-approval-request-new'; + const hideLocationDetails = !this.approvalRequest.location || this.approvalRequest.location === 'virtual'; + let locationDetailsLabel = ''; + if ( this.approvalRequest.location === 'in-state' ) { + locationDetailsLabel = 'City *'; + } else if ( this.approvalRequest.location === 'out-of-state' ) { + locationDetailsLabel = 'City, State *'; + } else if ( this.approvalRequest.location === 'foreign' ) { + locationDetailsLabel = 'Country *'; + } + return html`
@@ -124,6 +134,17 @@ export function renderForm(){
${this.validationHandler.renderErrorMessages('location')}
+
+ + this._onFormInput('locationDetails', e.target.value)} + > +
${this.validationHandler.renderErrorMessages('locationDetails')}
+
+
Dates *
@@ -268,6 +289,17 @@ export function renderForm(){
+
+ + +
${this.validationHandler.renderErrorMessages('comments')}
+
+

Approvers and Funding Sources

- +
@@ -22,7 +22,7 @@ return html`

Reimbursement Requests

- +
@@ -32,7 +32,7 @@ return html`

Employee Allocations

- +
@@ -42,7 +42,7 @@ return html`

General Settings

- +
@@ -52,7 +52,7 @@ return html`

Line Items

- +

From d1454f288c51f0f38458f499332f9d173beac8c3 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Fri, 31 May 2024 20:08:35 -0700 Subject: [PATCH 093/274] PR fixes added still having issues with the Validation --- src/client/js/components/app-approver-type.js | 137 ++++-------------- .../js/components/app-approver-type.tpl.js | 71 +++------ src/lib/cork/models/AdminApproverTypeModel.js | 4 +- 3 files changed, 52 insertions(+), 160 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index 62c0da0..1739b80 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -5,6 +5,7 @@ import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-el import "./ucdlib-employee-search-basic.js" import ValidationHandler from "../utils/ValidationHandler.js"; import IamEmployeeObjectAccessor from '../../../lib/utils/iamEmployeeObjectAccessor.js'; +import urlUtils from "../../../lib/utils/urlUtils.js"; export default class AppApproverType extends Mixin(LitElement) @@ -19,9 +20,6 @@ export default class AppApproverType extends Mixin(LitElement) } } - // static get styles() { - // return styles(); - // } constructor() { super(); @@ -30,7 +28,6 @@ export default class AppApproverType extends Mixin(LitElement) this.newApproverType = {}; - this.approver = {}; this.new = false //may delete @@ -49,7 +46,7 @@ export default class AppApproverType extends Mixin(LitElement) _onNewApproverType(e){ if (e.state === 'error' && e.error?.payload?.is400 ) { - this.newApproverType.validationHandler = new ValidationHandler(e) + this.newApproverType.validationHandler = new ValidationHandler() } } @@ -76,10 +73,6 @@ export default class AppApproverType extends Mixin(LitElement) let category = await this.SettingsModel.getByCategory('admin-approver-form'); this.description = category.payload[0].defaultValue; - let args = {status:"active"}; //if want all active do this to see your new ones - - await this.AdminApproverTypeModel.query(args); - this.AppStateModel.showLoaded(this.id); @@ -91,28 +84,16 @@ export default class AppApproverType extends Mixin(LitElement) * @description Get all data required for rendering this page */ async getPageData(){ + let args = {status:"active"}; //if want all active do this to see your new ones + const promises = []; promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); + promises.push(this.AdminApproverTypeModel.query(args)); const resolvedPromises = await Promise.allSettled(promises); return resolvedPromises; } - /** - * @description bound to AdminApproverTypeModel APPROVER_TYPE_QUERIED event - * fires when active approver type are fetched from the server - */ - _onApproverTypeFetched(e){ - if ( e.state !== 'loaded' ) return; - this.existingApprovers = e.payload.map(approver => { - approver = {...approver}; - approver.editing = false; - approver.validationHandler = new ValidationHandler(); - return approver; - }); - } - - /** * @description runs the refresh properties after edit/create/delete function runs * @@ -138,10 +119,6 @@ export default class AppApproverType extends Mixin(LitElement) validationHandler : new ValidationHandler() }; this.existingApprovers = []; - //this.newApproverType = {}; - this.initial = {}; - - this.approver = {}; this.new = false @@ -152,21 +129,6 @@ export default class AppApproverType extends Mixin(LitElement) } - /** - * @description bound to ApproverType BASIC_EMPLOYEES_FETCHED event - * fires when active line items are fetched from the server - */ - // _onBasicEmployeesFetched(e){ - // if ( e.state !== 'loaded' ) return; - // this.newApproverType = e.payload.map(at => { - // at = {...at}; - // at.editing = false; - // // at.validationHandler = new ValidationHandler(); - // return at; - // }); - // } - - /** * @description Event handler for when employees are selected from the employee search component * @param {CustomEvent} e - status-change event from ucdlib-employee-search-basic @@ -174,7 +136,6 @@ export default class AppApproverType extends Mixin(LitElement) _onEmployeeSelect(e, index) { let emp = e.detail.employee; if(emp){ - const newEmployees = []; emp = (new IamEmployeeObjectAccessor(emp)).travelAppObject; emp.validationHandler = new ValidationHandler(); if( this.employeeIsSelected(emp) ) return; //not yet wait for submit @@ -197,41 +158,18 @@ export default class AppApproverType extends Mixin(LitElement) } _onAddBar(e, approver){ + if(!approver.employees) approver.employees = []; approver.employees.push({}); this.index = this.index + 1; - // if(!this.index){ - // this.employeeIndex.push('employee-bar-0'); - // }else { - // this.employeeIndex.push(`employee-bar-${this.index}`); - // } - - // this.index = this.index + 1; - // this.employeeIndex = this.employeeIndex.filter((item, index) => this.employeeIndex.indexOf(item) === index) + this.requestUpdate(); } - _onDeleteBar(e, index){ + _onDeleteBar(e, index, approver){ if(this.index != 0) this.index = this.index - 1; - - - // let parentID = e.currentTarget.id; - // const r = /\d+/; - // let deleteID = parentID.match(r)[0]; - // let id = `employee-bar-${deleteID}`; - // let hashId = '#' + id; - - // this.renderRoot.querySelector(hashId).remove(); - - // this.employeeIndex = this.employeeIndex.filter(item => item !== hashId); - // let index = this.employeeIndex.indexOf(id); - - //this.newEmployees = []; - // this.newEmployees = this.approver.employees.filter((item, ind) => ind !== index); - // this.approver.employees = this.newEmployees; - - let filterEmployee = this.approver.employees.filter((item, ind) => ind !== index); + let filterEmployee = approver.employees.filter((item, ind) => ind !== index); let empForm = []; filterEmployee.map((emp, ind) => { empForm.push({ "employee" : emp, "approvalOrder": ind}); @@ -239,7 +177,7 @@ export default class AppApproverType extends Mixin(LitElement) }); this.newEmployees = empForm; - this.approver.employees = filterEmployee; + approver.employees = filterEmployee; this.requestUpdate(); } @@ -281,31 +219,26 @@ export default class AppApproverType extends Mixin(LitElement) return this.existingApprovers.find(item => item.approverTypeId == id); } - async _onApproverTypeQueryRequest(e){ - let query = e[1]; - e = e[0]; - - if ( e.state === 'error' ) { - if ( e.error?.payload?.is400 ) { - this.AppStateModel.showToast({message: 'Error when querying the approver types. Query needs to be fixed.', type: 'error'}) - } else { - this.AppStateModel.showToast({message: 'An unknown error ocurred when updating the approver type', type: 'error'}) - this.AppStateModel.showLoaded(this.id) - } - await this.waitController.waitForFrames(3); - window.scrollTo(0, this.lastScrollPosition); - } else if ( e.state === 'loading' ) { - this.AppStateModel.showLoading(); + /** + * @description bound to AdminApproverTypeModel APPROVER_TYPE_QUERY_REQUEST event + */ - } else if ( e.state === 'loaded' && this.isEqual(query, {'status':'active'})) { + async _onApproverTypeQueryRequest(e){ + let query = e.query; + + if ( e.state === 'loaded' && urlUtils.queryStringFromObject(query) == urlUtils.queryStringFromObject({'status':'active'})) { let approverArray = e.payload.filter(function (el) { return el.archived == false && el.hideFromFundAssignment == false; }); approverArray.map((emp) => { - if(!Array.isArray(emp.employees)) emp.employees = [emp.employees] + emp = {...emp}; + if(!Array.isArray(emp.employees)) emp.employees = [emp.employees]; + emp.editing = false; + emp.validationHandler = new ValidationHandler(); + return emp; }); this.existingApprovers = approverArray; @@ -321,7 +254,7 @@ export default class AppApproverType extends Mixin(LitElement) */ async _onApproverTypeUpdated(e){ if ( e.state === 'error' ) { - if ( e.error?.payload?.is500 ) { + if ( e.error?.payload?.is400 ) { const getApproverTypeId = e?.payload?.getApproverTypeId; const approverType = this.getApproverTypeId(getApproverTypeId); approverType.validationHandler = new ValidationHandler(e); @@ -333,7 +266,6 @@ export default class AppApproverType extends Mixin(LitElement) this.AppStateModel.showToast({message: 'An unknown error ocurred when updating the approver type', type: 'error'}) this.AppStateModel.showLoaded(this.id) } - await this.waitController.waitForFrames(3); window.scrollTo(0, this.lastScrollPosition); } else if ( e.state === 'loading' ) { this.AppStateModel.showLoading(); @@ -354,7 +286,7 @@ export default class AppApproverType extends Mixin(LitElement) */ async _onApproverTypeCreated(e){ if ( e.state === 'error' ) { - if ( e.error?.payload?.is500 ) { + if ( e.error?.payload?.is400 ) { this.newApproverType.validationHandler = new ValidationHandler(e); this.AppStateModel.showLoaded(this.id) this.requestUpdate(); @@ -363,7 +295,6 @@ export default class AppApproverType extends Mixin(LitElement) this.AppStateModel.showToast({message: 'An unknown error ocurred when creating the approver type', type: 'error'}) this.AppStateModel.showLoaded(this.id) } - await this.waitController.waitForFrames(3); window.scrollTo(0, this.lastScrollPosition); } else if ( e.state === 'loading' ) { this.AppStateModel.showLoading(); @@ -406,11 +337,10 @@ export default class AppApproverType extends Mixin(LitElement) const approverTypeId = e.target.getAttribute('approver-type-id'); if ( approverTypeId != 0 && approverTypeId) { let approverType = this.existingApprovers.find(a => a.approverTypeId == approverTypeId); - approverType.validationHandler = new ValidationHandler(e) approverType.employees = []; approverType.employees = this.newEmployees; delete approverType.editing; - console.log(`Done Updating ${approverTypeId} ...`); + console.log(`Done Updating Approver Type No. ${approverTypeId} ...`); await this.AdminApproverTypeModel.update(approverType); } else { @@ -428,7 +358,6 @@ export default class AppApproverType extends Mixin(LitElement) * */ async _onEdit(e, approver){ - this.initial = approver; approver.editing = true; this.requestUpdate(); @@ -442,7 +371,7 @@ export default class AppApproverType extends Mixin(LitElement) employeeFormat(approver){ let employeeFormat = []; - if(approver.employees[0] == null) { + if(approver.employees == null) { approver.employees = []; return approver; } @@ -455,18 +384,6 @@ export default class AppApproverType extends Mixin(LitElement) return approver; } - // /** - // * @description on edit Save button from a approver - // * - // */ - // async _onEditSave(e, editApprover){ - // editApprover.editing = false; - // editApprover = this.employeeFormat(editApprover); - - // await this.AdminApproverTypeModel.update(editApprover); - // this._refreshProperties(); - // } - /** * @description on edit Cancel button from a approver * @@ -479,7 +396,6 @@ export default class AppApproverType extends Mixin(LitElement) this.newApproverType = {}; return; } - approver = this.initial; this.new = false; approver.editing = false; approver.validationHandler = new ValidationHandler(); @@ -517,7 +433,6 @@ export default class AppApproverType extends Mixin(LitElement) if ( e.action !== 'delete-approver-item' ) return; let approverItem = e.data.approver; approverItem.archived = true; - approverItem = this.employeeFormat(approverItem); await this.AdminApproverTypeModel.update(approverItem); diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index b48240c..4c198d3 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -1,24 +1,13 @@ -import { html, css } from 'lit'; +import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import '@ucd-lib/theme-elements/brand/ucd-theme-brand-textbox/ucd-theme-brand-textbox.js' - -export function styles() { - const elementStyles = css` - :host { - display: block; - } - `; - - return [elementStyles]; -} - export function render() { return html`

Approvers

-

${unsafeHTML(this.description)}

+

${unsafeHTML(this.SettingsModel.getByKey('approver_type_description'))}

${this.existingApprovers.map((approver) => { @@ -27,7 +16,7 @@ return html` })}
- ${this.new ? html`${renderApproverForm.call(this, this.newApproverType)}`:html` + ${this.new ? renderApproverForm.call(this, this.newApproverType) : html`

`} @@ -58,55 +47,56 @@ function renderApproverItem(ap) { `:html``}
${ap.description}
-
- ${ap.employees.map((employee) => html` -
-  ${!ap.systemGenerated && ap.employees[0] ? html`${employee.firstName} ${employee.lastName}`:html`System Generated`}
-
- `)} -
+ + ${!ap.systemGenerated ? html` +
+ ${ap.employees && ap.employees.map((employee) => html` +
+  ${employee ? html`${employee.firstName} ${employee.lastName}`:html``}
+
+ `)} +
+ `:html` System Generated
`}
` } function renderApproverForm(approver) { if ( !approver || Object.keys(approver).length === 0 ) return html``; - const title = "Edit Approver" const approverId = approver.approverTypeId || 'new'; const inputIdLabel = `approver-label-${approverId}`; const inputIdDescription = `approver-description-${approverId}`; - this.approver = approver; return html`
-

${this.approver.editing ? html`Edit Approver`:html`Add Approver`} +

${approver.editing ? html`Edit Approver`:html`Add Approver`}
- this._setLabel(e.target.value, this.approver)} type="text" placeholder="Position Title"> + this._setLabel(e.target.value, approver)} type="text" placeholder="Position Title"> ${approver.validationHandler.renderErrorMessages('label')}
- +
- ${this.approver.editing ? html` - ${this.approver.employees.map((emp, index) => html` -
+ ${approver.employees && approver.employees.map((emp, index) => html` +
- this._onDeleteBar(e, index)} class='icon-link double-decker'> - - -
- `)} - `:html` - ${this.approver.employees.map((emp, index) => html` - `)} - `} +

- +

diff --git a/src/lib/cork/models/AdminApproverTypeModel.js b/src/lib/cork/models/AdminApproverTypeModel.js index 2f7192e..d9999ca 100644 --- a/src/lib/cork/models/AdminApproverTypeModel.js +++ b/src/lib/cork/models/AdminApproverTypeModel.js @@ -35,7 +35,9 @@ class AdminApproverTypeModel extends BaseModel { } } catch(e) {} - this.store.emit(this.store.events.APPROVER_TYPE_QUERY_REQUEST, [this.store.data.query[args],query]); + this.store.data.query[args].query = query; + + this.store.emit(this.store.events.APPROVER_TYPE_QUERY_REQUEST, this.store.data.query[args]); return this.store.data.query[args]; } From d1c394d41b81c6b99b83a1d24ccccbd5a30470e5 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Mon, 3 Jun 2024 14:37:56 -0400 Subject: [PATCH 094/274] remove logs --- .../pages/approval-requests/app-page-approval-request-new.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index 1eebf52..5da020b 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -184,7 +184,6 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) delete ar.travelEndDate } - console.log('submit', this.approvalRequest); this.ApprovalRequestModel.create(this.approvalRequest, true); } @@ -280,8 +279,6 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) this.canBeDeleted = e.payload.data.find(r => r.approvalStatus !== 'draft') ? false : true; this.validationHandler = new ValidationHandler(); this.approvalRequest = { ...currentInstance }; - - console.log(e); } /** From 0f612fb7cfbe87924bd825c18f7b0572629fb6ef Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Mon, 3 Jun 2024 15:29:49 -0400 Subject: [PATCH 095/274] fixes to #35 --- deploy/db-entrypoint/004-settings.sql | 12 +- src/client/js/pages/admin/app-page-admin.js | 50 +++---- .../js/pages/admin/app-page-admin.tpl.js | 122 ++++++++++-------- src/client/scss/global.scss | 14 ++ 4 files changed, 118 insertions(+), 80 deletions(-) diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index e60315c..f746c99 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -27,13 +27,15 @@ INSERT INTO "settings" ("key", "value", "label", "description", "default_value", VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('admin_line_items_form_order_help', '', 'Admin - Line Items Order Help Text', NULL, 'Changes the order in which the line item is displayed on the approval request form.', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '0'); + +-- admin page descriptions INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") -VALUES ('admin_approvers_funding_page_description', '', 'Admin - Approvers and Funding Sources Page Description', NULL, 'This is the Approvers and Funding Sources Page description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); +VALUES ('admin_approvers_funding_page_description', '', 'Admin - Approvers and Funding Sources Page Description', NULL, 'Maintain funding source options and their respective approval requirements.', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") -VALUES ('admin_reimbursement_requests_page_description', '', 'Admin - Reimbursement Requests Page Description', NULL, 'This is the Reimbursement Requests Page description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); +VALUES ('admin_reimbursement_requests_page_description', '', 'Admin - Reimbursement Requests Page Description', NULL, 'Manage submitted reimbursement requests.', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") -VALUES ('admin_employee_allocations_page_description', '', 'Admin - Employee Allocations Page Description', NULL, 'This is the Employee Allocations Page description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); +VALUES ('admin_employee_allocations_page_description', '', 'Admin - Employee Allocations Page Description', NULL, 'Allocate funds to specific employees from designated sources and time periods.', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") -VALUES ('admin_allocations_general_settings_page_description', '', 'Admin - Employee Allocations General Settings Page Description', NULL, 'This is the Employee Allocations General Settings Page description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); +VALUES ('admin_allocations_general_settings_page_description', '', 'Admin - Employee Allocations General Settings Page Description', NULL, 'Manage form field help text and other general settings.', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") -VALUES ('admin_allocations_line_items_page_description', '', 'Admin - Employee Allocations Line Items Page Description', NULL, 'This is the Employee Allocations Line Items description', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); \ No newline at end of file +VALUES ('admin_allocations_line_items_page_description', '', 'Admin - Employee Allocations Line Items Page Description', NULL, 'Manage expenditure line item options when submitting an approval request.', '1', NULL, '100', 'textarea', '{admin-page,admin-settings}', '0'); diff --git a/src/client/js/pages/admin/app-page-admin.js b/src/client/js/pages/admin/app-page-admin.js index af74951..aca2f21 100644 --- a/src/client/js/pages/admin/app-page-admin.js +++ b/src/client/js/pages/admin/app-page-admin.js @@ -23,41 +23,43 @@ export default class AppPageAdmin extends Mixin(LitElement) this._injectModel('AppStateModel','SettingsModel'); } - /** + /** * @description bound to AppStateModel app-state-update event * @param {Object} state - AppStateModel state */ - async _onAppStateUpdate(state) { - if ( this.id !== state.page ) return; + async _onAppStateUpdate(state) { + if ( this.id !== state.page ) return; + + this.AppStateModel.showLoading(); - this.AppStateModel.setTitle('Application Administration'); + this.AppStateModel.setTitle('Application Administration'); - const breadcrumbs = [ - this.AppStateModel.store.breadcrumbs.home, - this.AppStateModel.store.breadcrumbs.admin - ]; - this.AppStateModel.setBreadcrumbs(breadcrumbs); + const breadcrumbs = [ + this.AppStateModel.store.breadcrumbs.home, + this.AppStateModel.store.breadcrumbs.admin + ]; + this.AppStateModel.setBreadcrumbs(breadcrumbs); - const d = await this.getPageData(); - const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); - if ( hasError ) { - this.AppStateModel.showError(d); - return; - } - - this.AppStateModel.showLoaded(this.id); - this.requestUpdate(); + const d = await this.getPageData(); + const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + if ( hasError ) { + this.AppStateModel.showError(d); + return; } + this.AppStateModel.showLoaded(this.id); + this.requestUpdate(); + } + /** * @description Get all data required for rendering this page */ - async getPageData(){ - const promises = []; - promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); - const resolvedPromises = await Promise.allSettled(promises); - return resolvedPromises; - } + async getPageData(){ + const promises = []; + promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); + const resolvedPromises = await Promise.allSettled(promises); + return resolvedPromises; + } } diff --git a/src/client/js/pages/admin/app-page-admin.tpl.js b/src/client/js/pages/admin/app-page-admin.tpl.js index f05734c..30dd2ae 100644 --- a/src/client/js/pages/admin/app-page-admin.tpl.js +++ b/src/client/js/pages/admin/app-page-admin.tpl.js @@ -1,62 +1,82 @@ import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -export function render() { +export function render() { return html`
-
-
-
- - -
-
-

Approvers and Funding Sources

- -
-
-
-
- - -
-
-

Reimbursement Requests

- +
+ +
+ +
+

Approvers and Funding Sources

+ +
-
-
-
- - -
-
-

Employee Allocations

- + +
+ +
+

Reimbursement Requests

+ +
-
-
-
- - -
-
-

General Settings

- + +
+ +
+

Employee Allocations

+ +
-
-
-
- - -
-
-

Line Items

- + +
+ +
+

General Settings

+ +
-
-
-
+
+ +
+

Line Items

+ +
+
+ +

+
-`;} \ No newline at end of file +`;} diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index c36718e..0c3a0bc 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -52,7 +52,21 @@ align-items: center; } +.vm-listing__figure { + &.has-icon { + width: 100px; + } + + .icon-img { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + aspect-ratio: 1 / 1; + } + +} .pad-icon-top { padding-top: .3rem; From dab3296fc9ec6bc2347137db81ece6e9e10b8997 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 4 Jun 2024 16:42:47 -0400 Subject: [PATCH 096/274] #49 --- deploy/db-entrypoint/001-tables.sql | 4 +- deploy/db-entrypoint/004-settings.sql | 6 + src/api/approvalRequest.js | 23 +++ .../app-page-approval-request-confirm.js | 105 +++++++++-- .../app-page-approval-request-confirm.tpl.js | 16 +- src/client/scss/global.scss | 10 ++ src/lib/cork/models/ApprovalRequestModel.js | 19 ++ .../cork/services/ApprovalRequestService.js | 10 ++ src/lib/cork/stores/AppStateStore.js | 2 +- src/lib/cork/stores/ApprovalRequestStore.js | 34 +++- src/lib/db-models/approvalRequest.js | 163 +++++++++++++++++- 11 files changed, 371 insertions(+), 21 deletions(-) diff --git a/deploy/db-entrypoint/001-tables.sql b/deploy/db-entrypoint/001-tables.sql index c5eef61..7c58670 100644 --- a/deploy/db-entrypoint/001-tables.sql +++ b/deploy/db-entrypoint/001-tables.sql @@ -116,7 +116,7 @@ CREATE TABLE approval_request ( has_custom_travel_dates BOOLEAN NOT NULL DEFAULT FALSE, travel_start_date DATE, travel_end_date DATE, - comments VARCHAR(500), + comments VARCHAR(2000), no_expenditures BOOLEAN NOT NULL DEFAULT FALSE, validated_successfully BOOLEAN NOT NULL DEFAULT FALSE, submitted_at timestamp DEFAULT NOW() @@ -159,7 +159,7 @@ CREATE TABLE approval_request_approval_chain_link ( approver_order INTEGER NOT NULL DEFAULT 0, action VARCHAR(100) NOT NULL DEFAULT 'approval-needed', employee_kerberos VARCHAR(100) REFERENCES employee(kerberos), - comments VARCHAR(500), + comments VARCHAR(2000), fund_changes JSONB NOT NULL DEFAULT '{}'::JSONB, occurred timestamp DEFAULT NOW() ); diff --git a/deploy/db-entrypoint/004-settings.sql b/deploy/db-entrypoint/004-settings.sql index f746c99..3b4ac21 100644 --- a/deploy/db-entrypoint/004-settings.sql +++ b/deploy/db-entrypoint/004-settings.sql @@ -22,6 +22,12 @@ VALUES ('approval_request_form_travel-required', '', 'Travel Required Explanator INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('mileage_rate_description', '', 'Mileage Rate Description', 'Displayed below mileage input on approval request form.', 'Your estimated round-trip mileage. Reimbursement is based on mileage driven and the current IRS mileage rates, not actual expenses such as gasoline.', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approval_chain_intro', '', 'Approval Chain Introduction', 'Displayed above list of required approvers.', 'Based on the funding sources selected, the following employees will be notified and required to approve your request:', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '1'); + +INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") +VALUES ('approval_chain_intro_none', '', 'Approval Chain Introduction (no approval required)', 'Displayed above list of required approvers if no approval is required.', 'Based on the funding sources selected, approval is not required for this request.', '1', NULL, '10', 'textarea', '{approval-requests,admin-settings}', '0'); + -- admin line items page INSERT INTO "settings" ("key", "value", "label", "description", "default_value", "use_default_value", "keywords", "settings_page_order", "input_type", "categories", "can_be_html") VALUES ('admin_line_items_description', '', 'Admin - Line Items Description', 'Displays on top of line item admin settings page', 'Requesters will be able to select and assign monetary values to the following line items when submitting an approval form', '1', NULL, '100', 'textarea', '{admin-line-items,admin-settings}', '1'); diff --git a/src/api/approvalRequest.js b/src/api/approvalRequest.js index 22effd7..c5ec9f1 100644 --- a/src/api/approvalRequest.js +++ b/src/api/approvalRequest.js @@ -119,4 +119,27 @@ export default (api) => { }); + api.get('/approval-request/:id/approval-chain', protect('hasBasicAccess'), async (req, res) => { + const kerberos = req.auth.token.id; + const approvalRequestId = typeTransform.toPositiveInt(req.params.id); + if ( !approvalRequestId ) { + return res.status(400).json({error: true, message: 'Invalid approvalRequestId.'}); + } + const approvalRequestObj = await approvalRequest.get({requestIds: [approvalRequestId], isCurrent: true}); + if ( approvalRequestObj.error ) { + console.error('Error in GET /approval-request/:id/approval-chain', approvalRequest.error); + return res.status(500).json({error: true, message: 'Error getting approval request.'}); + } + if ( !approvalRequestObj.data.length ) { + return res.status(404).json({error: true, message: 'Approval request not found.'}); + } + const isOwnRequest = approvalRequestObj.data[0].employeeKerberos === kerberos; + if ( !isOwnRequest && !req.auth.token.hasAdminAccess ) { + return apiUtils.do403(res); + } + + const approvalChain = await approvalRequest.makeApprovalChain(approvalRequestObj.data[0]); + return res.json(approvalChain); + }); + }; diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js index f08c21f..a92fde1 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js @@ -3,11 +3,17 @@ import {render } from "./app-page-approval-request-confirm.tpl.js"; import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +import promiseUtils from '../../../../lib/utils/promiseUtils.js'; +import urlUtils from "../../../../lib/utils/urlUtils.js"; + export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { static get properties() { return { + approvalRequestId : {type: Number}, + approvalRequest : {type: Object}, + approvalChain : {type: Array}, } } @@ -15,8 +21,12 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) constructor() { super(); this.render = render.bind(this); + this.approvalRequestId = 0; + this.approvalRequest = {}; + this.approvalChain = []; + this.settingsCategory = 'approval-requests'; - this._injectModel('AppStateModel'); + this._injectModel('AppStateModel', 'ApprovalRequestModel', 'SettingsModel'); } /** @@ -27,24 +37,95 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) if ( this.id !== state.page ) return; this.AppStateModel.showLoading(); - this.AppStateModel.setTitle('New Approval Request Confirmation'); + this.AppStateModel.setTitle('Submit Approval Request'); + + this._setApprovalRequestId(state); + if ( !this.approvalRequestId ) { + this.AppStateModel.setLocation(this.AppStateModel.store.breadcrumbs['approval-requests'].link); + return; + } + + const d = await this.getPageData(); + const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + if ( hasError ) { + this.AppStateModel.showError(d); + return; + } - // todo set breadcrumbs based on approvalRequestId const breadcrumbs = [ this.AppStateModel.store.breadcrumbs.home, - //this.AppStateModel.store.breadcrumbs['approval-requests'], - //this.AppStateModel.store.breadcrumbs[this.id] + this.AppStateModel.store.breadcrumbs['approval-requests'], + {text: 'New', 'link': `${this.AppStateModel.store.breadcrumbs['approval-request-new'].link}/${this.approvalRequestId}`}, + this.AppStateModel.store.breadcrumbs[this.id] ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); - // const d = await this.getPageData(); - // const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); - // if ( hasError ) { - // this.AppStateModel.showError(d); - // return; - // } - this.AppStateModel.showLoaded(this.id); + + this.requestUpdate(); + } + + + /** + * @description Get all data required for rendering this page + */ + async getPageData(){ + + const promises = [ + this.ApprovalRequestModel.query({requestIds: this.approvalRequestId, isCurrent: true}), + this.ApprovalRequestModel.getApprovalChain(this.approvalRequestId), + this.SettingsModel.getByCategory(this.settingsCategory) + ] + const resolvedPromises = await Promise.allSettled(promises); + return promiseUtils.flattenAllSettledResults(resolvedPromises); + } + + _onApprovalRequestChainFetched(e) { + if ( e.state !== 'loaded' ) return; + if ( e.approvalRequestId !== this.approvalRequestId ) return; + this.approvalChain = e.payload; + console.log(e); + } + + _onApprovalRequestsRequested(e){ + if ( e.state !== 'loaded' ) return; + + // check that request was issue by this element + const elementQueryString = urlUtils.queryObjectToKebabString({requestIds: this.approvalRequestId, isCurrent: true}); + if ( e.query !== elementQueryString ) return; + + if ( !e.payload.total ){ + this.resetForm(); + setTimeout(() => { + this.AppStateModel.showError('This approval request does not exist.'); + }, 100); + return; + } + + // check that confirmation view is appropriate for this request + const approvalRequest = e.payload.data[0]; + if ( approvalRequest.approvalStatus !== 'draft' ) { + this.AppStateModel.setLocation(this.AppStateModel.store.breadcrumbs['approval-requests'].link); + return; + } + + if ( !approvalRequest.validatedSuccessfully ){ + this.AppStateModel.setLocation(`${this.AppStateModel.store.breadcrumbs['approval-request-new'].link}/${this.approvalRequestId}`); + return; + } + + this.approvalRequest = approvalRequest; + console.log(approvalRequest); + } + + + /** + * @description Set approvalRequestId property from App State location (the url) + * @param {Object} state - AppStateModel state + */ + _setApprovalRequestId(state) { + let approvalRequestId = Number(state?.location?.path?.[2]); + this.approvalRequestId = Number.isInteger(approvalRequestId) && approvalRequestId > 0 ? approvalRequestId : 0; } } diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js index 4d2c163..4b3e0e7 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js @@ -1,7 +1,21 @@ import { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; export function render() { return html` - +
+
+
+ +
+
`;} diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 0c3a0bc..29e34b8 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -71,3 +71,13 @@ .pad-icon-top { padding-top: .3rem; } + +.size-h4 { + font-size: 1.092rem !important; + line-height: 1.2 !important; +} +@media (min-width: 768px) { + .size-h4 { + font-size: 1.428rem !important; + } +} diff --git a/src/lib/cork/models/ApprovalRequestModel.js b/src/lib/cork/models/ApprovalRequestModel.js index ad6741e..ac73888 100644 --- a/src/lib/cork/models/ApprovalRequestModel.js +++ b/src/lib/cork/models/ApprovalRequestModel.js @@ -34,6 +34,24 @@ class ApprovalRequestModel extends BaseModel { } + /** + * @description Get approval chain for most current version of a specific approval request + * @param {*} approvalRequestId + * @returns + */ + async getApprovalChain(approvalRequestId) { + let state = this.store.data.approvalChainByRequestId[approvalRequestId]; + try { + if( state && state.state === 'loading' ) { + await state.request; + } else { + await this.service.getApprovalChain(approvalRequestId); + } + } catch(e) {} + + return this.store.data.approvalChainByRequestId[approvalRequestId]; + } + /** * @description Delete an approval request by id - must have always been in a draft state * @param {String} approvalRequestId - id of approval request to delete @@ -65,6 +83,7 @@ class ApprovalRequestModel extends BaseModel { const state = this.store.data.created[timestamp]; if ( state && state.state === 'loaded' ) { this.store.data.fetched = {}; + this.store.data.approvalChainByRequestId = {}; } return state; } diff --git a/src/lib/cork/services/ApprovalRequestService.js b/src/lib/cork/services/ApprovalRequestService.js index 9cf0240..10a9a3a 100644 --- a/src/lib/cork/services/ApprovalRequestService.js +++ b/src/lib/cork/services/ApprovalRequestService.js @@ -44,6 +44,16 @@ class ApprovalRequestService extends BaseService { }); } + getApprovalChain(approvalRequestId) { + return this.request({ + url : `/api/approval-request/${approvalRequestId}/approval-chain`, + checkCached: () => this.store.data.approvalChainByRequestId[approvalRequestId], + onLoading : request => this.store.approvalChainLoading(request, approvalRequestId), + onLoad : result => this.store.approvalChainLoaded(result.body, approvalRequestId), + onError : e => this.store.approvalChainError(e, approvalRequestId) + }); + } + } const service = new ApprovalRequestService(); diff --git a/src/lib/cork/stores/AppStateStore.js b/src/lib/cork/stores/AppStateStore.js index f3203bc..2a12d2b 100644 --- a/src/lib/cork/stores/AppStateStore.js +++ b/src/lib/cork/stores/AppStateStore.js @@ -11,7 +11,7 @@ class AppStateStoreImpl extends AppStateStore { this.breadcrumbs = { home: {text: 'Home', link: '/'}, 'approval-request-new': {text: 'New', link: '/approval-request/new'}, - 'approval-request-confirm': {text: 'Confirm', link: '/approval-request/confirm'}, + 'approval-request-confirm': {text: 'Review and Submit', link: '/approval-request/confirm'}, 'approval-requests': {text: 'Your Approval Requests', link: '/approval-request'}, 'approver': {text: 'Approve a Request', link: '/approver'}, 'reports': {text: 'Reports', link: '/reports'}, diff --git a/src/lib/cork/stores/ApprovalRequestStore.js b/src/lib/cork/stores/ApprovalRequestStore.js index 1eec773..b384d79 100644 --- a/src/lib/cork/stores/ApprovalRequestStore.js +++ b/src/lib/cork/stores/ApprovalRequestStore.js @@ -8,17 +8,47 @@ class ApprovalRequestStore extends BaseStore { this.data = { fetched: {}, deleted: {}, - created: {} + created: {}, + approvalChainByRequestId: {} }; this.events = { APPROVAL_REQUESTS_FETCHED: 'approval-requests-fetched', APPROVAL_REQUESTS_REQUESTED: 'approval-requests-requested', APPROVAL_REQUEST_DELETED: 'approval-request-deleted', - APPROVAL_REQUEST_CREATED: 'approval-request-created' + APPROVAL_REQUEST_CREATED: 'approval-request-created', + APPROVAL_REQUEST_CHAIN_FETCHED: 'approval-request-chain-fetched' }; } + approvalChainLoading(approvalRequestId) { + this._setApprovalChainState({ + state : this.STATE.LOADING, + approvalRequestId + }); + } + + approvalChainLoaded(payload, approvalRequestId) { + this._setApprovalChainState({ + state : this.STATE.LOADED, + payload, + approvalRequestId + }); + } + + approvalChainError(error, approvalRequestId) { + this._setApprovalChainState({ + state : this.STATE.ERROR, + error, + approvalRequestId + }); + } + + _setApprovalChainState(state) { + this.data.approvalChainByRequestId[state.approvalRequestId] = state; + this.emit(this.events.APPROVAL_REQUEST_CHAIN_FETCHED, state); + } + approvalRequestCreatedLoading(request, timestamp) { this._setApprovalRequestCreatedState({ state : this.STATE.LOADING, diff --git a/src/lib/db-models/approvalRequest.js b/src/lib/db-models/approvalRequest.js index 2d407d2..58c4587 100644 --- a/src/lib/db-models/approvalRequest.js +++ b/src/lib/db-models/approvalRequest.js @@ -2,6 +2,7 @@ import pg from "./pg.js"; import EntityFields from "../utils/EntityFields.js"; import validations from "./approvalRequestValidations.js"; import employeeModel from "./employee.js"; +import fundingSourceModel from "./fundingSource.js" import typeTransform from "../utils/typeTransform.js"; class ApprovalRequest { @@ -113,7 +114,7 @@ class ApprovalRequest { { dbName: 'comments', jsonName: 'comments', - charLimit: 500 + charLimit: 2000 }, { dbName: 'submitted_at', @@ -362,14 +363,14 @@ class ApprovalRequest { delete data.submitted_at // do validation - data.validatedSuccessfully = false; + data.validated_successfully = false; if ( forceValidation ) data.forceValidation = true; const validation = await this.entityFields.validate(data, ['employee_allocation_id']); if ( !validation.valid ) { return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; } if ( data.forceValidation || payload.approval_status !== 'draft' ){ - data.validatedSuccessfully = true; + data.validated_successfully = true; } delete data.forceValidation; @@ -516,6 +517,162 @@ class ApprovalRequest { return {success: true, approvalRequestId}; } + /** + * @description Construct an approval chain for an approval request based on funding sources selected + * @param {Object|Number} approvalRequest - approval request object or approval request ID + * @returns {Object|Array} - If error returns error object, otherwise returns array of approvers with properties: + * - approvalTypeOrder {Integer} - order of approval type + * - employeeOrder {Integer} - order of employee within approval type + * - approverTypes {Array} - array of approver types with properties: + * - approverTypeId {Integer} - approver type ID + * - approverTypeLabel {String} - approver type label + * - employeeKerberos {String} - kerberos of approver + * - employee {Object} - employee record of approver + */ + async makeApprovalChain(approvalRequest){ + + // check if approval request ID was given instead of object + // retrieve approval request object if so + let approvalRequestId = typeTransform.toPositiveInt(approvalRequest); + if ( approvalRequestId ){ + approvalRequest = await this.get({requestIds: [approvalRequestId], isCurrent: true}); + if ( approvalRequest.error ) return approvalRequest; + if ( !approvalRequest.total ) return {error: true, message: 'Approval request not found', is400: true}; + approvalRequest = approvalRequest.data[0]; + } + + // get full funding source objects + const fundingSourceIds = (approvalRequest.fundingSources || []).map(fs => fs.fundingSourceId); + if ( !fundingSourceIds.length ) return []; + const fundingSources = await fundingSourceModel.get({ids: fundingSourceIds}); + if ( fundingSources.error ) return fundingSources; + + // get employee record of employee who submitted the request + if ( !approvalRequest.employeeKerberos ) return {error: true, message: 'Employee kerberos not found', is400: true}; + let submitter = await employeeModel.getIamRecordById(approvalRequest.employeeKerberos); + if ( submitter.error ) return submitter; + submitter = submitter.res; + + // extract approvers from funding source and flatten + // approver will have properties: + // approvalTypeOrder, employeeOrder, approverTypeLabel, approverTypeId,employeeId, employeeIdType + const approvers = []; + for (const fs of fundingSources){ + for (const ap of (fs.approverTypes || [])){ + + // if system generated, we determine the approver employee + if ( ap.systemGenerated ){ + + // submitter supervisor + if ( ap.approverTypeId == 1 ){ + + if ( !submitter?.supervisor?.iamId ) { + return {error: true, message: 'Submitter supervisor not found'}; + } + approvers.push({ + approvalTypeOrder: ap.approvalOrder, + employeeOrder: 0, + approverTypeLabel: ap.label, + approverTypeId: ap.approverTypeId, + employeeId: submitter.supervisor.iamId, + employeeIdType: 'iam-id' + }); + + // submitter department head + } else if ( ap.approverTypeId == 2 ){ + + // bail if submitter has no department head and is not department head + if ( !submitter?.departmentHead?.iamId && !(submitter?.groups || []).find(g => g.partOfOrg && g.isHead) ) { + return {error: true, message: 'Submitter department head not found'}; + } + + approvers.push({ + approvalTypeOrder: ap.approvalOrder, + employeeOrder: 0, + approverTypeLabel: ap.label, + approverTypeId: ap.approverTypeId, + employeeId: submitter.departmentHead.iamId, + employeeIdType: 'iam-id' + }); + + // a system generated approver we don't know how to handle + } else { + return {error: true, message: 'Invalid system generated approver type'}; + } + + // not system generated, we use the employee id provided + } else { + if ( !ap.employees || !ap.employees.length ) return {error: true, message: 'No employees found for approver type'}; + for (const employee of ap.employees ){ + if ( !employee.kerberos ) return {error: true, message: 'Employee kerberos not found'}; + approvers.push({ + approvalTypeOrder: ap.approvalOrder, + employeeOrder: employee.approvalOrder, + approverTypeLabel: ap.label, + approverTypeId: ap.approverTypeId, + employeeId: employee.kerberos, + employeeIdType: 'user-id' + }); + } + } + } + } + + // retrieve employee records for each approver + const approverEmployeeRecords = []; + const promises = []; + let promiseIndex = 0; + for (const approver of approvers) { + const id = `${approver.employeeIdType}--${approver.employeeId}`; + if ( !approverEmployeeRecords.find(a => a.id === id)) { + promises.push(employeeModel.getIamRecordById(approver.employeeId, approver.employeeIdType)); + approverEmployeeRecords.push({id, promiseIndex}); + promiseIndex += 1; + } + } + const resolvedPromises = await Promise.allSettled(promises); + for ( const i in resolvedPromises ){ + const resolvedPromise = resolvedPromises[i]; + if ( resolvedPromise.status === 'rejected' ){ + return {error: true, message: 'Error getting approver employee record'}; + } + if ( resolvedPromise.value.error ){ + return resolvedPromise.value; + } + + const approver = approverEmployeeRecords.find(a => a.promiseIndex == i); + approver.employee = resolvedPromise.value.res; + } + + // merge the employee records with the approver records into a unique array of employee approvers + const uniqueApprovers = []; + for ( const approver of approvers ){ + const employeeRecord = approverEmployeeRecords.find(a => a.id === `${approver.employeeIdType}--${approver.employeeId}`); + const employeeKerberos = employeeRecord.employee.user_id; + if ( !employeeKerberos ) return {error: true, message: 'Approver kerberos is missing from employee record'}; + let uniqueRecord = uniqueApprovers.find(a => a.employeeKerberos === employeeKerberos); + if ( !uniqueRecord ){ + uniqueRecord = {approvalTypeOrder: approver.approvalTypeOrder, employeeOrder: approver.employeeOrder, approverTypes: []}; + uniqueApprovers.push(uniqueRecord); + }; + uniqueRecord.employeeKerberos = employeeKerberos; + uniqueRecord.employee = employeeRecord.employee; + if ( approver.approvalTypeOrder < uniqueRecord.approvalTypeOrder ) uniqueRecord.approvalTypeOrder = approver.approvalTypeOrder; + if ( approver.employeeOrder < uniqueRecord.employeeOrder ) uniqueRecord.employeeOrder = approver.employeeOrder; + if ( !uniqueRecord.approverTypes.find(at => at.approverTypeId === approver.approverTypeId)){ + uniqueRecord.approverTypes.push({approverTypeId: approver.approverTypeId, approverTypeLabel: approver.approverTypeLabel}); + } + } + + // sort by approval type order, then by employee order + uniqueApprovers.sort((a, b) => { + if ( a.approvalTypeOrder !== b.approvalTypeOrder ) return a.approvalTypeOrder - b.approvalTypeOrder; + return a.employeeOrder - b.employeeOrder + }); + + return uniqueApprovers; + } + } export default new ApprovalRequest(); From 69aeb6b9c3db6f5b8f083bd0039b68ad74aefdf3 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Tue, 4 Jun 2024 18:50:42 -0700 Subject: [PATCH 097/274] requested changes added --- src/client/js/components/app-approver-type.js | 160 ++++-------------- .../js/components/app-approver-type.tpl.js | 15 +- .../pages/admin/app-page-admin-line-items.js | 27 ++- 3 files changed, 58 insertions(+), 144 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index 1739b80..0fa3dcf 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -16,43 +16,28 @@ export default class AppApproverType extends Mixin(LitElement) existingApprovers:{type: Array, attribute: 'existingApprovers'}, newApproverType:{type: Object, attribute: 'newApproverType'}, approver: {type: Object, attribute: 'approver'}, - + new: {type:Boolean, attribute: 'new'} } } constructor() { super(); - this.id = 'admin-approvers'; this.existingApprovers = []; this.newApproverType = {}; - this.new = false + this.new = false; + this.element = 'admin-approvers'; - //may delete - this.employeeIndex = []; - this.newEmployees = []; - this.index = 0; - - - - this.render = render.bind(this); this._injectModel('AppStateModel', 'AdminApproverTypeModel', 'SettingsModel'); this._resetProperties(); } - _onNewApproverType(e){ - if (e.state === 'error' && e.error?.payload?.is400 ) { - this.newApproverType.validationHandler = new ValidationHandler() - } - } - _newForm(e) { this.new = true; - this.requestUpdate(); } /** @@ -60,7 +45,7 @@ export default class AppApproverType extends Mixin(LitElement) * @param {Object} state - AppStateModel state */ async _onAppStateUpdate(state) { - if ( state.page != this.id ) return; + if ( state.page != this.element ) return; this.AppStateModel.showLoading(); const d = await this.getPageData(); @@ -70,11 +55,7 @@ export default class AppApproverType extends Mixin(LitElement) return; } - let category = await this.SettingsModel.getByCategory('admin-approver-form'); - this.description = category.payload[0].defaultValue; - - - this.AppStateModel.showLoaded(this.id); + this.AppStateModel.showLoaded(this.element); this.requestUpdate(); } @@ -119,13 +100,7 @@ export default class AppApproverType extends Mixin(LitElement) validationHandler : new ValidationHandler() }; this.existingApprovers = []; - this.new = false - - - //may delete - this.employeeIndex = []; - this.newEmployees = []; - this.index = 0; + this.new = false; } @@ -133,53 +108,28 @@ export default class AppApproverType extends Mixin(LitElement) * @description Event handler for when employees are selected from the employee search component * @param {CustomEvent} e - status-change event from ucdlib-employee-search-basic */ - _onEmployeeSelect(e, index) { + _onEmployeeSelect(e, approverType, employeeIndex) { let emp = e.detail.employee; if(emp){ emp = (new IamEmployeeObjectAccessor(emp)).travelAppObject; - emp.validationHandler = new ValidationHandler(); - if( this.employeeIsSelected(emp) ) return; //not yet wait for submit - let empForm = { "employee" : emp, "approvalOrder": index} - this.newEmployees.push(empForm); + approverType.employees[employeeIndex] = emp } - //Plan for if it is taken out of employee form - // this.newEmployees = [...this.newEmployees, ...newEmployees]; } - - /** - * @description Check if an employee is already in the selected list - * @param {Object} employee - employee object - * @returns - */ - employeeIsSelected(employee) { - if(this.newEmployees == null) return; - return this.newEmployees.find(e => e.employee.kerberos === employee.kerberos); - } - - _onAddBar(e, approver){ - if(!approver.employees) approver.employees = []; - approver.employees.push({}); - this.index = this.index + 1; + _onAddBar(e, approverType){ + if(!approverType.employees) approverType.employees = []; + approverType.employees.push({}); this.requestUpdate(); } - _onDeleteBar(e, index, approver){ - - if(this.index != 0) this.index = this.index - 1; - - - let filterEmployee = approver.employees.filter((item, ind) => ind !== index); - let empForm = []; - filterEmployee.map((emp, ind) => { - empForm.push({ "employee" : emp, "approvalOrder": ind}); - - }); - - this.newEmployees = empForm; - approver.employees = filterEmployee; - this.requestUpdate(); + _onDeleteBar(e, employeeIndex, approverType) { + if (!employeeIndex) { + approverType.employees[0] = {}; + } else { + approverType.employees.splice(employeeIndex, 1); + } + this.requestUpdate() } async _setLabel(value, approver){ @@ -192,22 +142,7 @@ export default class AppApproverType extends Mixin(LitElement) this.requestUpdate(); } - isEqual(obj1, obj2) { - var props1 = Object.getOwnPropertyNames(obj1); - var props2 = Object.getOwnPropertyNames(obj2); - if (props1.length != props2.length) { - return false; - } - for (var i = 0; i < props1.length; i++) { - let val1 = obj1[props1[i]]; - let val2 = obj2[props1[i]]; - let isObjects = this.isObject(val1) && this.isObject(val2); - if (isObjects && !this.isEqual(val1, val2) || !isObjects && val1 !== val2) { - return false; - } - } - return true; - } + isObject(object) { return object != null && typeof object === 'object'; } @@ -226,7 +161,6 @@ export default class AppApproverType extends Mixin(LitElement) async _onApproverTypeQueryRequest(e){ let query = e.query; - if ( e.state === 'loaded' && urlUtils.queryStringFromObject(query) == urlUtils.queryStringFromObject({'status':'active'})) { let approverArray = e.payload.filter(function (el) { return el.archived == false && @@ -255,16 +189,16 @@ export default class AppApproverType extends Mixin(LitElement) async _onApproverTypeUpdated(e){ if ( e.state === 'error' ) { if ( e.error?.payload?.is400 ) { - const getApproverTypeId = e?.payload?.getApproverTypeId; + const getApproverTypeId = e.data.approverTypeId; const approverType = this.getApproverTypeId(getApproverTypeId); approverType.validationHandler = new ValidationHandler(e); - this.AppStateModel.showLoaded(this.id) + this.AppStateModel.showLoaded(this.element) this.requestUpdate(); this.AppStateModel.showToast({message: 'Error when updating the approver type. Form data needs fixing.', type: 'error'}) } else { this.AppStateModel.showToast({message: 'An unknown error ocurred when updating the approver type', type: 'error'}) - this.AppStateModel.showLoaded(this.id) + this.AppStateModel.showLoaded(this.element) } window.scrollTo(0, this.lastScrollPosition); } else if ( e.state === 'loading' ) { @@ -288,12 +222,12 @@ export default class AppApproverType extends Mixin(LitElement) if ( e.state === 'error' ) { if ( e.error?.payload?.is400 ) { this.newApproverType.validationHandler = new ValidationHandler(e); - this.AppStateModel.showLoaded(this.id) + this.AppStateModel.showLoaded(this.element) this.requestUpdate(); this.AppStateModel.showToast({message: 'Error when creating the approver type. Form data needs fixing.', type: 'error'}) } else { this.AppStateModel.showToast({message: 'An unknown error ocurred when creating the approver type', type: 'error'}) - this.AppStateModel.showLoaded(this.id) + this.AppStateModel.showLoaded(this.element) } window.scrollTo(0, this.lastScrollPosition); } else if ( e.state === 'loading' ) { @@ -337,17 +271,11 @@ export default class AppApproverType extends Mixin(LitElement) const approverTypeId = e.target.getAttribute('approver-type-id'); if ( approverTypeId != 0 && approverTypeId) { let approverType = this.existingApprovers.find(a => a.approverTypeId == approverTypeId); - approverType.employees = []; - approverType.employees = this.newEmployees; - delete approverType.editing; - console.log(`Done Updating Approver Type No. ${approverTypeId} ...`); - - await this.AdminApproverTypeModel.update(approverType); + console.log(`Updating Approver Type No. ${approverTypeId} ...`); + await this.AdminApproverTypeModel.update(this.employeeFormat(approverType)); } else { - this.newApproverType.employees = []; - this.newApproverType.employees = this.newEmployees; - await this.AdminApproverTypeModel.create(this.newApproverType); - console.log("Done Creating..."); + await this.AdminApproverTypeModel.create(this.employeeFormat(this.newApproverType)); + console.log("Creating..."); } @@ -359,7 +287,6 @@ export default class AppApproverType extends Mixin(LitElement) */ async _onEdit(e, approver){ approver.editing = true; - this.requestUpdate(); } @@ -390,7 +317,6 @@ export default class AppApproverType extends Mixin(LitElement) */ async _onEditCancel(e, approver){ - if (!approver.approverTypeId) { this.new = false; this.newApproverType = {}; @@ -398,13 +324,16 @@ export default class AppApproverType extends Mixin(LitElement) } this.new = false; approver.editing = false; - approver.validationHandler = new ValidationHandler(); - for(let i = this.index; i > 0; i--){ - approver.employees.pop() + let query = "status=active"; + + let storeApproverType = this.AdminApproverTypeModel.store.data.query[query].payload.find(at => at.approverTypeId === approver.approverTypeId); + for( let prop in storeApproverType ) { + approver[prop] = storeApproverType[prop]; } - this.index = 0; + console.log(approver) + approver.validationHandler = new ValidationHandler(); this.requestUpdate(); } @@ -441,25 +370,6 @@ export default class AppApproverType extends Mixin(LitElement) } - /** - * @description Get Approver type from query - * - */ - async _getApproverType(){ - - let approverArray = approvers.payload.filter(function (el) { - return el.archived == false && - el.hideFromFundAssignment == false; - }); - approverArray.map((emp) => { - if(!Array.isArray(emp.employees)) emp.employees = [emp.employees] - }); - - this.existingApprovers = approverArray; - - this.requestUpdate(); - } - } customElements.define('app-approver-type', AppApproverType); \ No newline at end of file diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index 4c198d3..5ed1689 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -73,10 +73,9 @@ function renderApproverForm(approver) {

${approver.editing ? html`Edit Approver`:html`Add Approver`} -
+
this._setLabel(e.target.value, approver)} type="text" placeholder="Position Title"> - ${approver.validationHandler.renderErrorMessages('label')}
@@ -89,23 +88,19 @@ function renderApproverForm(approver) {
- ${approver.employees && approver.employees.map((emp, index) => html` -
+
- this._onDeleteBar(e, index, approver)} class='icon-link double-decker'> + this._onDeleteBar(e, index, approver)} class='icon-link double-decker'>
diff --git a/src/client/js/pages/admin/app-page-admin-line-items.js b/src/client/js/pages/admin/app-page-admin-line-items.js index aaa5a23..15ea476 100644 --- a/src/client/js/pages/admin/app-page-admin-line-items.js +++ b/src/client/js/pages/admin/app-page-admin-line-items.js @@ -59,15 +59,15 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); - try { - const d = await this.getPageData(); - const hasError = d.some(e => e.state === 'error'); - if ( !hasError ) this.AppStateModel.showLoaded(this.id); - this.requestUpdate(); - } catch(e) { - this.AppStateModel.showError(this.id); + const d = await this.getPageData(); + const hasError = d.some(e => e.status === 'rejected' || e.value.state === 'error'); + if ( hasError ) { + this.AppStateModel.showError(d); + return; } + this.AppStateModel.showLoaded(this.id); + this.requestUpdate(); } /** @@ -182,7 +182,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) /** * @description bound to LineItemsModel LINE_ITEM_CREATED event */ - async _onLineItemCreated(e){ + async _onLineItemCreated(e){ if ( e.state === 'error' ) { if ( e.error?.payload?.is400 ) { this.newLineItem.validationHandler = new ValidationHandler(e); @@ -225,6 +225,10 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) } } + /** + * @description Bound to delete button for each line item + * @param {Object} lineItem - line item object to delete + */ _onDeleteClick(lineItem){ this.AppStateModel.showDialogModal({ title : 'Delete Line Item', @@ -237,6 +241,11 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) }); } + /** + * @description Callback for dialog-action AppStateModel event + * @param {Object} e - AppStateModel dialog-action event + * @returns + */ _onDialogAction(e){ if ( e.action !== 'delete-line-item' ) return; const lineItem = e.data.lineItem; @@ -251,7 +260,7 @@ export default class AppPageAdminLineItems extends Mixin(LitElement) const promises = []; promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); promises.push(this.LineItemsModel.getActiveLineItems()); - const resolvedPromises = await Promise.all(promises); + const resolvedPromises = await Promise.allSettled(promises); return resolvedPromises; } From d221860497cb7323eb057c264417c07c3a878660 Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 5 Jun 2024 11:04:53 -0400 Subject: [PATCH 098/274] ui component for #49 --- src/api/approvalRequest.js | 15 +++- .../approval-request-status-action.js | 82 +++++++++++++++++++ .../approval-request-status-action.tpl.js | 27 ++++++ .../app-page-approval-request-confirm.js | 9 +- .../app-page-approval-request-confirm.tpl.js | 34 +++++++- .../approval-request-status-action.scss | 32 ++++++++ src/client/scss/global.scss | 6 ++ src/client/scss/style.scss | 1 + 8 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 src/client/js/components/approval-request-status-action.js create mode 100644 src/client/js/components/approval-request-status-action.tpl.js create mode 100644 src/client/scss/components/approval-request-status-action.scss diff --git a/src/api/approvalRequest.js b/src/api/approvalRequest.js index c5ec9f1..de841c4 100644 --- a/src/api/approvalRequest.js +++ b/src/api/approvalRequest.js @@ -119,6 +119,10 @@ export default (api) => { }); + /** + * @description Returns the employees that need to approve the given approval request based on the submitter and funding sources selected + * Called before an approval request is actually submitted. + */ api.get('/approval-request/:id/approval-chain', protect('hasBasicAccess'), async (req, res) => { const kerberos = req.auth.token.id; const approvalRequestId = typeTransform.toPositiveInt(req.params.id); @@ -139,7 +143,16 @@ export default (api) => { } const approvalChain = await approvalRequest.makeApprovalChain(approvalRequestObj.data[0]); - return res.json(approvalChain); + + // transform chain object to match format in approvalRequest object + const out = approvalChain.map((chainObj) => { + return { + action: 'approval-needed', + employee: (new IamEmployeeObjectAccessor(chainObj.employee)).travelAppObject, + approverTypes: chainObj.approverTypes, + }; + }); + return res.json(out); }); }; diff --git a/src/client/js/components/approval-request-status-action.js b/src/client/js/components/approval-request-status-action.js new file mode 100644 index 0000000..b77c07e --- /dev/null +++ b/src/client/js/components/approval-request-status-action.js @@ -0,0 +1,82 @@ +import { LitElement } from 'lit'; +import {render} from "./approval-request-status-action.tpl.js"; + +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; + +/** + * @class ApprovalRequestStatusAction + * @description UI component for displaying a single approval request status action + * @property {Object} action - the action object + * See approvalRequest DB model for action object structure + * + * @emits view-comments - when view comments button is clicked + */ +export default class ApprovalRequestStatusAction extends Mixin(LitElement) + .with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + action: {type: Object} + } + } + + + constructor() { + super(); + this.render = render.bind(this); + + this.action = {}; + + this.byStatus = { + 'approval-needed': { + label: 'Pending Approval By:', + iconClass: 'fa-solid fa-user', + brandColor: 'primary' + }, + 'approved': { + label: 'Approved By:', + iconClass: 'fa-solid fa-check', + brandColor: 'quad' + }, + 'denied': { + label: 'Denied By:', + iconClass: 'fa-solid fa-ban', + brandColor: 'double-decker' + }, + 'canceled': { + label: 'Canceled By:', + iconClass: 'fa-solid fa-times', + brandColor: 'redbud' + }, + 'revision-requested': { + label: 'Revision Requested By:', + iconClass: 'fa-solid fa-edit', + brandColor: 'pinot' + }, + 'recalled': { + label: 'Recalled By:', + iconClass: 'fa-solid fa-rotate-left', + brandColor: 'secondary' + }, + 'approved-with-changes': { + label: 'Approved With Changes By:', + iconClass: 'fa-solid fa-check', + brandColor: 'quad' + } + } + } + + /** + * @description bound to view comments button click event + */ + _onViewCommentsClick() { + this.dispatchEvent(new CustomEvent('view-comments', { + detail: this.action + })); + } + +} + +customElements.define('approval-request-status-action', ApprovalRequestStatusAction); diff --git a/src/client/js/components/approval-request-status-action.tpl.js b/src/client/js/components/approval-request-status-action.tpl.js new file mode 100644 index 0000000..9180a44 --- /dev/null +++ b/src/client/js/components/approval-request-status-action.tpl.js @@ -0,0 +1,27 @@ +import { html } from 'lit'; + +/** + * @description Main render function + * @returns {TemplateResult} + */ +export function render() { + if ( !Object.keys(this.action).length ) return html``; + const status = this.byStatus[this.action.action]; + if ( !status ) return html``; + + return html` +
+
+ +
+
+
${status.label}
+
${this.action?.employee?.firstName} ${this.action?.employee?.lastName}
+
${(this.action.approverTypes || []).map(at => at.approverTypeLabel).join(', ')}
+ +
+
+`;} diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js index a92fde1..dccca20 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js @@ -14,6 +14,7 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) approvalRequestId : {type: Number}, approvalRequest : {type: Object}, approvalChain : {type: Array}, + formLink : {type: String}, } } @@ -25,6 +26,7 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) this.approvalRequest = {}; this.approvalChain = []; this.settingsCategory = 'approval-requests'; + this.formLink = ''; this._injectModel('AppStateModel', 'ApprovalRequestModel', 'SettingsModel'); } @@ -52,10 +54,12 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) return; } + this.formLink = `${this.AppStateModel.store.breadcrumbs['approval-request-new'].link}/${this.approvalRequestId}`; + const breadcrumbs = [ this.AppStateModel.store.breadcrumbs.home, this.AppStateModel.store.breadcrumbs['approval-requests'], - {text: 'New', 'link': `${this.AppStateModel.store.breadcrumbs['approval-request-new'].link}/${this.approvalRequestId}`}, + {text: this.approvalRequest?.label || 'New', 'link': this.formLink}, this.AppStateModel.store.breadcrumbs[this.id] ]; this.AppStateModel.setBreadcrumbs(breadcrumbs); @@ -84,9 +88,10 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) if ( e.state !== 'loaded' ) return; if ( e.approvalRequestId !== this.approvalRequestId ) return; this.approvalChain = e.payload; - console.log(e); } + _onSubmitButtonClick(){} + _onApprovalRequestsRequested(e){ if ( e.state !== 'loaded' ) return; diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js index 4b3e0e7..2b2ced2 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js @@ -1,21 +1,49 @@ import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import '../../components/approval-request-status-action.js'; + export function render() { return html` -
+
+
+
+
+
+ + Cancel +
+
+
+
+ `;} diff --git a/src/client/scss/components/approval-request-status-action.scss b/src/client/scss/components/approval-request-status-action.scss new file mode 100644 index 0000000..1f144bb --- /dev/null +++ b/src/client/scss/components/approval-request-status-action.scss @@ -0,0 +1,32 @@ +approval-request-status-action { + + display: block; + margin-bottom: 1rem; + + .container { + display: flex; + } + .icon-circle { + display: flex; + justify-content: center; + align-items: center; + width: 4rem; + height: 4rem; + min-width: 4rem; + min-height: 4rem; + font-size: 2rem; + border-radius: 50%; + color: var(--category-brand-contrast-color, #fff); + margin-right: 1rem; + } + .comments { + display: flex; + font-size: .875rem; + align-items: center; + color: #73ABDD; + + .fa-comment { + margin-right: .5rem; + } + } +} diff --git a/src/client/scss/global.scss b/src/client/scss/global.scss index 29e34b8..255a4dd 100644 --- a/src/client/scss/global.scss +++ b/src/client/scss/global.scss @@ -19,6 +19,12 @@ .small { font-size: .875rem; } +.smaller { + font-size: .75rem; +} +.grey { + color: #4C4C4C; +} .skinny-form { padding: 1rem .5rem; diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index e85c22a..9d59845 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -13,6 +13,7 @@ @use './pages/approval-request-new.scss' as approvalRequestNew; // components +@use './components/approval-request-status-action.scss' as approvalRequestStatusAction; @use './components/dialog-modal.scss' as dialogModal; @use './components/employee-search-advanced.scss' as employeeSearchAdvanced; @use './components/employee-search-basic.scss' as employeeSearchBasic; From 53a69ac8497af9bd0892a3a707fe06d19615a6f0 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Wed, 5 Jun 2024 11:11:46 -0700 Subject: [PATCH 099/274] clearing debugging functions and adding descriptions --- src/client/js/components/app-approver-type.js | 83 ++++++++++++------- .../js/components/app-approver-type.tpl.js | 14 +--- .../pages/admin/app-page-admin-approvers.js | 4 + 3 files changed, 56 insertions(+), 45 deletions(-) diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index 0fa3dcf..8ad70db 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -1,13 +1,21 @@ -import { html, LitElement } from 'lit'; -import {render, styles} from "./app-approver-type.tpl.js"; +import {LitElement } from 'lit'; +import {render} from "./app-approver-type.tpl.js"; import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; import "./ucdlib-employee-search-basic.js" import ValidationHandler from "../utils/ValidationHandler.js"; import IamEmployeeObjectAccessor from '../../../lib/utils/iamEmployeeObjectAccessor.js'; import urlUtils from "../../../lib/utils/urlUtils.js"; +import AppPageAdminApprovers from '../pages/admin/app-page-admin-approvers.js'; +/** + * @description Admin approvertype component for managing approver type options + * where user can create, edit, and archive an approver type for approver requests + * @param {Array} existingApprovers - local copy of active approvertype objects from AdminApproverTypeModel + * @param {Object} newApproverType - new approver type object being created + * @param {Boolean} new - Tells if it is new approver to activate form or not + */ export default class AppApproverType extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -15,7 +23,6 @@ export default class AppApproverType extends Mixin(LitElement) return { existingApprovers:{type: Array, attribute: 'existingApprovers'}, newApproverType:{type: Object, attribute: 'newApproverType'}, - approver: {type: Object, attribute: 'approver'}, new: {type:Boolean, attribute: 'new'} } } @@ -36,6 +43,10 @@ export default class AppApproverType extends Mixin(LitElement) } + /** + * @description Change new property from to true + * @param {CustomEvent} e + */ _newForm(e) { this.new = true; } @@ -61,8 +72,9 @@ export default class AppApproverType extends Mixin(LitElement) } - /** + /** * @description Get all data required for rendering this page + * @return {Promise} */ async getPageData(){ let args = {status:"active"}; //if want all active do this to see your new ones @@ -85,7 +97,7 @@ export default class AppApproverType extends Mixin(LitElement) } /** - * @description reset properties for the approver + * @description reset properties * */ async _resetProperties(){ @@ -116,6 +128,10 @@ export default class AppApproverType extends Mixin(LitElement) } } + /** + * @description Adds a employee bar to the employees section + * + */ _onAddBar(e, approverType){ if(!approverType.employees) approverType.employees = []; approverType.employees.push({}); @@ -123,6 +139,10 @@ export default class AppApproverType extends Mixin(LitElement) this.requestUpdate(); } + /** + * @description Deletes a employee bar from the employees section + * + */ _onDeleteBar(e, employeeIndex, approverType) { if (!employeeIndex) { approverType.employees[0] = {}; @@ -132,24 +152,38 @@ export default class AppApproverType extends Mixin(LitElement) this.requestUpdate() } + /** + * @description Set the label for the approver object + * + */ async _setLabel(value, approver){ approver.label = value; this.requestUpdate(); } + /** + * @description Set the description for the approver object + * + */ async _setDescription(value, approver){ approver.description = value; this.requestUpdate(); } + /** + * @description checks if something is an object + * @param {Object} object + * @returns {Boolean} whether it is true or false + */ isObject(object) { return object != null && typeof object === 'object'; } /** - * @description Returns a approver type from the element's approverType array by approverTypeId - */ + * @description Returns a approver type from the element's approverType array by approverTypeId + * @param {Number} + */ getApproverTypeId(id){ return this.existingApprovers.find(item => item.approverTypeId == id); } @@ -157,6 +191,7 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description bound to AdminApproverTypeModel APPROVER_TYPE_QUERY_REQUEST event + * @param {CustomEvent} e */ async _onApproverTypeQueryRequest(e){ @@ -185,6 +220,7 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description bound to AdminApproverTypeModel APPROVER_TYPE_UPDATED event + * @param {CustomEvent} e */ async _onApproverTypeUpdated(e){ if ( e.state === 'error' ) { @@ -217,6 +253,7 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description bound to AdminApproverTypeModel APPROVER_TYPE_CREATED event + * @param {CustomEvent} e */ async _onApproverTypeCreated(e){ if ( e.state === 'error' ) { @@ -240,28 +277,8 @@ export default class AppApproverType extends Mixin(LitElement) /** - * @description on submit button get the form data - * - * let data = { - "approverTypeId": 0, - "label": "mkl", - "description": "wfe", - "systemGenerated": false, - "hideFromFundAssignment": false, - "archived": false, - "employees":[ - { - "employee":{kerberos: "MaybeF2", firstName:"F", lastName:"G", department:null}, - "approvalOrder": 5 - }, - { - "employee":{kerberos: "MaybeF22", firstName:"G", lastName:"H", department:null}, - "approvalOrder": 5 - } - ] - }; - - * + * @description on submit button get the form data + * @param {CustomEvent} e * */ async _onFormSubmit(e){ @@ -290,8 +307,10 @@ export default class AppApproverType extends Mixin(LitElement) this.requestUpdate(); } + /** * @description on edit button from a approver + * @param {Object} approver * @returns {Array} array of objects with updated employees * */ @@ -328,10 +347,10 @@ export default class AppApproverType extends Mixin(LitElement) let query = "status=active"; let storeApproverType = this.AdminApproverTypeModel.store.data.query[query].payload.find(at => at.approverTypeId === approver.approverTypeId); + for( let prop in storeApproverType ) { approver[prop] = storeApproverType[prop]; } - console.log(approver) approver.validationHandler = new ValidationHandler(); @@ -340,7 +359,7 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description on archive button from a approver - * + * @param {Object} approver */ async _onDelete(approver){ this.AppStateModel.showDialogModal({ @@ -356,7 +375,7 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description on dialog action for deleting an approver - * + * @param {CustomEvent} */ async _onDialogAction(e){ if ( e.action !== 'delete-approver-item' ) return; diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index 5ed1689..54e8364 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -19,8 +19,6 @@ return html` ${this.new ? renderApproverForm.call(this, this.newApproverType) : html`

`} - -
`;} @@ -105,25 +103,15 @@ function renderApproverForm(approver) {
`)} - -

-
- - +
- - -

diff --git a/src/client/js/pages/admin/app-page-admin-approvers.js b/src/client/js/pages/admin/app-page-admin-approvers.js index 8ad0a6c..8034d0a 100644 --- a/src/client/js/pages/admin/app-page-admin-approvers.js +++ b/src/client/js/pages/admin/app-page-admin-approvers.js @@ -3,6 +3,10 @@ import {render} from "./app-page-admin-approvers.tpl.js"; import { LitCorkUtils, Mixin } from "../../../../lib/appGlobals.js"; import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; import "../../components/app-approver-type.js" + +/** + * @description Admin page for managing approver type and funding options + */ export default class AppPageAdminApprovers extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { From 17a89eea986f71ebc205d5451f439e05157a6eb8 Mon Sep 17 00:00:00 2001 From: Sabrina Baggett Date: Sat, 8 Jun 2024 12:34:41 -0700 Subject: [PATCH 100/274] draft component added --- src/client/js/components/app-draft-list.js | 63 +++++++++++++++++++ .../js/components/app-draft-list.tpl.js | 31 +++++++++ .../app-page-approval-request-new.js | 5 +- .../app-page-approval-request-new.tpl.js | 5 ++ src/client/scss/components/draft-list.scss | 38 +++++++++++ src/client/scss/style.scss | 1 + 6 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/client/js/components/app-draft-list.js create mode 100644 src/client/js/components/app-draft-list.tpl.js create mode 100644 src/client/scss/components/draft-list.scss diff --git a/src/client/js/components/app-draft-list.js b/src/client/js/components/app-draft-list.js new file mode 100644 index 0000000..7802cac --- /dev/null +++ b/src/client/js/components/app-draft-list.js @@ -0,0 +1,63 @@ +import { LitElement } from 'lit'; +import {render} from "./app-draft-list.tpl.js"; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; + +export default class AppDraftList extends Mixin(LitElement) + .with(LitCorkUtils, MainDomElement) { + static get properties() { + return { + drafts: {type: Array}, + kerb: {type: String}, + existingDrafts: {type: Array} + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this.drafts = []; + this.existingDrafts = []; + + this._injectModel('ApprovalRequestModel', 'AuthModel'); + this.kerb = this.AuthModel.getToken().token.preferred_username; + } + + /** + * @description Retrieve necessary data for component + * @returns {Promise} + */ + async init(){ + const promises = [ + this.ApprovalRequestModel.query({employees:this.kerb}) + ]; + + return await Promise.allSettled(promises); + } + + /** + * @description Attached to approval-requests-fetched event from ApprovalRequestModel + * Fires when active approval requests are fetched from the server + * @param {Object} e - cork-app-utils event object + * @returns + */ + _onApprovalRequestsFetched(e) { + if ( e.state !== 'loaded' ) return; + this.existingDrafts = []; + + if (e.query.includes('request-ids')) { + const id = e.query.split('='); + let draftId = this.drafts; + this.existingDrafts = draftId.filter((draft) => draft.approvalRequestId !== Number(id[1])); + } else { + this.drafts = e.payload.data.filter(draft => draft.approvalStatus == "draft"); + } + + this.requestUpdate(); + } + + +} + +customElements.define('app-draft-list', AppDraftList); \ No newline at end of file diff --git a/src/client/js/components/app-draft-list.tpl.js b/src/client/js/components/app-draft-list.tpl.js new file mode 100644 index 0000000..23dc69e --- /dev/null +++ b/src/client/js/components/app-draft-list.tpl.js @@ -0,0 +1,31 @@ +import { html } from 'lit'; + +export function render() { +return html` +
+
+
+ + Your Drafts +
+

You have unsubmitted drafts. Click one to resume where you left off:

+
    + ${this.existingDrafts && this.existingDrafts.length !=0 ? + draftList.call(this, this.existingDrafts): + draftList.call(this, this.drafts) + } + +
+
+
+ +`;} + +function draftList(draftsList) { + return html` + ${draftsList && draftsList.map(d => html` +
  • ${d.label ? d.label: html`Unititled Request`} +
    ${new Date(d.submittedAt)} +
  • + `)} + `} \ No newline at end of file diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.js index 5da020b..cde9367 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.js @@ -48,8 +48,8 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) this.approvalFormId = 0; this.settingsCategory = 'approval-requests'; this.expenditureOptions = []; - this.fundingSourceSelectRef = createRef(); + this.draftListSelectRef = createRef(); this.waitController = new WaitController(this); this._injectModel( @@ -140,7 +140,8 @@ export default class AppPageApprovalRequestNew extends Mixin(LitElement) const promises = [ this.SettingsModel.getByCategory(this.settingsCategory), this.LineItemsModel.getActiveLineItems(), - this.fundingSourceSelectRef.value.init() + this.fundingSourceSelectRef.value.init(), + this.draftListSelectRef.value.init() ]; if ( this.approvalFormId ) { promises.push(this.ApprovalRequestModel.query({requestIds: this.approvalFormId})); diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js index 2738041..6a2a85f 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-new.tpl.js @@ -3,6 +3,8 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { ref } from 'lit/directives/ref.js'; import "../../components/funding-source-select.js"; +import "../../components/app-draft-list.js"; + export function render() { return html` @@ -22,6 +24,9 @@ return html` ${renderForm.call(this)} diff --git a/src/client/scss/components/draft-list.scss b/src/client/scss/components/draft-list.scss new file mode 100644 index 0000000..07b9aac --- /dev/null +++ b/src/client/scss/components/draft-list.scss @@ -0,0 +1,38 @@ +app-draft-list { + .component { + width: 390px; + background-color: #FFF9E6; + margin: 10px; + padding: 40px; + } + .inner-component{ + width:315px; + } + .icon { + color:#FFBF00; + font-weight:900; + font-size: 50px; + margin-left:0; + margin-right:10px; + } + .draftHeading { + color:#022851; + text-align:center; + vertical-align:center; + font-size: 28px; + font-weight:800; + } + .componentHeader{ + display: inline-flex; + align-items: center; + } + .listTitle{ + font-size: 19px; + } + .listDescription{ + font-size: 16px; + } + a { + cursor:pointer; + } + } \ No newline at end of file diff --git a/src/client/scss/style.scss b/src/client/scss/style.scss index 9d59845..b428986 100644 --- a/src/client/scss/style.scss +++ b/src/client/scss/style.scss @@ -18,3 +18,4 @@ @use './components/employee-search-advanced.scss' as employeeSearchAdvanced; @use './components/employee-search-basic.scss' as employeeSearchBasic; @use './components/funding-source-select.scss' as fundingSourceSelect; +@use './components/draft-list.scss' as draftList; From 280076def7f746522147a525acc77dc6a9e32954 Mon Sep 17 00:00:00 2001 From: Mark Warren Date: Mon, 10 Jun 2024 20:40:22 -0700 Subject: [PATCH 101/274] ititial committ --- src/client/js/components/site-wide-banner.js | 64 +++++++++++++++++++ .../js/components/site-wide-banner.tpl.js | 31 +++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/client/js/components/site-wide-banner.js create mode 100644 src/client/js/components/site-wide-banner.tpl.js diff --git a/src/client/js/components/site-wide-banner.js b/src/client/js/components/site-wide-banner.js new file mode 100644 index 0000000..dc4b20a --- /dev/null +++ b/src/client/js/components/site-wide-banner.js @@ -0,0 +1,64 @@ +import { LitElement } from 'lit'; +import { render } from "./site-wide-banner.tpl.js"; +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import { createRef } from 'lit/directives/ref.js'; +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +/** + * @description Generic dialog modal for app-wide use + * See AppStateModel.showDialogModal() for usage and accepted parameters + */ +export default class SiteWideBanner extends Mixin(LitElement) +.with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + modalTitle: {type: String}, + modalContent: {type: String}, + actions: {type: Array}, + data: {type: Object} + } + } + + + constructor() { + super(); + this.render = render.bind(this); + + this.modalTitle = ''; + this.modalContent = ''; + this.actions = []; + this.data = {}; + + this.dialogRef = createRef(); + + this._injectModel('AppStateModel'); + } + + /** + * @description Bound to AppStateModel dialog-open event + * Will open the dialog modal with the provided title, content, and actions + */ + _onDialogOpen(e){ + this.modalTitle = e.title; + this.modalContent = e.content; + this.actions = e.actions; + this.data = e.data; + + this.dialogRef.value.showModal(); + } + + /** + * @description Bound to dialog button(s) click event + * Will emit a dialog-action AppStateModel event with the action value and data + * @param {String} action - The action value to emit + */ + _onButtonClick(action){ + this.dialogRef.value.close(); + this.AppStateModel.emit('dialog-action', {action, data: this.data}); + + } + +} + +customElements.define('app-dialog-modal', AppDialogModal); \ No newline at end of file diff --git a/src/client/js/components/site-wide-banner.tpl.js b/src/client/js/components/site-wide-banner.tpl.js new file mode 100644 index 0000000..6d3044e --- /dev/null +++ b/src/client/js/components/site-wide-banner.tpl.js @@ -0,0 +1,31 @@ +import { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { ref } from 'lit/directives/ref.js'; + +export function render() { +return html` +
    +

    Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

    Primary Button

    + +

    Alternative Button

    +
    +
    +16x9 Image +
    This is some caption text for an image
    +
    +
    +

    Title for the wysiwyg-feature-block

    +
    +

    Sed vehicula metus tellus, ut scelerisque justo posuere eget. Suspendisse eu feugiat nibh.

    Title

    Title Intro

    Subtitle

    Subtitle Aux

    Subtitle 2

    Subtitle 3

    Title

    Title Intro

    Subtitle

    Subtitle Aux

    Subtitle 2

    Subtitle 3

    +
    +
    + + + +
    +`;} \ No newline at end of file From b32d29390b9aba0a40f0f4ef06ae07b485d23c6b Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Tue, 11 Jun 2024 12:34:07 -0400 Subject: [PATCH 102/274] fixes for #24 --- src/api/admin/approverType.js | 28 +- src/client/js/components/app-approver-type.js | 267 ++++----- .../js/components/app-approver-type.tpl.js | 95 +-- .../ucdlib-employee-search-basic.js | 10 +- src/client/scss/components/approver-type.scss | 10 +- .../cork/services/AdminApproverTypeService.js | 2 +- src/lib/cork/stores/AdminApproverTypeStore.js | 4 +- src/lib/db-models/approverType.js | 546 +++++++++--------- 8 files changed, 462 insertions(+), 500 deletions(-) diff --git a/src/api/admin/approverType.js b/src/api/admin/approverType.js index d4d80ff..088b89f 100644 --- a/src/api/admin/approverType.js +++ b/src/api/admin/approverType.js @@ -25,6 +25,9 @@ export default (api) => { const approverTypeData = req.body; const data = await approverType.create(approverTypeData); + if ( data.error && data.is400 ) { + return res.status(400).json(data); + } if ( data.error ) { console.error('Error in POST /approver-type', data.error); return res.status(500).json({error: true, message: 'Error creating approver-type.'}); @@ -36,14 +39,17 @@ export default (api) => { /** * @description Update an approver-type */ - api.put('/approver-type', protect('hasAdminAccess'), async (req, res) => { - const approverTypeData = req.body; - - const data = await approverType.update(approverTypeData); - if ( data.error ) { - console.error('Error in PUT /approver-type', data.error); - return res.status(500).json({error: true, message: 'Error updating approver-type.'}); - } - res.json({data: data, error: false}); - }); -}; \ No newline at end of file + api.put('/approver-type', protect('hasAdminAccess'), async (req, res) => { + const approverTypeData = req.body; + + const data = await approverType.update(approverTypeData); + if ( data.error && data.is400 ) { + return res.status(400).json(data); + } + if ( data.error ) { + console.error('Error in PUT /approver-type', data.error); + return res.status(500).json({error: true, message: 'Error updating approver-type.'}); + } + res.json({data: data, error: false}); + }); +}; diff --git a/src/client/js/components/app-approver-type.js b/src/client/js/components/app-approver-type.js index 8ad70db..5dbc57b 100644 --- a/src/client/js/components/app-approver-type.js +++ b/src/client/js/components/app-approver-type.js @@ -6,7 +6,6 @@ import "./ucdlib-employee-search-basic.js" import ValidationHandler from "../utils/ValidationHandler.js"; import IamEmployeeObjectAccessor from '../../../lib/utils/iamEmployeeObjectAccessor.js'; import urlUtils from "../../../lib/utils/urlUtils.js"; -import AppPageAdminApprovers from '../pages/admin/app-page-admin-approvers.js'; /** * @description Admin approvertype component for managing approver type options @@ -14,7 +13,6 @@ import AppPageAdminApprovers from '../pages/admin/app-page-admin-approvers.js'; * @param {Array} existingApprovers - local copy of active approvertype objects from AdminApproverTypeModel * @param {Object} newApproverType - new approver type object being created * @param {Boolean} new - Tells if it is new approver to activate form or not - */ export default class AppApproverType extends Mixin(LitElement) .with(LitCorkUtils, MainDomElement) { @@ -32,25 +30,19 @@ export default class AppApproverType extends Mixin(LitElement) super(); this.existingApprovers = []; this.newApproverType = {}; + this.settingsCategory = 'admin-approver-form'; + this.query = {status:"active"}; this.new = false; this.element = 'admin-approvers'; - + this.render = render.bind(this); this._injectModel('AppStateModel', 'AdminApproverTypeModel', 'SettingsModel'); this._resetProperties(); } - /** - * @description Change new property from to true - * @param {CustomEvent} e - */ - _newForm(e) { - this.new = true; - } - /** * @description bound to AppStateModel app-state-update event * @param {Object} state - AppStateModel state @@ -76,31 +68,40 @@ export default class AppApproverType extends Mixin(LitElement) * @description Get all data required for rendering this page * @return {Promise} */ - async getPageData(){ - let args = {status:"active"}; //if want all active do this to see your new ones - - const promises = []; - promises.push(this.SettingsModel.getByCategory(this.settingsCategory)); - promises.push(this.AdminApproverTypeModel.query(args)); - const resolvedPromises = await Promise.allSettled(promises); - return resolvedPromises; - } - + async getPageData(){ + const promises = [ + this.SettingsModel.getByCategory(this.settingsCategory), + this.AdminApproverTypeModel.query(this.query) + ]; + const resolvedPromises = await Promise.allSettled(promises); + return resolvedPromises; + } + /** * @description runs the refresh properties after edit/create/delete function runs - * + * */ - async _refreshProperties(){ - this._resetProperties(); - this.AppStateModel.refresh(); - } + async _refreshProperties(){ + this._resetProperties(); + this.AppStateModel.refresh(); + } /** * @description reset properties - * + * */ async _resetProperties(){ + this._resetNewApproverType(); + this.existingApprovers = []; + this.new = false; + + } + + /** + * @description reset new approver type object + */ + _resetNewApproverType(){ this.newApproverType = { approverTypeId: 0, label: "", @@ -110,29 +111,27 @@ export default class AppApproverType extends Mixin(LitElement) archived: false, employees: [], validationHandler : new ValidationHandler() - }; - this.existingApprovers = []; - this.new = false; - + }; } - /** + /** * @description Event handler for when employees are selected from the employee search component * @param {CustomEvent} e - status-change event from ucdlib-employee-search-basic - */ - _onEmployeeSelect(e, approverType, employeeIndex) { - let emp = e.detail.employee; - if(emp){ - emp = (new IamEmployeeObjectAccessor(emp)).travelAppObject; - approverType.employees[employeeIndex] = emp - } + */ + _onEmployeeSelect(e, approverType, employeeIndex) { + let emp = e.detail.employee; + if(emp){ + emp = (new IamEmployeeObjectAccessor(emp)).travelAppObject; + approverType.employees[employeeIndex] = emp + this.requestUpdate(); } + } /** - * @description Adds a employee bar to the employees section - * + * @description Adds a employee search bar to the employees section + * */ - _onAddBar(e, approverType){ + _onAddBar(approverType){ if(!approverType.employees) approverType.employees = []; approverType.employees.push({}); @@ -140,11 +139,11 @@ export default class AppApproverType extends Mixin(LitElement) } /** - * @description Deletes a employee bar from the employees section - * + * @description Deletes a employee search bar from the employees section + * */ - _onDeleteBar(e, employeeIndex, approverType) { - if (!employeeIndex) { + _onDeleteBar(employeeIndex, approverType) { + if (!employeeIndex && approverType.employees.length == 1) { approverType.employees[0] = {}; } else { approverType.employees.splice(employeeIndex, 1); @@ -154,7 +153,7 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description Set the label for the approver object - * + * */ async _setLabel(value, approver){ approver.label = value; @@ -163,28 +162,18 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description Set the description for the approver object - * + * */ async _setDescription(value, approver){ approver.description = value; this.requestUpdate(); } - - /** - * @description checks if something is an object - * @param {Object} object - * @returns {Boolean} whether it is true or false - */ - isObject(object) { - return object != null && typeof object === 'object'; - } - /** * @description Returns a approver type from the element's approverType array by approverTypeId - * @param {Number} + * @param {Number} */ - getApproverTypeId(id){ + getApproverTypeById(id){ return this.existingApprovers.find(item => item.approverTypeId == id); } @@ -192,41 +181,45 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description bound to AdminApproverTypeModel APPROVER_TYPE_QUERY_REQUEST event * @param {CustomEvent} e - */ - + */ async _onApproverTypeQueryRequest(e){ let query = e.query; - if ( e.state === 'loaded' && urlUtils.queryStringFromObject(query) == urlUtils.queryStringFromObject({'status':'active'})) { + if ( e.state === 'loaded' && urlUtils.queryStringFromObject(query) == urlUtils.queryStringFromObject(this.query)) { let approverArray = e.payload.filter(function (el) { return el.archived == false && el.hideFromFundAssignment == false; }); - approverArray.map((emp) => { - emp = {...emp}; - if(!Array.isArray(emp.employees)) emp.employees = [emp.employees]; - emp.editing = false; - emp.validationHandler = new ValidationHandler(); - return emp; - }); - - this.existingApprovers = approverArray; - + this.existingApprovers = approverArray.map(emp => this._copyApproverType(emp)); + this.requestUpdate(); } + } + /** + * @description Copy an approver type object and reset its state properties + * @param {Object} approverType - approver type object from the AdminApproverTypeModel + * @returns {Object} + */ + _copyApproverType(approverType){ + approverType = JSON.parse(JSON.stringify(approverType)); + if ( !Array.isArray(approverType.employees) ) approverType.employees = [approverType.employees]; + approverType.editing = false; + approverType.validationHandler = new ValidationHandler(); + return approverType; } /** * @description bound to AdminApproverTypeModel APPROVER_TYPE_UPDATED event + * Fires after an approver type is updated - successfully or not * @param {CustomEvent} e */ async _onApproverTypeUpdated(e){ if ( e.state === 'error' ) { if ( e.error?.payload?.is400 ) { const getApproverTypeId = e.data.approverTypeId; - const approverType = this.getApproverTypeId(getApproverTypeId); + const approverType = this.getApproverTypeById(getApproverTypeId); approverType.validationHandler = new ValidationHandler(e); this.AppStateModel.showLoaded(this.element) this.requestUpdate(); @@ -253,6 +246,7 @@ export default class AppApproverType extends Mixin(LitElement) /** * @description bound to AdminApproverTypeModel APPROVER_TYPE_CREATED event + * Fires after an approver type is created - successfully or not * @param {CustomEvent} e */ async _onApproverTypeCreated(e){ @@ -275,120 +269,85 @@ export default class AppApproverType extends Mixin(LitElement) } } - /** - * @description on submit button get the form data + * @description on submit button get the form data * @param {CustomEvent} e - * + * */ - async _onFormSubmit(e){ - e.preventDefault(); - this.lastScrollPosition = window.scrollY; - - const approverTypeId = e.target.getAttribute('approver-type-id'); - if ( approverTypeId != 0 && approverTypeId) { - let approverType = this.existingApprovers.find(a => a.approverTypeId == approverTypeId); - console.log(`Updating Approver Type No. ${approverTypeId} ...`); - await this.AdminApproverTypeModel.update(this.employeeFormat(approverType)); - } else { - await this.AdminApproverTypeModel.create(this.employeeFormat(this.newApproverType)); - console.log("Creating..."); - - } - + async _onFormSubmit(e){ + e.preventDefault(); + this.lastScrollPosition = window.scrollY; + + const approverTypeId = e.target.getAttribute('approver-type-id'); + if ( approverTypeId != 0 && approverTypeId) { + let approverType = this.getApproverTypeById(approverTypeId); + await this.AdminApproverTypeModel.update(approverType); + } else { + await this.AdminApproverTypeModel.create(this.newApproverType); } + } /** * @description on edit button from a approver - * + * */ - async _onEdit(e, approver){ - approver.editing = true; - this.requestUpdate(); - } - - - /** - * @description on edit button from a approver - * @param {Object} approver - * @returns {Array} array of objects with updated employees - * - */ - employeeFormat(approver){ - let employeeFormat = []; - - if(approver.employees == null) { - approver.employees = []; - return approver; - } - - for (let [index, a] of approver.employees.entries()){ - let samp = {employee:a, approvalOrder: index} - employeeFormat.push(samp); - } - approver.employees = employeeFormat; - return approver; -} + async _onEdit(e, approver){ + approver.editing = true; + this.requestUpdate(); + } /** * @description on edit Cancel button from a approver - * + * */ - async _onEditCancel(e, approver){ - - if (!approver.approverTypeId) { - this.new = false; - this.newApproverType = {}; - return; - } + async _onEditCancel(approver){ + + if (!approver.approverTypeId) { this.new = false; - approver.editing = false; + this._resetNewApproverType(); + return; + } - let query = "status=active"; + let query = urlUtils.queryStringFromObject(this.query); - let storeApproverType = this.AdminApproverTypeModel.store.data.query[query].payload.find(at => at.approverTypeId === approver.approverTypeId); + let storeApproverType = this.AdminApproverTypeModel.store.data.query[query].payload.find(at => at.approverTypeId === approver.approverTypeId); - for( let prop in storeApproverType ) { - approver[prop] = storeApproverType[prop]; - } + // get index of approver in existing approvers and update approver + let index = this.existingApprovers.findIndex(at => at.approverTypeId === approver.approverTypeId); + this.existingApprovers[index] = this._copyApproverType(storeApproverType); - approver.validationHandler = new ValidationHandler(); - - this.requestUpdate(); - } + this.requestUpdate(); + } /** * @description on archive button from a approver - * @param {Object} approver + * @param {Object} approver */ - async _onDelete(approver){ - this.AppStateModel.showDialogModal({ - title : 'Delete Approver Type Option', - content : 'Are you sure you want to delete this Approver Type Option?', - actions : [ - {text: 'Delete', value: 'delete-approver-item', color: 'double-decker'}, - {text: 'Cancel', value: 'cancel', invert: true, color: 'primary'} - ], - data : {approver} - }); + async _onDelete(approver){ + this.AppStateModel.showDialogModal({ + title : 'Delete Approver Type', + content : 'Are you sure you want to delete this approver type?', + actions : [ + {text: 'Delete', value: 'delete-approver-item', color: 'double-decker'}, + {text: 'Cancel', value: 'cancel', invert: true, color: 'primary'} + ], + data : {approver} + }); } /** * @description on dialog action for deleting an approver - * @param {CustomEvent} + * @param {CustomEvent} */ async _onDialogAction(e){ if ( e.action !== 'delete-approver-item' ) return; let approverItem = e.data.approver; approverItem.archived = true; - approverItem = this.employeeFormat(approverItem); await this.AdminApproverTypeModel.update(approverItem); - this._refreshProperties(); - } } -customElements.define('app-approver-type', AppApproverType); \ No newline at end of file +customElements.define('app-approver-type', AppApproverType); diff --git a/src/client/js/components/app-approver-type.tpl.js b/src/client/js/components/app-approver-type.tpl.js index 54e8364..188211a 100644 --- a/src/client/js/components/app-approver-type.tpl.js +++ b/src/client/js/components/app-approver-type.tpl.js @@ -2,7 +2,7 @@ import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import '@ucd-lib/theme-elements/brand/ucd-theme-brand-textbox/ucd-theme-brand-textbox.js' -export function render() { +export function render() { return html`

    Approvers

    @@ -14,10 +14,10 @@ return html` if(approver.editing) return renderApproverForm.call(this, approver); return renderApproverItem.call(this, approver); })} - + ${this.new ? renderApproverForm.call(this, this.newApproverType) : html` -

    +

    `}
    @@ -37,13 +37,13 @@ function renderApproverItem(ap) { this._onEdit(e, ap)} class='icon-link admin-blue'> - + ${!ap.systemGenerated ? html` this._onDelete(ap)} class='icon-link double-decker'> `:html``} - +
    ${ap.description}
    ${!ap.systemGenerated ? html` @@ -51,7 +51,7 @@ function renderApproverItem(ap) { ${ap.employees && ap.employees.map((employee) => html`
     ${employee ? html`${employee.firstName} ${employee.lastName}`:html``}
    -
    + `)} `:html` System Generated
    `} @@ -69,52 +69,53 @@ function renderApproverForm(approver) { return html`
    -
    -

    ${approver.editing ? html`Edit Approver`:html`Add Approver`} -
    - - this._setLabel(e.target.value, approver)} type="text" placeholder="Position Title"> -
    -
    - - -
    +

    ${approver.editing ? html`Edit Approver`:html`Add Approver`}

    +
    + + this._setLabel(e.target.value, approver)} type="text" placeholder="Approver Type Title"> +
    ${approver.validationHandler.renderErrorMessages('label')}
    +
    +
    + + +
    +
    + + this._onAddBar(approver)} class='icon-link quad'> + + +
    ${approver.validationHandler.renderErrorMessages('employees')}
    +
    +
    + ${approver.employees && approver.employees.map((emp, index) => html`
    - - this._onAddBar(e, approver)} class='icon-link quad'> - - + + this._onDeleteBar(index, approver)} class='icon-link double-decker'> + +
    - ${approver.employees && approver.employees.map((emp, index) => html` -
    - - this._onDeleteBar(e, index, approver)} class='icon-link double-decker'> - - -
    - `)} - - -

    - - -

    -
    - + `)}
    - + + +

    + + +

    +
    +
    ` -} \ No newline at end of file +} diff --git a/src/client/js/components/ucdlib-employee-search-basic.js b/src/client/js/components/ucdlib-employee-search-basic.js index e6161fd..12da8be 100644 --- a/src/client/js/components/ucdlib-employee-search-basic.js +++ b/src/client/js/components/ucdlib-employee-search-basic.js @@ -147,8 +147,14 @@ export default class UcdlibEmployeeSearchBasic extends Mixin(LitElement) */ async _getEmployeeRecordByAttribute(p){ if (p.has('selectedValue') && - this.selectedValue !== this.selectedObject.user_id && - this.selectedValue) { + this.selectedValue !== this.selectedObject.user_id ) { + + if ( !this.selectedValue ) { + this.selectedObject = {}; + this.selectedText = ''; + this.query = ''; + return; + } let iamObject = await this.EmployeeModel.getIamRecordById(this.selectedValue); this.iamRecordMissing = false; diff --git a/src/client/scss/components/approver-type.scss b/src/client/scss/components/approver-type.scss index 3d85944..498af57 100644 --- a/src/client/scss/components/approver-type.scss +++ b/src/client/scss/components/approver-type.scss @@ -1,7 +1,7 @@ app-approver-type { .approvertype-block{ - border-bottom: medium solid #DBEAF7; - padding:15px, 0px, 15px, 0px; + border-bottom: 2px solid #DBEAF7; + padding: 1rem 0; } .user-icon{ color:#13639E; @@ -47,8 +47,12 @@ app-approver-type { margin-top:10px; } + form { + border-bottom: medium solid #DBEAF7; + } + .approvertype-form{ - border-bottom: medium solid #DBEAF7; + max-width: 600px; } .btn { margin: 15px 10px 5px 0px; diff --git a/src/lib/cork/services/AdminApproverTypeService.js b/src/lib/cork/services/AdminApproverTypeService.js index d2db367..b95ace2 100644 --- a/src/lib/cork/services/AdminApproverTypeService.js +++ b/src/lib/cork/services/AdminApproverTypeService.js @@ -13,7 +13,7 @@ class AdminApproverTypeService extends BaseService { query(data){ return this.request({ url : `/api/admin/approver-type?${data}`, - checkCached: () => this.store.data.query[JSON.stringify(data)], + checkCached: () => this.store.data.query[data], onLoading : request => this.store.queryLoading(request, data), onLoad : result => this.store.queryLoaded(result.body, data), onError : e => this.store.queryError(e, data) diff --git a/src/lib/cork/stores/AdminApproverTypeStore.js b/src/lib/cork/stores/AdminApproverTypeStore.js index 6b6e77d..0c04d7d 100644 --- a/src/lib/cork/stores/AdminApproverTypeStore.js +++ b/src/lib/cork/stores/AdminApproverTypeStore.js @@ -77,14 +77,14 @@ class AdminApproverTypeStore extends BaseStore { }); } - updateLoaded(payload, id, data) { + updateLoaded(payload) { this._setUpdateState({ state : this.STATE.LOADED, payload }); } - updateError(error, id, data) { + updateError(error, data) { this._setUpdateState({ state : this.STATE.ERROR, error, diff --git a/src/lib/db-models/approverType.js b/src/lib/db-models/approverType.js index e2a1680..0330ac3 100644 --- a/src/lib/db-models/approverType.js +++ b/src/lib/db-models/approverType.js @@ -8,287 +8,273 @@ import employeeModel from "./employee.js"; */ class AdminApproverType { - constructor(){ - this.entityFields = new EntityFields([ - {dbName: 'approver_type_id', jsonName: 'approverTypeId', required: true}, - {dbName: 'label', jsonName: 'label', required: true, userEditable: true}, - {dbName: 'description', jsonName: 'description', userEditable: true}, - {dbName: 'system_generated', jsonName: 'systemGenerated', customValidation: this.systemValidation.bind(this)}, - {dbName: 'hide_from_fund_assignment', jsonName: 'hideFromFundAssignment'}, - {dbName: 'archived', jsonName: 'archived'}, - {dbName: 'approval_order', jsonName: 'approvalOrder'}, - {dbName: 'employees', jsonName: 'employees', customValidation: this.kerberosValidation.bind(this)}, - ]); - this.entityEmployeeFields = new EntityFields([ - {dbName: 'approver_type_id', jsonName: 'approverTypeId', required: true}, - {dbName: 'employee_kerberos', jsonName: 'employees', required: true}, - {dbName: 'approval_order', jsonName: 'approvalOrder'}, - - ]); + constructor(){ + this.entityFields = new EntityFields([ + {dbName: 'approver_type_id', jsonName: 'approverTypeId', required: true}, + {dbName: 'label', jsonName: 'label', required: true, userEditable: true}, + {dbName: 'description', jsonName: 'description', userEditable: true}, + {dbName: 'system_generated', jsonName: 'systemGenerated', customValidation: this.systemValidation.bind(this)}, + {dbName: 'hide_from_fund_assignment', jsonName: 'hideFromFundAssignment'}, + {dbName: 'archived', jsonName: 'archived'}, + {dbName: 'approval_order', jsonName: 'approvalOrder'}, + {dbName: 'employees', jsonName: 'employees', customValidation: this.kerberosValidation.bind(this)}, + ]); + } + + /** + * @description Query the approver type + * @param {Object} kwargs - optional arguments including: + * IDs: integer|array - Ids of approver types + * status = active: string - if status is "active", return only active (non-archived) approver Types + * status = archived: string - if status is "archived", return only archived (non-active) approver Types + * @returns {Object|Array} + * + * all props in approver_type camelcased + * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee + */ + async query(kwargs){ + let idArray; + + if(typeof(kwargs.id) === "number"){ + idArray = [kwargs.id]; + } else { + idArray = kwargs.id ? kwargs.id.split(',') : ''; + } + const status = kwargs.status ? kwargs.status : ''; + + const whereArgs = {}; + + if(idArray != '') whereArgs['ap.approver_type_id'] = idArray; + + if(status == "archived") { + whereArgs['ap.archived'] = true; + } else if (status == "active"){ + whereArgs['ap.archived'] = false; + } + + const whereClause = pg.toWhereClause(whereArgs); + + let query = ` + SELECT + ap.*, + ( + SELECT json_agg(json_build_object( + 'kerberos', emp.kerberos, + 'firstName', emp.first_name, + 'lastName', emp.last_name + )) + FROM employee emp + WHERE ate.employee_kerberos = emp.kerberos + ORDER BY ate.approval_order ASC + ) AS employees + FROM + approver_type ap + LEFT JOIN + approver_type_employee ate ON ap.approver_type_id = ate.approver_type_id + ${whereClause.sql ? `WHERE ${whereClause.sql}` : ''} + ` + + const res = await pg.query(query, whereClause.values); + let r = this.entityFields.toJsonArray(res.res.rows); + + if( res.error ) return res; + + const data = this.queryFormat(r); + return data; + } + + /** + * @description Formats the query results to combine the data based on approverTypeID + * @param {Number} data - Query Results + * + * @returns {Array} Array of Objects for the combined results + * + */ + async queryFormat(data){ + const mergedData = data.reduce((acc, item) => { + const existingItem = acc.find(element => element.approverTypeId === item.approverTypeId); + if (existingItem) { + existingItem.employees = existingItem.employees.concat(item.employees || []); + } else { + if ( !item.employees ) item.employees = []; + acc.push(item); } - /** - * @description Query the approver type - * @param {Object} kwargs - optional arguments including: - * IDs: integer|array - Ids of approver types - * status = active: string - if status is "active", return only active (non-archived) approver Types - * status = archived: string - if status is "archived", return only archived (non-active) approver Types - * @returns {Object|Array} - * - * all props in approver_type camelcased - * employees property should be an empty array or array of kerberos ids in order designated in approver_type_employee - */ - async query(kwargs){ - let idArray; - - if(typeof(kwargs.id) === "number"){ - idArray = [kwargs.id]; - } else { - idArray = kwargs.id ? kwargs.id.split(',') : ''; + return acc; + }, []); + return mergedData; + } + + /** + * @description Use data for employees validation + * @param {Object} data - Object of Entity fields with camelcase + * + * @returns {Object} {status: false, message:""} + */ + async systemValidation(field, value, out, payload){ + let error = {errorType: 'invalid', message: 'System Generated ApproverTypes can not be archived. Set Archive to false.'}; + if(value && payload.archived) { + out.valid = false; + this.entityFields.pushError(out, field, error); + return; + } + + error = {errorType: 'invalid', message: 'System Generated ApproverTypes can not have employees. Set employees to an empty array.'}; + if ( !value) return; + + if(!Array.isArray(payload.employees) || payload.employees.length != 0) { + out.valid = false; + this.entityFields.pushError(out, field, error); + return; + } + } + + /** + * @description Use data for kerberos validation + * @param {Object} data - Object of Entity fields with camelcase + * + * @returns {Object} {status: false, message:""} + */ + async kerberosValidation(field, value, out, payload){ + if ( payload.system_generated || payload.archived ) return; + let error = {errorType: 'invalid', message: 'At least one employee must be assigned to a non-system generated approver type.'}; + if ( !Array.isArray(value) || value.length == 0 ) { + this.entityFields.pushError(out, field, error); + return; + } + const noKerberos = value.every(v => ( v.kerberos && v.kerberos != '' && v.kerberos != undefined)); + + if ( !noKerberos ) { + this.entityFields.pushError(out, field, error); + return; + } + } + + /** + * @description Create the admin approver type table + * @param {Object} data - Object of Entity fields with camelcase + * + * @returns {Object} {error: false} + */ + async create(data){ + data = this.entityFields.toDbObj(data); + const validation = await this.entityFields.validate(data, ['approver_type_id']); + + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + + const client = await pg.pool.connect(); + let out = {}; + let approverTypeId; + + try{ + let approverEmployee = data.employees || []; + + await client.query('BEGIN'); + + delete data.approver_type_id; + delete data.employees; + + data = pg.prepareObjectForInsert(data); + + const sql = `INSERT INTO approver_type (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING approver_type_id`; + + const res = await client.query(sql, data.values); + approverTypeId = res.rows[0].approver_type_id; + for (const [index, employee] of approverEmployee.entries()) { + + await employeeModel.upsertInTransaction(client, employee); + + const toEmployeeCreate = { + 'approver_type_id' : approverTypeId, + 'employee_kerberos': employee.kerberos, + 'approval_order': index + }; + + let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeCreate); + + const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholders})`; + await client.query(employeeSql, approverEmployeeData.values); } - const status = kwargs.status ? kwargs.status : ''; - - const whereArgs = {}; - - if(idArray != '') whereArgs['ap.approver_type_id'] = idArray; - - if(status == "archived") { - whereArgs['ap.archived'] = true; - } else if (status == "active"){ - whereArgs['ap.archived'] = false; - } - - const whereClause = pg.toWhereClause(whereArgs); - - let query = ` - SELECT - ap.*, - ( - SELECT json_agg(json_build_object( - 'kerberos', emp.kerberos, - 'firstName', emp.first_name, - 'lastName', emp.last_name - )) - FROM employee emp - WHERE ate.employee_kerberos = emp.kerberos - ) AS employees - FROM - approver_type ap - LEFT JOIN - approver_type_employee ate ON ap.approver_type_id = ate.approver_type_id - ${whereClause.sql ? `WHERE ${whereClause.sql}` : ''} - ` - - const res = await pg.query(query, whereClause.values); - let r = this.entityFields.toJsonArray(res.res.rows); - - if( res.error ) return res; - - const data = this.queryFormat(r); - return data; - } - - /** - * @description Formats the query results to combine the data based on approverTypeID - * @param {Number} data - Query Results - * - * @returns {Array} Array of Objects for the combined results - * - */ - async queryFormat(data){ - const mergedData = data.reduce((acc, item) => { - const existingItem = acc.find(element => element.approverTypeId === item.approverTypeId); - if (existingItem) { - existingItem.employees = existingItem.employees.concat(item.employees || []); - } else { - acc.push(item); - } - return acc; - }, []); - return mergedData; - } - - /** - * @description Use data for employees validation - * @param {Object} data - Object of Entity fields with camelcase - * - * @returns {Object} {status: false, message:""} - */ - async systemValidation(field, value, out, payload){ - let error = {errorType: 'invalid', message: 'System Generated ApproverTypes can not be archived. Set Archive to false.'}; - if(value && payload.archived) { - out.valid = false; - this.entityFields.pushError(out, field, error); - return; - } - - error = {errorType: 'invalid', message: 'System Generated ApproverTypes can not have employees. Set employees to an empty array.'}; - if(value && payload["employees"].length != 0) { - out.valid = false; - this.entityFields.pushError(out, field, error); - return; - } - } - - /** - * @description Use data for kerberos validation - * @param {Object} data - Object of Entity fields with camelcase - * - * @returns {Object} {status: false, message:""} - */ - async kerberosValidation(field, value, out){ - let error = {errorType: 'invalid', message: 'Employee Kerberos Error.'}; - const noKerberos = value.every(v => ( v.employee.kerberos && v.employee.kerberos != '' && v.employee.kerberos != undefined)); - - if ( !noKerberos ) { - this.entityFields.pushError(out, field, error); - return; - } - } - - /** - * @description Create the admin approver type table - * @param {Object} data - Object of Entity fields with camelcase - * - * @returns {Object} {error: false} - */ - async create(data){ - data = this.entityFields.toDbObj(data); - const validation = await this.entityFields.validate(data, ['approver_type_id']); - - if ( !validation.valid ) { - return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; - } - - const client = await pg.pool.connect(); - let out = {}; - let approverTypeId; - - try{ - let approverEmployee = data.employees; - - await client.query('BEGIN'); - - delete data.approver_type_id; - delete data.employees; - - data = pg.prepareObjectForInsert(data); - - const sql = `INSERT INTO approver_type (${data.keysString}) VALUES (${data.placeholdersString}) RETURNING approver_type_id`; - - const res = await client.query(sql, data.values); - approverTypeId = res.rows[0].approver_type_id; - - if ( Object.keys(approverEmployee).length ) { - - for (const [index, a] of approverEmployee.entries()) { - - await employeeModel.upsertInTransaction(client, a.employee); - - const toEmployeeCreate = { - 'approver_type_id' : approverTypeId, - 'employee_kerberos': a.employee.kerberos - }; - - if ( a.approvalOrder ){ - toEmployeeCreate['approval_order'] = index; - } - if ( !Object.keys(toEmployeeCreate).length ){ - return pg.returnError('no valid fields to update'); - } - - let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeCreate); - - const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholders})`; - await client.query(employeeSql, approverEmployeeData.values); - } - } - await client.query('COMMIT'); - } catch (error) { - await client.query('ROLLBACK'); - out.err = error; - } finally { - client.release(); - } - - out.res = await this.query({id: approverTypeId}); - out.err = false; - - return out; - } - - /** - * @description Update the admin approver type table - * @param {Object} data - Object of Entity fields with camelcase - * use a transaction if changes are needed to the employee list - * - * @returns {Object} {error: false} - */ - async update(data){ - data = this.entityFields.toDbObj(data); - - const validation = await this.entityFields.validate(data); - - if ( !validation.valid ) { - return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; - } - - const client = await pg.pool.connect(); - let out = {}; - let approverTypeId; - - try{ - let approverEmployee = data.employees; - approverTypeId = data.approver_type_id; - - await client.query('BEGIN'); - - delete data.employees; - - const updateClause = pg.toUpdateClause(data); - const sql = ` - UPDATE approver_type - SET ${updateClause.sql} - WHERE approver_type_id = $${updateClause.values.length + 1} - RETURNING approver_type_id - `; - - await client.query(`DELETE FROM approver_type_employee WHERE approver_type_id = ($1)`, [approverTypeId]); - await client.query(sql, [...updateClause.values, approverTypeId]); - - if ( Object.keys(approverEmployee).length ) { - for (const [index, a] of approverEmployee.entries()) { - await employeeModel.upsertInTransaction(client, a.employee); - - const toEmployeeUpdate = { - 'approver_type_id' : approverTypeId, - 'employee_kerberos': a.employee.kerberos - }; - - if ( a.approvalOrder ){ - toEmployeeUpdate['approval_order'] = index; - } - if ( !Object.keys(toEmployeeUpdate).length ){ - return pg.returnError('no valid fields to update'); - } - - let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeUpdate); - - const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholdersString}) RETURNING *`; - await client.query(employeeSql, approverEmployeeData.values); - } - } - await client.query('COMMIT'); - } catch (error) { - await client.query('ROLLBACK'); - out.err = error; - } finally { - client.release(); - } - out.res = await this.query({id: approverTypeId}); - out.err = false; - - return out; - + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + out.err = error; + } finally { + client.release(); + } + + out.res = await this.query({id: approverTypeId}); + out.err = false; + + return out; + } + + /** + * @description Update the admin approver type table + * @param {Object} data - Object of Entity fields with camelcase + * use a transaction if changes are needed to the employee list + * + * @returns {Object} {error: false} + */ + async update(data){ + data = this.entityFields.toDbObj(data); + + const validation = await this.entityFields.validate(data); + + if ( !validation.valid ) { + return {error: true, message: 'Validation Error', is400: true, fieldsWithErrors: validation.fieldsWithErrors}; + } + + const client = await pg.pool.connect(); + let out = {}; + let approverTypeId; + + try{ + let approverEmployee = data.employees || []; + approverTypeId = data.approver_type_id; + + await client.query('BEGIN'); + + delete data.employees; + + const updateClause = pg.toUpdateClause(data); + const sql = ` + UPDATE approver_type + SET ${updateClause.sql} + WHERE approver_type_id = $${updateClause.values.length + 1} + RETURNING approver_type_id + `; + + await client.query(`DELETE FROM approver_type_employee WHERE approver_type_id = ($1)`, [approverTypeId]); + await client.query(sql, [...updateClause.values, approverTypeId]); + + for (const [index, employee] of approverEmployee.entries()) { + await employeeModel.upsertInTransaction(client, employee); + + const toEmployeeUpdate = { + 'approver_type_id' : approverTypeId, + 'employee_kerberos': employee.kerberos, + 'approval_order': index + }; + + let approverEmployeeData = pg.prepareObjectForInsert(toEmployeeUpdate); + + const employeeSql = `INSERT INTO approver_type_employee (${approverEmployeeData.keysString}) VALUES (${approverEmployeeData.placeholdersString}) RETURNING *`; + await client.query(employeeSql, approverEmployeeData.values); } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + out.err = error; + } finally { + client.release(); + } + out.res = await this.query({id: approverTypeId}); + out.err = false; + + return out; + } } -export default new AdminApproverType(); \ No newline at end of file + +export default new AdminApproverType(); From 6b3541497a76a0b354f9cfefd25aa6375e40afae Mon Sep 17 00:00:00 2001 From: spelkey-ucd Date: Wed, 12 Jun 2024 16:29:42 -0400 Subject: [PATCH 103/274] #51 --- .../js/components/approval-request-details.js | 90 +++++++++++++++++++ .../approval-request-details.tpl.js | 26 ++++++ .../components/funding-source-select.tpl.js | 5 +- .../app-page-approval-request-confirm.js | 22 ++++- .../app-page-approval-request-confirm.tpl.js | 12 ++- src/lib/utils/typeTransform.js | 36 ++++++++ 6 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 src/client/js/components/approval-request-details.js create mode 100644 src/client/js/components/approval-request-details.tpl.js diff --git a/src/client/js/components/approval-request-details.js b/src/client/js/components/approval-request-details.js new file mode 100644 index 0000000..0a29670 --- /dev/null +++ b/src/client/js/components/approval-request-details.js @@ -0,0 +1,90 @@ +import { LitElement } from 'lit'; +import {render} from "./approval-request-details.tpl.js"; + +import { MainDomElement } from "@ucd-lib/theme-elements/utils/mixins/main-dom-element.js"; + +import { LitCorkUtils, Mixin } from "../../../lib/appGlobals.js"; +import typeTransform from '../../../lib/utils/typeTransform.js'; + +/** + * @class ApprovalRequestDetails + * @description Component that displays the details of an approval request + * @property {Object} approvalRequest - The approval request object + */ +export default class ApprovalRequestDetails extends Mixin(LitElement) + .with(LitCorkUtils, MainDomElement) { + + static get properties() { + return { + approvalRequest: {type: Object} + } + } + + constructor() { + super(); + this.render = render.bind(this); + + this.approvalRequest = {}; + } + + /** + * @description Get first and last name of the employee + * @returns {String} + */ + getEmployeeName(){ + return `${this.approvalRequest.employee?.firstName || ''} ${this.approvalRequest.employee?.lastName || ''}`; + } + + /** + * @description Get the location for the approval request + * @returns {String} + */ + getLocation(){ + const locations = { + 'in-state': 'In State', + 'out-of-state': 'Out of State', + 'foreign': 'Foreign', + 'virtual': 'Virtual' + }; + const location = locations[this.approvalRequest.location] || this.approvalRequest.location; + return `${location}${this.approvalRequest.locationDetails ? ` - ${this.approvalRequest.locationDetails}` : ''}`; + } + + /** + * @description Get the program dates for the approval request + * @returns {String} + */ + getProgramDates(){ + const startDate = typeTransform.toDateFromISO(this.approvalRequest.programStartDate); + const endDate = typeTransform.toDateFromISO(this.approvalRequest.programEndDate); + + if ( !startDate ) return ''; + + if ( !endDate || startDate.getTime() === endDate.getTime() ) { + return typeTransform.toUtcString(startDate); + } + + return `${typeTransform.toUtcString(startDate)} - ${typeTransform.toUtcString(endDate)}`; + } + + /** + * @description Get the travel dates for the approval request + * @returns {String} + */ + getTravelDates(){ + if ( !this.approvalRequest.travelRequired ) return ''; + const startDate = typeTransform.toDateFromISO(this.approvalRequest.travelStartDate); + const endDate = typeTransform.toDateFromISO(this.approvalRequest.travelEndDate); + + if ( !startDate ) return ''; + + if ( !endDate || startDate.getTime() === endDate.getTime() ) { + return typeTransform.toUtcString(startDate); + } + + return `${typeTransform.toUtcString(startDate)} - ${typeTransform.toUtcString(endDate)}`; + } + +} + +customElements.define('approval-request-details', ApprovalRequestDetails); diff --git a/src/client/js/components/approval-request-details.tpl.js b/src/client/js/components/approval-request-details.tpl.js new file mode 100644 index 0000000..e3f54a4 --- /dev/null +++ b/src/client/js/components/approval-request-details.tpl.js @@ -0,0 +1,26 @@ +import { html } from 'lit'; + +/** + * @description Main render function for this component + * @returns {TemplateResult} + */ +export function render() { + return html` + ${renderField('Title', this.approvalRequest.label)} + ${renderField('Employee', this.getEmployeeName())} + ${renderField('Name of Sponsoring Organization', this.approvalRequest.organization)} + ${renderField('Business Purpose', this.approvalRequest.businessPurpose)} + ${renderField('Location', this.getLocation())} + ${renderField('Program Dates', this.getProgramDates())} + ${renderField('Travel Dates', this.getTravelDates(), 'Not Applicable')} + ${renderField('Comments', this.approvalRequest.comments, 'None')} + `;} + +function renderField(label, value, defaultValue='') { + return html` +
    +
    ${label}
    +
    ${value || defaultValue}
    +
    + `; +} diff --git a/src/client/js/components/funding-source-select.tpl.js b/src/client/js/components/funding-source-select.tpl.js index d296c9b..63f01a8 100644 --- a/src/client/js/components/funding-source-select.tpl.js +++ b/src/client/js/components/funding-source-select.tpl.js @@ -96,5 +96,8 @@ function renderForm(){ * @description Render the read-only list view */ function renderList(){ - return html``; + return html` +
    + +
    `; } diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js index dccca20..9acc66b 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.js @@ -15,7 +15,7 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) approvalRequest : {type: Object}, approvalChain : {type: Array}, formLink : {type: String}, - + totalExpenditures: {type: Number} } } @@ -84,6 +84,12 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) return promiseUtils.flattenAllSettledResults(resolvedPromises); } + willUpdate(props){ + if ( props.has('approvalRequest') ){ + this._setTotalExpenditures(); + } + } + _onApprovalRequestChainFetched(e) { if ( e.state !== 'loaded' ) return; if ( e.approvalRequestId !== this.approvalRequestId ) return; @@ -133,6 +139,20 @@ export default class AppPageApprovalRequestConfirm extends Mixin(LitElement) this.approvalRequestId = Number.isInteger(approvalRequestId) && approvalRequestId > 0 ? approvalRequestId : 0; } + /** + * @description Set the totalExpenditures property based on the expenditures array from the current approval request + */ + _setTotalExpenditures(){ + let total = 0; + if ( this.approvalRequest.expenditures ){ + this.approvalRequest.expenditures.forEach(e => { + if ( !e.amount ) return; + total += Number(e.amount); + }); + } + this.totalExpenditures = total; + } + } customElements.define('app-page-approval-request-confirm', AppPageApprovalRequestConfirm); diff --git a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js index 2b2ced2..81f31d2 100644 --- a/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js +++ b/src/client/js/pages/approval-requests/app-page-approval-request-confirm.tpl.js @@ -2,12 +2,22 @@ import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import '../../components/approval-request-status-action.js'; +import '../../components/approval-request-details.js'; export function render() { return html`
    -
    +
    +

    Trip, Training, or Professional Development Opportunity

    + +

    Estimated Expenses

    + + +