testing dynamics gp integration

deepsource-autofix-76c6eb20
Dan Gowans 2023-03-06 13:51:07 -05:00
parent e58b9d32ab
commit fe12e1d172
30 changed files with 558 additions and 261 deletions

View File

@ -11,7 +11,10 @@ export const config = {
lotOccupancy: {},
workOrders: {},
adminCleanup: {},
printPdf: {}
printPdf: {},
dynamicsGP: {
integrationIsEnabled: false
}
}
};
export default config;

View File

@ -13,7 +13,10 @@ export const config: Config = {
lotOccupancy: {},
workOrders: {},
adminCleanup: {},
printPdf: {}
printPdf: {},
dynamicsGP: {
integrationIsEnabled: false
}
}
}

View File

@ -48,4 +48,8 @@ config.settings.map.mapCityDefault = 'Sault Ste. Marie';
config.settings.workOrders.workOrderNumberLength = 6;
config.settings.workOrders.workOrderMilestoneDateRecentBeforeDays = 7;
config.settings.workOrders.workOrderMilestoneDateRecentAfterDays = 30;
config.settings.dynamicsGP = {
integrationIsEnabled: true,
lookupOrder: ['diamond/cashReceipt', 'invoice']
};
export default config;

View File

@ -66,4 +66,9 @@ config.settings.workOrders.workOrderNumberLength = 6
config.settings.workOrders.workOrderMilestoneDateRecentBeforeDays = 7
config.settings.workOrders.workOrderMilestoneDateRecentAfterDays = 30
config.settings.dynamicsGP = {
integrationIsEnabled: true,
lookupOrder: ['diamond/cashReceipt', 'invoice']
}
export default config

View File

@ -8,4 +8,5 @@ config.users = {
canUpdate: ['*testUpdate'],
isAdmin: ['*testAdmin']
};
config.settings.dynamicsGP.integrationIsEnabled = false;
export default config;

View File

@ -13,4 +13,6 @@ config.users = {
isAdmin: ['*testAdmin']
}
config.settings.dynamicsGP!.integrationIsEnabled = false
export default config

View File

@ -1,5 +1,6 @@
import './polyfills.js';
import type * as configTypes from '../types/configTypes';
import type { config as MSSQLConfig } from 'mssql';
export declare function getProperty(propertyName: 'application.applicationName'): string;
export declare function getProperty(propertyName: 'application.logoURL'): string;
export declare function getProperty(propertyName: 'application.httpPort'): number;
@ -48,4 +49,7 @@ export declare function getProperty(propertyName: 'settings.workOrders.calendarE
export declare function getProperty(propertyName: 'settings.workOrders.prints'): string[];
export declare function getProperty(propertyName: 'settings.adminCleanup.recordDeleteAgeDays'): number;
export declare function getProperty(propertyName: 'settings.printPdf.contentDisposition'): 'attachment' | 'inline';
export declare function getProperty(propertyName: 'settings.dynamicsGP.integrationIsEnabled'): boolean;
export declare function getProperty(propertyName: 'settings.dynamicsGP.mssqlConfig'): MSSQLConfig;
export declare function getProperty(propertyName: 'settings.dynamicsGP.lookupOrder'): configTypes.DynamicsGPLookup[];
export declare const keepAliveMillis: number;

View File

@ -50,6 +50,8 @@ configFallbackValues.set('settings.workOrders.prints', [
]);
configFallbackValues.set('settings.adminCleanup.recordDeleteAgeDays', 60);
configFallbackValues.set('settings.printPdf.contentDisposition', 'attachment');
configFallbackValues.set('settings.dynamicsGP.integrationIsEnabled', false);
configFallbackValues.set('settings.dynamicsGP.lookupOrder', ['invoice']);
export function getProperty(propertyName) {
const propertyNameSplit = propertyName.split('.');
let currentObject = config;

View File

@ -6,6 +6,9 @@ import { config } from '../data/config.js'
import type * as configTypes from '../types/configTypes'
// eslint-disable-next-line node/no-extraneous-import
import type { config as MSSQLConfig } from 'mssql'
/*
* SET UP FALLBACK VALUES
*/
@ -94,6 +97,9 @@ configFallbackValues.set('settings.adminCleanup.recordDeleteAgeDays', 60)
configFallbackValues.set('settings.printPdf.contentDisposition', 'attachment')
configFallbackValues.set('settings.dynamicsGP.integrationIsEnabled', false)
configFallbackValues.set('settings.dynamicsGP.lookupOrder', ['invoice'])
/*
* Set up function overloads
*/
@ -211,6 +217,18 @@ export function getProperty(
propertyName: 'settings.printPdf.contentDisposition'
): 'attachment' | 'inline'
export function getProperty(
propertyName: 'settings.dynamicsGP.integrationIsEnabled'
): boolean
export function getProperty(
propertyName: 'settings.dynamicsGP.mssqlConfig'
): MSSQLConfig
export function getProperty(
propertyName: 'settings.dynamicsGP.lookupOrder'
): configTypes.DynamicsGPLookup[]
export function getProperty(propertyName: string): unknown {
const propertyNameSplit = propertyName.split('.')

View File

@ -0,0 +1,2 @@
import type { DynamicsGPDocument } from '../types/recordTypes.js';
export declare function getDynamicsGPDocument(documentNumber: string): Promise<DynamicsGPDocument | undefined>;

View File

@ -0,0 +1,62 @@
import * as gp from '@cityssm/dynamics-gp/gp.js';
import * as diamond from '@cityssm/dynamics-gp/diamond.js';
import * as configFunctions from './functions.config.js';
if (configFunctions.getProperty('settings.dynamicsGP.integrationIsEnabled')) {
gp.setMSSQLConfig(configFunctions.getProperty('settings.dynamicsGP.mssqlConfig'));
diamond.setMSSQLConfig(configFunctions.getProperty('settings.dynamicsGP.mssqlConfig'));
}
async function _getDynamicsGPDocument(documentNumber, lookupType) {
let document;
switch (lookupType) {
case 'invoice': {
const invoice = await gp.getInvoiceByInvoiceNumber(documentNumber);
if (invoice) {
document = {
documentType: 'Invoice',
documentNumber: invoice.invoiceNumber,
documentDate: invoice.documentDate,
documentDescription: [
invoice.comment1,
invoice.comment2,
invoice.comment3,
invoice.comment4
],
documentTotal: invoice.documentAmount
};
}
break;
}
case 'diamond/cashReceipt': {
const receipt = await diamond.getCashReceiptByDocumentNumber(documentNumber);
if (receipt) {
document = {
documentType: 'Cash Receipt',
documentNumber: receipt.documentNumber.toString(),
documentDate: receipt.documentDate,
documentDescription: [
receipt.description,
receipt.description2,
receipt.description3,
receipt.description4,
receipt.description5
],
documentTotal: receipt.total
};
}
}
}
return document;
}
export async function getDynamicsGPDocument(documentNumber) {
if (!configFunctions.getProperty('settings.dynamicsGP.integrationIsEnabled')) {
return;
}
let document;
for (const lookupType of configFunctions.getProperty('settings.dynamicsGP.lookupOrder')) {
document = await _getDynamicsGPDocument(documentNumber, lookupType);
if (document !== undefined) {
break;
}
}
return document;
}

View File

@ -0,0 +1,95 @@
/* eslint-disable unicorn/filename-case */
import * as gp from '@cityssm/dynamics-gp/gp.js'
import * as diamond from '@cityssm/dynamics-gp/diamond.js'
import * as configFunctions from './functions.config.js'
import type { DynamicsGPLookup } from '../types/configTypes'
import type { DynamicsGPDocument } from '../types/recordTypes.js'
if (configFunctions.getProperty('settings.dynamicsGP.integrationIsEnabled')) {
gp.setMSSQLConfig(
configFunctions.getProperty('settings.dynamicsGP.mssqlConfig')
)
diamond.setMSSQLConfig(
configFunctions.getProperty('settings.dynamicsGP.mssqlConfig')
)
}
async function _getDynamicsGPDocument(
documentNumber: string,
lookupType: DynamicsGPLookup
): Promise<DynamicsGPDocument | undefined> {
let document: DynamicsGPDocument | undefined
switch (lookupType) {
case 'invoice': {
const invoice = await gp.getInvoiceByInvoiceNumber(documentNumber)
if (invoice) {
document = {
documentType: 'Invoice',
documentNumber: invoice.invoiceNumber,
documentDate: invoice.documentDate,
documentDescription: [
invoice.comment1,
invoice.comment2,
invoice.comment3,
invoice.comment4
],
documentTotal: invoice.documentAmount
}
}
break
}
case 'diamond/cashReceipt': {
const receipt = await diamond.getCashReceiptByDocumentNumber(
documentNumber
)
if (receipt) {
document = {
documentType: 'Cash Receipt',
documentNumber: receipt.documentNumber.toString(),
documentDate: receipt.documentDate,
documentDescription: [
receipt.description,
receipt.description2,
receipt.description3,
receipt.description4,
receipt.description5
],
documentTotal: receipt.total
}
}
}
}
return document
}
export async function getDynamicsGPDocument(
documentNumber: string
): Promise<DynamicsGPDocument | undefined> {
if (
!configFunctions.getProperty('settings.dynamicsGP.integrationIsEnabled')
) {
return
}
let document: DynamicsGPDocument | undefined
for (const lookupType of configFunctions.getProperty(
'settings.dynamicsGP.lookupOrder'
)) {
document = await _getDynamicsGPDocument(documentNumber, lookupType)
if (document !== undefined) {
break
}
}
return document
}

View File

@ -1,5 +1,7 @@
import { acquireConnection } from './pool.js';
import { dateIntegerToString, timeIntegerToString } from '@cityssm/expressjs-server-js/dateTimeFns.js';
import * as configFunctions from '../functions.config.js';
import * as gpFunctions from '../functions.dynamicsGP.js';
export async function getLotOccupancyTransactions(lotOccupancyId, connectedDatabase) {
const database = connectedDatabase ?? (await acquireConnection());
database.function('userFn_dateIntegerToString', dateIntegerToString);
@ -17,6 +19,16 @@ export async function getLotOccupancyTransactions(lotOccupancyId, connectedDatab
if (connectedDatabase === undefined) {
database.release();
}
if (configFunctions.getProperty('settings.dynamicsGP.integrationIsEnabled')) {
for (const transaction of lotOccupancyTransactions) {
if ((transaction.externalReceiptNumber ?? '') !== '') {
const gpDocument = await gpFunctions.getDynamicsGPDocument(transaction.externalReceiptNumber);
if (gpDocument !== undefined) {
transaction.dynamicsGPDocument = gpDocument;
}
}
}
}
return lotOccupancyTransactions;
}
export default getLotOccupancyTransactions;

View File

@ -6,6 +6,9 @@ import {
timeIntegerToString
} from '@cityssm/expressjs-server-js/dateTimeFns.js'
import * as configFunctions from '../functions.config.js'
import * as gpFunctions from '../functions.dynamicsGP.js'
import type * as recordTypes from '../../types/recordTypes'
export async function getLotOccupancyTransactions(
@ -35,6 +38,20 @@ export async function getLotOccupancyTransactions(
database.release()
}
if (configFunctions.getProperty('settings.dynamicsGP.integrationIsEnabled')) {
for (const transaction of lotOccupancyTransactions) {
if ((transaction.externalReceiptNumber ?? '') !== '') {
const gpDocument = await gpFunctions.getDynamicsGPDocument(
transaction.externalReceiptNumber!
)
if (gpDocument !== undefined) {
transaction.dynamicsGPDocument = gpDocument
}
}
}
}
return lotOccupancyTransactions
}

429
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
},
"scripts": {
"start": "cross-env NODE_ENV=production node ./bin/www",
"dev:test": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:* TEST_DATABASES=true nodemon ./bin/www.js",
"dev:test": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:*,dynamics-gp:* TEST_DATABASES=true nodemon ./bin/www.js",
"dev:test:process": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:* TEST_DATABASES=true nodemon ./bin/wwwProcess.js",
"dev:live": "cross-env NODE_ENV=dev DEBUG=lot-occupancy-system:* nodemon ./bin/www.js",
"cy:open": "cypress open --config-file cypress.config.js",
@ -36,6 +36,7 @@
"@cityssm/bulma-js": "^0.4.0",
"@cityssm/bulma-webapp-js": "^1.5.0",
"@cityssm/date-diff": "^2.2.3",
"@cityssm/dynamics-gp": "^0.2.1",
"@cityssm/expressjs-server-js": "^2.3.3",
"@cityssm/ntfy-publish": "^0.2.1",
"@cityssm/pdf-puppeteer": "^2.0.0-beta.1",
@ -90,13 +91,14 @@
"@types/http-errors": "^2.0.1",
"@types/leaflet": "^1.9.1",
"@types/mocha": "^10.0.1",
"@types/mssql": "^8.1.2",
"@types/node-windows": "^0.1.2",
"@types/papaparse": "^5.3.7",
"@types/randomcolor": "^0.5.7",
"@types/session-file-store": "^1.2.2",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"axe-core": "^4.6.3",
"bulma": "^0.9.4",
"bulma-divider": "^0.2.0",

View File

@ -1455,14 +1455,34 @@ Object.defineProperty(exports, "__esModule", { value: true });
tableRowElement.className = 'container--lotOccupancyTransaction';
tableRowElement.dataset.transactionIndex =
lotOccupancyTransaction.transactionIndex.toString();
let externalReceiptNumberHTML = '';
if (lotOccupancyTransaction.externalReceiptNumber !== '') {
externalReceiptNumberHTML = cityssm.escapeHTML((_a = lotOccupancyTransaction.externalReceiptNumber) !== null && _a !== void 0 ? _a : '');
if (los.dynamicsGPIntegrationIsEnabled) {
if (lotOccupancyTransaction.dynamicsGPDocument === undefined) {
externalReceiptNumberHTML += ` <span data-tooltip="No Matching Document Found">
<i class="fas fa-times-circle has-text-danger" aria-label="No Matching Document Found"></i>
</span>`;
}
else {
externalReceiptNumberHTML +=
lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(2) === lotOccupancyTransaction.transactionAmount.toFixed(2)
? ` <span data-tooltip="Matching Document Found">
<i class="fas fa-check-circle has-text-success" aria-label="Matching Document Found"></i>
</span>`
: ` <span data-tooltip="Matching Document: $${lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(2)}">
<i class="fas fa-check-circle has-text-warning" aria-label="Matching Document: $${lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(2)}"></i>
</span>`;
}
}
externalReceiptNumberHTML += '<br />';
}
tableRowElement.innerHTML =
'<td>' +
((_a = lotOccupancyTransaction.transactionDateString) !== null && _a !== void 0 ? _a : '') +
((_b = lotOccupancyTransaction.transactionDateString) !== null && _b !== void 0 ? _b : '') +
'</td>' +
('<td>' +
(lotOccupancyTransaction.externalReceiptNumber === ''
? ''
: cityssm.escapeHTML((_b = lotOccupancyTransaction.externalReceiptNumber) !== null && _b !== void 0 ? _b : '') + '<br />') +
externalReceiptNumberHTML +
'<small>' +
cityssm.escapeHTML((_c = lotOccupancyTransaction.transactionNote) !== null && _c !== void 0 ? _c : '') +
'</small>' +

View File

@ -365,14 +365,34 @@ function renderLotOccupancyTransactions() {
tableRowElement.className = 'container--lotOccupancyTransaction';
tableRowElement.dataset.transactionIndex =
lotOccupancyTransaction.transactionIndex.toString();
let externalReceiptNumberHTML = '';
if (lotOccupancyTransaction.externalReceiptNumber !== '') {
externalReceiptNumberHTML = cityssm.escapeHTML((_a = lotOccupancyTransaction.externalReceiptNumber) !== null && _a !== void 0 ? _a : '');
if (los.dynamicsGPIntegrationIsEnabled) {
if (lotOccupancyTransaction.dynamicsGPDocument === undefined) {
externalReceiptNumberHTML += ` <span data-tooltip="No Matching Document Found">
<i class="fas fa-times-circle has-text-danger" aria-label="No Matching Document Found"></i>
</span>`;
}
else {
externalReceiptNumberHTML +=
lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(2) === lotOccupancyTransaction.transactionAmount.toFixed(2)
? ` <span data-tooltip="Matching Document Found">
<i class="fas fa-check-circle has-text-success" aria-label="Matching Document Found"></i>
</span>`
: ` <span data-tooltip="Matching Document: $${lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(2)}">
<i class="fas fa-check-circle has-text-warning" aria-label="Matching Document: $${lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(2)}"></i>
</span>`;
}
}
externalReceiptNumberHTML += '<br />';
}
tableRowElement.innerHTML =
'<td>' +
((_a = lotOccupancyTransaction.transactionDateString) !== null && _a !== void 0 ? _a : '') +
((_b = lotOccupancyTransaction.transactionDateString) !== null && _b !== void 0 ? _b : '') +
'</td>' +
('<td>' +
(lotOccupancyTransaction.externalReceiptNumber === ''
? ''
: cityssm.escapeHTML((_b = lotOccupancyTransaction.externalReceiptNumber) !== null && _b !== void 0 ? _b : '') + '<br />') +
externalReceiptNumberHTML +
'<small>' +
cityssm.escapeHTML((_c = lotOccupancyTransaction.transactionNote) !== null && _c !== void 0 ? _c : '') +
'</small>' +

View File

@ -515,16 +515,45 @@ function renderLotOccupancyTransactions(): void {
tableRowElement.dataset.transactionIndex =
lotOccupancyTransaction.transactionIndex!.toString()
let externalReceiptNumberHTML = ''
if (lotOccupancyTransaction.externalReceiptNumber !== '') {
externalReceiptNumberHTML = cityssm.escapeHTML(
lotOccupancyTransaction.externalReceiptNumber ?? ''
)
if (los.dynamicsGPIntegrationIsEnabled) {
if (lotOccupancyTransaction.dynamicsGPDocument === undefined) {
externalReceiptNumberHTML += ` <span data-tooltip="No Matching Document Found">
<i class="fas fa-times-circle has-text-danger" aria-label="No Matching Document Found"></i>
</span>`
} else {
externalReceiptNumberHTML +=
lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(
2
) === lotOccupancyTransaction.transactionAmount.toFixed(2)
? ` <span data-tooltip="Matching Document Found">
<i class="fas fa-check-circle has-text-success" aria-label="Matching Document Found"></i>
</span>`
: ` <span data-tooltip="Matching Document: $${lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(
2
)}">
<i class="fas fa-check-circle has-text-warning" aria-label="Matching Document: $${lotOccupancyTransaction.dynamicsGPDocument.documentTotal.toFixed(
2
)}"></i>
</span>`
}
}
externalReceiptNumberHTML += '<br />'
}
tableRowElement.innerHTML =
'<td>' +
(lotOccupancyTransaction.transactionDateString ?? '') +
'</td>' +
('<td>' +
(lotOccupancyTransaction.externalReceiptNumber === ''
? ''
: cityssm.escapeHTML(
lotOccupancyTransaction.externalReceiptNumber ?? ''
) + '<br />') +
externalReceiptNumberHTML +
'<small>' +
cityssm.escapeHTML(lotOccupancyTransaction.transactionNote ?? '') +
'</small>' +

View File

@ -378,12 +378,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
function getWorkOrderURL(workOrderId = '', edit = false, time = false) {
return getRecordURL('workOrders', workOrderId, edit, time);
}
/*
* Settings
*/
const dynamicsGPIntegrationIsEnabled = exports.dynamicsGPIntegrationIsEnabled;
/*
* Declare LOS
*/
const los = {
urlPrefix,
apiKey: document.querySelector('main').dataset.apiKey,
dynamicsGPIntegrationIsEnabled,
highlightMap,
initializeUnlockFieldButtons,
initializeDatePickers,

View File

@ -520,6 +520,12 @@ declare const bulmaJS: BulmaJS
return getRecordURL('workOrders', workOrderId, edit, time)
}
/*
* Settings
*/
const dynamicsGPIntegrationIsEnabled = exports.dynamicsGPIntegrationIsEnabled as boolean
/*
* Declare LOS
*/
@ -527,6 +533,7 @@ declare const bulmaJS: BulmaJS
const los: globalTypes.LOS = {
urlPrefix,
apiKey: document.querySelector('main')!.dataset.apiKey!,
dynamicsGPIntegrationIsEnabled,
highlightMap,
initializeUnlockFieldButtons,
initializeDatePickers,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,4 @@
import type { config as MSSQLConfig } from 'mssql';
export interface Config {
application: ConfigApplication;
session: ConfigSession;
@ -60,8 +61,14 @@ export interface Config {
printPdf: {
contentDisposition?: 'attachment' | 'inline';
};
dynamicsGP?: {
integrationIsEnabled: boolean;
mssqlConfig?: MSSQLConfig;
lookupOrder?: DynamicsGPLookup[];
};
};
}
export type DynamicsGPLookup = 'diamond/cashReceipt' | 'invoice';
interface ConfigApplication {
applicationName?: string;
backgroundURL?: string;

View File

@ -1,3 +1,5 @@
import type { config as MSSQLConfig } from 'mssql'
export interface Config {
application: ConfigApplication
session: ConfigSession
@ -60,9 +62,16 @@ export interface Config {
printPdf: {
contentDisposition?: 'attachment' | 'inline'
}
dynamicsGP?: {
integrationIsEnabled: boolean
mssqlConfig?: MSSQLConfig
lookupOrder?: DynamicsGPLookup[]
}
}
}
export type DynamicsGPLookup = 'diamond/cashReceipt' | 'invoice'
interface ConfigApplication {
applicationName?: string
backgroundURL?: string

View File

@ -31,6 +31,7 @@ export interface LOS {
WorkOrderCloseDate: string;
workOrderCloseDate: string;
};
dynamicsGPIntegrationIsEnabled: boolean;
getRandomColor: (seedString: string) => string;
setUnsavedChanges: () => void;
clearUnsavedChanges: () => void;

View File

@ -42,6 +42,8 @@ export interface LOS {
workOrderCloseDate: string
}
dynamicsGPIntegrationIsEnabled: boolean
getRandomColor: (seedString: string) => string
setUnsavedChanges: () => void

View File

@ -145,6 +145,14 @@ export interface LotOccupancyTransaction extends Record {
transactionAmount: number;
externalReceiptNumber?: string;
transactionNote?: string;
dynamicsGPDocument?: DynamicsGPDocument;
}
export interface DynamicsGPDocument {
documentType: 'Invoice' | 'Cash Receipt';
documentNumber: string;
documentDate: Date;
documentDescription: string[];
documentTotal: number;
}
export interface LotOccupancyOccupant extends Record {
lotOccupancyId?: number;

View File

@ -191,6 +191,15 @@ export interface LotOccupancyTransaction extends Record {
transactionAmount: number
externalReceiptNumber?: string
transactionNote?: string
dynamicsGPDocument?: DynamicsGPDocument
}
export interface DynamicsGPDocument {
documentType: 'Invoice' | 'Cash Receipt'
documentNumber: string
documentDate: Date
documentDescription: string[]
documentTotal: number
}
export interface LotOccupancyOccupant extends Record {

View File

@ -27,6 +27,7 @@
workOrderOpenDate: "<%= configFunctions.getProperty('aliases.workOrderOpenDate') %>",
workOrderCloseDate: "<%= configFunctions.getProperty('aliases.workOrderCloseDate') %>"
};
exports.dynamicsGPIntegrationIsEnabled = <%= configFunctions.getProperty('settings.dynamicsGP.integrationIsEnabled') %>;
</script>
<script src="<%= urlPrefix %>/lib/cityssm-bulma-js/bulma-js.js"></script>
<script src="<%= urlPrefix %>/lib/cityssm-bulma-webapp-js/cityssm.min.js"></script>