outlook integration

deepsource-autofix-76c6eb20
Dan Gowans 2022-09-15 16:11:57 -04:00
parent 326cac8717
commit f0f9c6dfe5
34 changed files with 650 additions and 10 deletions

3
app.js
View File

@ -9,6 +9,7 @@ import session from "express-session";
import FileStore from "session-file-store"; import FileStore from "session-file-store";
import routerLogin from "./routes/login.js"; import routerLogin from "./routes/login.js";
import routerDashboard from "./routes/dashboard.js"; import routerDashboard from "./routes/dashboard.js";
import routerApi from "./routes/api.js";
import routerLots from "./routes/lots.js"; import routerLots from "./routes/lots.js";
import routerMaps from "./routes/maps.js"; import routerMaps from "./routes/maps.js";
import routerLotOccupancies from "./routes/lotOccupancies.js"; import routerLotOccupancies from "./routes/lotOccupancies.js";
@ -22,6 +23,7 @@ import * as htmlFns from "@cityssm/expressjs-server-js/htmlFns.js";
import { version } from "./version.js"; import { version } from "./version.js";
import * as databaseInitializer from "./helpers/initializer.database.js"; import * as databaseInitializer from "./helpers/initializer.database.js";
import debug from "debug"; import debug from "debug";
import { apiGetHandler } from "./handlers/permissions.js";
const debugApp = debug("lot-occupancy-system:app"); const debugApp = debug("lot-occupancy-system:app");
databaseInitializer.initializeDatabase(); databaseInitializer.initializeDatabase();
const __dirname = "."; const __dirname = ".";
@ -106,6 +108,7 @@ app.get(urlPrefix + "/", sessionChecker, (_request, response) => {
response.redirect(urlPrefix + "/dashboard"); response.redirect(urlPrefix + "/dashboard");
}); });
app.use(urlPrefix + "/dashboard", sessionChecker, routerDashboard); app.use(urlPrefix + "/dashboard", sessionChecker, routerDashboard);
app.use(urlPrefix + "/api/:apiKey", apiGetHandler, routerApi);
app.use(urlPrefix + "/lots", sessionChecker, routerLots); app.use(urlPrefix + "/lots", sessionChecker, routerLots);
app.use(urlPrefix + "/maps", sessionChecker, routerMaps); app.use(urlPrefix + "/maps", sessionChecker, routerMaps);
app.use(urlPrefix + "/lotOccupancies", sessionChecker, routerLotOccupancies); app.use(urlPrefix + "/lotOccupancies", sessionChecker, routerLotOccupancies);

4
app.ts
View File

@ -12,6 +12,7 @@ import FileStore from "session-file-store";
import routerLogin from "./routes/login.js"; import routerLogin from "./routes/login.js";
import routerDashboard from "./routes/dashboard.js"; import routerDashboard from "./routes/dashboard.js";
import routerApi from "./routes/api.js";
import routerLots from "./routes/lots.js"; import routerLots from "./routes/lots.js";
import routerMaps from "./routes/maps.js"; import routerMaps from "./routes/maps.js";
import routerLotOccupancies from "./routes/lotOccupancies.js"; import routerLotOccupancies from "./routes/lotOccupancies.js";
@ -29,6 +30,7 @@ import { version } from "./version.js";
import * as databaseInitializer from "./helpers/initializer.database.js"; import * as databaseInitializer from "./helpers/initializer.database.js";
import debug from "debug"; import debug from "debug";
import { apiGetHandler } from "./handlers/permissions.js";
const debugApp = debug("lot-occupancy-system:app"); const debugApp = debug("lot-occupancy-system:app");
/* /*
@ -210,6 +212,8 @@ app.get(urlPrefix + "/", sessionChecker, (_request, response) => {
app.use(urlPrefix + "/dashboard", sessionChecker, routerDashboard); app.use(urlPrefix + "/dashboard", sessionChecker, routerDashboard);
app.use(urlPrefix + "/api/:apiKey", apiGetHandler, routerApi);
app.use(urlPrefix + "/lots", sessionChecker, routerLots); app.use(urlPrefix + "/lots", sessionChecker, routerLots);
app.use(urlPrefix + "/maps", sessionChecker, routerMaps); app.use(urlPrefix + "/maps", sessionChecker, routerMaps);
app.use(urlPrefix + "/lotOccupancies", sessionChecker, routerLotOccupancies); app.use(urlPrefix + "/lotOccupancies", sessionChecker, routerLotOccupancies);

View File

@ -0,0 +1,3 @@
import type { RequestHandler } from "express";
export declare const handler: RequestHandler;
export default handler;

View File

@ -0,0 +1,56 @@
import ical, { ICalEventStatus } from "ical-generator";
import { getWorkOrderMilestones } from "../../helpers/lotOccupancyDB/getWorkOrderMilestones.js";
const timeStringSplitRegex = /[ :-]/;
export const handler = (request, response) => {
const workOrderMilestones = getWorkOrderMilestones({
workOrderMilestoneDateFilter: "recent",
workOrderTypeIds: request.query.workOrderTypeIds,
workOrderMilestoneTypeIds: request.query
.workOrderMilestoneTypeIds
}, { includeWorkOrders: true, orderBy: "date" });
const calendar = ical({
name: "Work Order Milestone Calendar"
});
for (const milestone of workOrderMilestones) {
const milestoneTimePieces = (milestone.workOrderMilestoneDateString +
" " +
milestone.workOrderMilestoneTimeString).split(timeStringSplitRegex);
const milestoneDate = new Date(Number.parseInt(milestoneTimePieces[0], 10), Number.parseInt(milestoneTimePieces[1], 10) - 1, Number.parseInt(milestoneTimePieces[2], 10), Number.parseInt(milestoneTimePieces[3], 10), Number.parseInt(milestoneTimePieces[4], 10));
const eventData = {
start: milestoneDate,
stamp: new Date(milestone.recordCreate_timeMillis),
lastModified: new Date(milestone.recordUpdate_timeMillis),
allDay: !milestone.workOrderMilestoneTime,
summary: milestone.workOrderMilestoneDescription
};
const calendarEvent = calendar.createEvent(eventData);
if (milestone.workOrderMilestoneCompletionDate) {
calendarEvent.status(ICalEventStatus.CONFIRMED);
}
if (milestone.workOrderMilestoneTypeId) {
calendarEvent.createCategory({
name: milestone.workOrderMilestoneType
});
}
if (milestone.workOrder.workOrderLots.length > 0) {
const lotNames = [];
for (const lot of milestone.workOrder.workOrderLots) {
lotNames.push(lot.mapName + ": " + lot.lotName);
}
calendarEvent.location(lotNames.join(", "));
}
if (milestone.workOrder.workOrderLotOccupancies.length > 0) {
for (const lotOccupancy of milestone.workOrder
.workOrderLotOccupancies) {
for (const occupants of lotOccupancy.lotOccupancyOccupants) {
calendarEvent.createAttendee({
name: occupants.occupantName,
email: "no-reply@example.com"
});
}
}
}
}
calendar.serve(response);
};
export default handler;

View File

@ -0,0 +1,88 @@
/* eslint-disable unicorn/filename-case */
import ical, { ICalEventData, ICalEventStatus } from "ical-generator";
import { getWorkOrderMilestones } from "../../helpers/lotOccupancyDB/getWorkOrderMilestones.js";
import type { RequestHandler } from "express";
import { dateIntegerToString } from "@cityssm/expressjs-server-js/dateTimeFns.js";
const timeStringSplitRegex = /[ :-]/;
export const handler: RequestHandler = (request, response) => {
const workOrderMilestones = getWorkOrderMilestones(
{
workOrderMilestoneDateFilter: "recent",
workOrderTypeIds: request.query.workOrderTypeIds as string,
workOrderMilestoneTypeIds: request.query
.workOrderMilestoneTypeIds as string
},
{ includeWorkOrders: true, orderBy: "date" }
);
const calendar = ical({
name: "Work Order Milestone Calendar"
});
for (const milestone of workOrderMilestones) {
const milestoneTimePieces = (
milestone.workOrderMilestoneDateString +
" " +
milestone.workOrderMilestoneTimeString
).split(timeStringSplitRegex);
const milestoneDate = new Date(
Number.parseInt(milestoneTimePieces[0], 10),
Number.parseInt(milestoneTimePieces[1], 10) - 1,
Number.parseInt(milestoneTimePieces[2], 10),
Number.parseInt(milestoneTimePieces[3], 10),
Number.parseInt(milestoneTimePieces[4], 10)
);
const eventData: ICalEventData = {
start: milestoneDate,
stamp: new Date(milestone.recordCreate_timeMillis),
lastModified: new Date(milestone.recordUpdate_timeMillis),
allDay: !milestone.workOrderMilestoneTime,
summary: milestone.workOrderMilestoneDescription
};
const calendarEvent = calendar.createEvent(eventData);
if (milestone.workOrderMilestoneCompletionDate) {
calendarEvent.status(ICalEventStatus.CONFIRMED);
}
if (milestone.workOrderMilestoneTypeId) {
calendarEvent.createCategory({
name: milestone.workOrderMilestoneType
});
}
if (milestone.workOrder.workOrderLots.length > 0) {
const lotNames = [];
for (const lot of milestone.workOrder.workOrderLots) {
lotNames.push(lot.mapName + ": " + lot.lotName);
}
calendarEvent.location(lotNames.join(", "));
}
if (milestone.workOrder.workOrderLotOccupancies.length > 0) {
for (const lotOccupancy of milestone.workOrder
.workOrderLotOccupancies) {
for (const occupants of lotOccupancy.lotOccupancyOccupants) {
calendarEvent.createAttendee({
name: occupants.occupantName,
email: "no-reply@example.com"
});
}
}
}
}
calendar.serve(response);
};
export default handler;

View File

@ -4,3 +4,4 @@ export declare const adminGetHandler: RequestHandler;
export declare const adminPostHandler: RequestHandler; export declare const adminPostHandler: RequestHandler;
export declare const updateGetHandler: RequestHandler; export declare const updateGetHandler: RequestHandler;
export declare const updatePostHandler: RequestHandler; export declare const updatePostHandler: RequestHandler;
export declare const apiGetHandler: RequestHandler;

View File

@ -31,3 +31,9 @@ export const updatePostHandler = (request, response, next) => {
} }
return response.json(forbiddenJSON); return response.json(forbiddenJSON);
}; };
export const apiGetHandler = async (request, response, next) => {
if (await userFunctions.apiKeyIsValid(request)) {
return next();
}
return response.redirect(urlPrefix + "/login");
};

View File

@ -44,3 +44,11 @@ export const updatePostHandler: RequestHandler = (request, response, next) => {
return response.json(forbiddenJSON); return response.json(forbiddenJSON);
}; };
export const apiGetHandler: RequestHandler = async (request, response, next) => {
if (await userFunctions.apiKeyIsValid(request)) {
return next();
}
return response.redirect(urlPrefix + "/login");
};

View File

@ -0,0 +1,3 @@
import type { RequestHandler } from "express";
export declare const handler: RequestHandler;
export default handler;

View File

@ -0,0 +1,11 @@
import { getWorkOrderMilestoneTypes, getWorkOrderTypes } from "../../helpers/functions.cache.js";
export const handler = (request, response) => {
const workOrderTypes = getWorkOrderTypes();
const workOrderMilestoneTypes = getWorkOrderMilestoneTypes();
response.render("workOrder-outlook", {
headTitle: "Work Order Outlook Integration",
workOrderTypes,
workOrderMilestoneTypes
});
};
export default handler;

View File

@ -0,0 +1,16 @@
import type { RequestHandler } from "express";
import { getWorkOrderMilestoneTypes, getWorkOrderTypes } from "../../helpers/functions.cache.js";
export const handler: RequestHandler = (request, response) => {
const workOrderTypes = getWorkOrderTypes();
const workOrderMilestoneTypes = getWorkOrderMilestoneTypes();
response.render("workOrder-outlook", {
headTitle: "Work Order Outlook Integration",
workOrderTypes,
workOrderMilestoneTypes
});
};
export default handler;

View File

@ -2,3 +2,4 @@ import * as recordTypes from "../types/recordTypes";
export declare const regenerateApiKey: (userName: string) => Promise<void>; export declare const regenerateApiKey: (userName: string) => Promise<void>;
export declare const getApiKey: (userName: string) => Promise<string>; export declare const getApiKey: (userName: string) => Promise<string>;
export declare const getApiKeyFromSession: (session: recordTypes.PartialSession) => Promise<string>; export declare const getApiKeyFromSession: (session: recordTypes.PartialSession) => Promise<string>;
export declare const getUserNameFromApiKey: (apiKey: string) => Promise<string>;

View File

@ -41,3 +41,13 @@ export const getApiKey = async (userName) => {
export const getApiKeyFromSession = async (session) => { export const getApiKeyFromSession = async (session) => {
return await getApiKey(session.user.userName); return await getApiKey(session.user.userName);
}; };
export const getUserNameFromApiKey = async (apiKey) => {
if (!apiKeys) {
await loadApiKeys();
}
for (const [userName, currentApiKey] of Object.entries(apiKeys)) {
if (apiKey === currentApiKey) {
return userName;
}
}
};

View File

@ -54,3 +54,15 @@ export const getApiKeyFromSession = async (
) => { ) => {
return await getApiKey(session.user.userName); return await getApiKey(session.user.userName);
}; };
export const getUserNameFromApiKey = async (apiKey: string) => {
if (!apiKeys) {
await loadApiKeys();
}
for (const [userName, currentApiKey] of Object.entries(apiKeys)) {
if (apiKey === currentApiKey) {
return userName;
}
}
};

View File

@ -1,3 +1,4 @@
import type { Request } from "express"; import type { Request } from "express";
export declare const userIsAdmin: (request: Request) => boolean; export declare const userIsAdmin: (request: Request) => boolean;
export declare const userCanUpdate: (request: Request) => boolean; export declare const userCanUpdate: (request: Request) => boolean;
export declare const apiKeyIsValid: (request: Request) => Promise<boolean>;

View File

@ -1,3 +1,5 @@
import { getUserNameFromApiKey } from "./functions.api.js";
import * as configFunctions from "./functions.config.js";
export const userIsAdmin = (request) => { export const userIsAdmin = (request) => {
var _a; var _a;
const user = (_a = request.session) === null || _a === void 0 ? void 0 : _a.user; const user = (_a = request.session) === null || _a === void 0 ? void 0 : _a.user;
@ -14,3 +16,19 @@ export const userCanUpdate = (request) => {
} }
return user.userProperties.canUpdate; return user.userProperties.canUpdate;
}; };
export const apiKeyIsValid = async (request) => {
const apiKey = request.params.apiKey;
if (!apiKey) {
return false;
}
const userName = await getUserNameFromApiKey(apiKey);
if (!userName) {
return false;
}
const canLogin = configFunctions
.getProperty("users.canLogin")
.some((currentUserName) => {
return userName === currentUserName.toLowerCase();
});
return canLogin;
};

View File

@ -1,3 +1,6 @@
import { getUserNameFromApiKey } from "./functions.api.js";
import * as configFunctions from "./functions.config.js";
import type { Request } from "express"; import type { Request } from "express";
export const userIsAdmin = (request: Request): boolean => { export const userIsAdmin = (request: Request): boolean => {
@ -19,3 +22,25 @@ export const userCanUpdate = (request: Request): boolean => {
return user.userProperties.canUpdate; return user.userProperties.canUpdate;
}; };
export const apiKeyIsValid = async (request: Request): Promise<boolean> => {
const apiKey = request.params.apiKey;
if (!apiKey) {
return false;
}
const userName = await getUserNameFromApiKey(apiKey);
if (!userName) {
return false;
}
const canLogin = configFunctions
.getProperty("users.canLogin")
.some((currentUserName) => {
return userName === currentUserName.toLowerCase();
});
return canLogin;
};

View File

@ -4,6 +4,8 @@ interface WorkOrderMilestoneFilters {
workOrderId?: number | string; workOrderId?: number | string;
workOrderMilestoneDateFilter?: "upcomingMissed" | "recent" | "date"; workOrderMilestoneDateFilter?: "upcomingMissed" | "recent" | "date";
workOrderMilestoneDateString?: string; workOrderMilestoneDateString?: string;
workOrderTypeIds?: string;
workOrderMilestoneTypeIds?: string;
} }
interface WorkOrderMilestoneOptions { interface WorkOrderMilestoneOptions {
includeWorkOrders?: boolean; includeWorkOrders?: boolean;

View File

@ -3,6 +3,7 @@ import { lotOccupancyDB as databasePath } from "../../data/databasePaths.js";
import { getWorkOrder } from "./getWorkOrder.js"; import { getWorkOrder } from "./getWorkOrder.js";
import { dateIntegerToString, dateStringToInteger, dateToInteger, timeIntegerToString } from "@cityssm/expressjs-server-js/dateTimeFns.js"; import { dateIntegerToString, dateStringToInteger, dateToInteger, timeIntegerToString } from "@cityssm/expressjs-server-js/dateTimeFns.js";
import * as configFunctions from "../functions.config.js"; import * as configFunctions from "../functions.config.js";
const commaSeparatedNumbersRegex = /^\d+(,\d+)*$/;
export const getWorkOrderMilestones = (filters, options, connectedDatabase) => { export const getWorkOrderMilestones = (filters, options, connectedDatabase) => {
const database = connectedDatabase || const database = connectedDatabase ||
sqlite(databasePath, { sqlite(databasePath, {
@ -41,6 +42,18 @@ export const getWorkOrderMilestones = (filters, options, connectedDatabase) => {
sqlWhereClause += " and m.workOrderMilestoneDate = ?"; sqlWhereClause += " and m.workOrderMilestoneDate = ?";
sqlParameters.push(dateStringToInteger(filters.workOrderMilestoneDateString)); sqlParameters.push(dateStringToInteger(filters.workOrderMilestoneDateString));
} }
if (filters.workOrderTypeIds &&
commaSeparatedNumbersRegex.test(filters.workOrderTypeIds)) {
sqlWhereClause +=
" and w.workOrderTypeId in (" + filters.workOrderTypeIds + ")";
}
if (filters.workOrderMilestoneTypeIds &&
commaSeparatedNumbersRegex.test(filters.workOrderMilestoneTypeIds)) {
sqlWhereClause +=
" and m.workOrderMilestoneTypeId in (" +
filters.workOrderMilestoneTypeIds +
")";
}
let orderByClause = ""; let orderByClause = "";
switch (options.orderBy) { switch (options.orderBy) {
case "completion": case "completion":
@ -63,9 +76,11 @@ export const getWorkOrderMilestones = (filters, options, connectedDatabase) => {
" m.workOrderMilestoneDescription," + " m.workOrderMilestoneDescription," +
" m.workOrderMilestoneCompletionDate, userFn_dateIntegerToString(m.workOrderMilestoneCompletionDate) as workOrderMilestoneCompletionDateString," + " m.workOrderMilestoneCompletionDate, userFn_dateIntegerToString(m.workOrderMilestoneCompletionDate) as workOrderMilestoneCompletionDateString," +
" m.workOrderMilestoneCompletionTime, userFn_timeIntegerToString(m.workOrderMilestoneCompletionTime) as workOrderMilestoneCompletionTimeString," + " m.workOrderMilestoneCompletionTime, userFn_timeIntegerToString(m.workOrderMilestoneCompletionTime) as workOrderMilestoneCompletionTimeString," +
" m.recordCreate_userName, m.recordUpdate_userName" + " m.recordCreate_userName, m.recordCreate_timeMillis," +
" m.recordUpdate_userName, m.recordUpdate_timeMillis" +
" from WorkOrderMilestones m" + " from WorkOrderMilestones m" +
" left join WorkOrderMilestoneTypes t on m.workOrderMilestoneTypeId = t.workOrderMilestoneTypeId" + " left join WorkOrderMilestoneTypes t on m.workOrderMilestoneTypeId = t.workOrderMilestoneTypeId" +
" left join WorkOrders w on m.workOrderId = w.workOrderId" +
sqlWhereClause + sqlWhereClause +
orderByClause) orderByClause)
.all(sqlParameters); .all(sqlParameters);

View File

@ -19,6 +19,8 @@ interface WorkOrderMilestoneFilters {
workOrderId?: number | string; workOrderId?: number | string;
workOrderMilestoneDateFilter?: "upcomingMissed" | "recent" | "date"; workOrderMilestoneDateFilter?: "upcomingMissed" | "recent" | "date";
workOrderMilestoneDateString?: string; workOrderMilestoneDateString?: string;
workOrderTypeIds?: string;
workOrderMilestoneTypeIds?: string;
} }
interface WorkOrderMilestoneOptions { interface WorkOrderMilestoneOptions {
@ -26,6 +28,8 @@ interface WorkOrderMilestoneOptions {
orderBy: "completion" | "date"; orderBy: "completion" | "date";
} }
const commaSeparatedNumbersRegex = /^\d+(,\d+)*$/;
export const getWorkOrderMilestones = ( export const getWorkOrderMilestones = (
filters: WorkOrderMilestoneFilters, filters: WorkOrderMilestoneFilters,
options: WorkOrderMilestoneOptions, options: WorkOrderMilestoneOptions,
@ -95,6 +99,24 @@ export const getWorkOrderMilestones = (
); );
} }
if (
filters.workOrderTypeIds &&
commaSeparatedNumbersRegex.test(filters.workOrderTypeIds)
) {
sqlWhereClause +=
" and w.workOrderTypeId in (" + filters.workOrderTypeIds + ")";
}
if (
filters.workOrderMilestoneTypeIds &&
commaSeparatedNumbersRegex.test(filters.workOrderMilestoneTypeIds)
) {
sqlWhereClause +=
" and m.workOrderMilestoneTypeId in (" +
filters.workOrderMilestoneTypeIds +
")";
}
// Order By // Order By
let orderByClause = ""; let orderByClause = "";
@ -125,9 +147,11 @@ export const getWorkOrderMilestones = (
" m.workOrderMilestoneDescription," + " m.workOrderMilestoneDescription," +
" m.workOrderMilestoneCompletionDate, userFn_dateIntegerToString(m.workOrderMilestoneCompletionDate) as workOrderMilestoneCompletionDateString," + " m.workOrderMilestoneCompletionDate, userFn_dateIntegerToString(m.workOrderMilestoneCompletionDate) as workOrderMilestoneCompletionDateString," +
" m.workOrderMilestoneCompletionTime, userFn_timeIntegerToString(m.workOrderMilestoneCompletionTime) as workOrderMilestoneCompletionTimeString," + " m.workOrderMilestoneCompletionTime, userFn_timeIntegerToString(m.workOrderMilestoneCompletionTime) as workOrderMilestoneCompletionTimeString," +
" m.recordCreate_userName, m.recordUpdate_userName" + " m.recordCreate_userName, m.recordCreate_timeMillis," +
" m.recordUpdate_userName, m.recordUpdate_timeMillis" +
" from WorkOrderMilestones m" + " from WorkOrderMilestones m" +
" left join WorkOrderMilestoneTypes t on m.workOrderMilestoneTypeId = t.workOrderMilestoneTypeId" + " left join WorkOrderMilestoneTypes t on m.workOrderMilestoneTypeId = t.workOrderMilestoneTypeId" +
" left join WorkOrders w on m.workOrderId = w.workOrderId" +
sqlWhereClause + sqlWhereClause +
orderByClause orderByClause
) )

82
package-lock.json generated
View File

@ -29,6 +29,7 @@
"express-rate-limit": "^6.6.0", "express-rate-limit": "^6.6.0",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"ical-generator": "^3.5.1",
"leaflet": "^1.8.0", "leaflet": "^1.8.0",
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"randomcolor": "^0.6.2", "randomcolor": "^0.6.2",
@ -1090,7 +1091,7 @@
"version": "9.1.1", "version": "9.1.1",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz",
"integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==",
"dev": true "devOptional": true
}, },
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "0.7.31", "version": "0.7.31",
@ -1102,7 +1103,7 @@
"version": "18.0.3", "version": "18.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.3.tgz",
"integrity": "sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ==", "integrity": "sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ==",
"dev": true "devOptional": true
}, },
"node_modules/@types/node-fetch": { "node_modules/@types/node-fetch": {
"version": "2.6.2", "version": "2.6.2",
@ -3266,7 +3267,7 @@
"version": "1.11.5", "version": "1.11.5",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.5.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.5.tgz",
"integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==", "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==",
"dev": true "devOptional": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
@ -6315,6 +6316,57 @@
"node": ">=8.12.0" "node": ">=8.12.0"
} }
}, },
"node_modules/ical-generator": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-3.5.1.tgz",
"integrity": "sha512-OLCxRso9ulfkZeFY/aUzSZu9K/7IWnkJpjSG6coaNJXuToAyuBiCq4w1MG0cgSGzHQeY1WTVXez16fLwiZb5yg==",
"dependencies": {
"uuid-random": "^1.3.2"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@touch4it/ical-timezones": ">=1.6.0",
"@types/luxon": ">= 1.26.0",
"@types/mocha": ">= 8.2.1",
"@types/node": ">= 15.0.0",
"dayjs": ">= 1.10.0",
"luxon": ">= 1.26.0",
"moment": ">= 2.29.0",
"moment-timezone": ">= 0.5.33",
"rrule": ">= 2.6.8"
},
"peerDependenciesMeta": {
"@touch4it/ical-timezones": {
"optional": true
},
"@types/luxon": {
"optional": true
},
"@types/mocha": {
"optional": true
},
"@types/node": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-timezone": {
"optional": true
},
"rrule": {
"optional": true
}
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -11337,6 +11389,11 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/uuid-random": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/uuid-random/-/uuid-random-1.3.2.tgz",
"integrity": "sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ=="
},
"node_modules/v8flags": { "node_modules/v8flags": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz",
@ -12792,7 +12849,7 @@
"version": "9.1.1", "version": "9.1.1",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz",
"integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==",
"dev": true "devOptional": true
}, },
"@types/ms": { "@types/ms": {
"version": "0.7.31", "version": "0.7.31",
@ -12804,7 +12861,7 @@
"version": "18.0.3", "version": "18.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.3.tgz",
"integrity": "sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ==", "integrity": "sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ==",
"dev": true "devOptional": true
}, },
"@types/node-fetch": { "@types/node-fetch": {
"version": "2.6.2", "version": "2.6.2",
@ -14447,7 +14504,7 @@
"version": "1.11.5", "version": "1.11.5",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.5.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.5.tgz",
"integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==", "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==",
"dev": true "devOptional": true
}, },
"debug": { "debug": {
"version": "4.3.4", "version": "4.3.4",
@ -16834,6 +16891,14 @@
"integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
"dev": true "dev": true
}, },
"ical-generator": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-3.5.1.tgz",
"integrity": "sha512-OLCxRso9ulfkZeFY/aUzSZu9K/7IWnkJpjSG6coaNJXuToAyuBiCq4w1MG0cgSGzHQeY1WTVXez16fLwiZb5yg==",
"requires": {
"uuid-random": "^1.3.2"
}
},
"iconv-lite": { "iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -20709,6 +20774,11 @@
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true "dev": true
}, },
"uuid-random": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/uuid-random/-/uuid-random-1.3.2.tgz",
"integrity": "sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ=="
},
"v8flags": { "v8flags": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz",

View File

@ -53,6 +53,7 @@
"express-rate-limit": "^6.6.0", "express-rate-limit": "^6.6.0",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"ical-generator": "^3.5.1",
"leaflet": "^1.8.0", "leaflet": "^1.8.0",
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"randomcolor": "^0.6.2", "randomcolor": "^0.6.2",

View File

View File

@ -0,0 +1,49 @@
(() => {
const urlPrefix = document.querySelector("main").dataset.urlPrefix;
const apiKey = document.querySelector("main").dataset.apiKey;
const workOrderTypeIdsElement = document.querySelector("#icsFilters--workOrderTypeIds");
const workOrderMilestoneTypeIdsElement = document.querySelector("#icsFilters--workOrderMilestoneTypeIds");
const updateCalendarURL = () => {
let url = window.location.href.slice(0, Math.max(0, window.location.href.indexOf(window.location.pathname) + 1)) +
urlPrefix +
"api/" +
apiKey +
"/" +
"milestoneICS/" +
"?";
if (!workOrderTypeIdsElement.disabled &&
workOrderTypeIdsElement.selectedOptions.length > 0) {
url += "workOrderTypeIds=";
for (const optionElement of workOrderTypeIdsElement.selectedOptions) {
url += optionElement.value + ",";
}
url = url.slice(0, -1) + "&";
}
if (!workOrderMilestoneTypeIdsElement.disabled &&
workOrderMilestoneTypeIdsElement.selectedOptions.length > 0) {
url += "workOrderMilestoneTypeIds=";
for (const optionElement of workOrderMilestoneTypeIdsElement.selectedOptions) {
url += optionElement.value + ",";
}
url = url.slice(0, -1) + "&";
}
document.querySelector("#icsFilters--calendarURL").value = url.slice(0, -1);
};
document
.querySelector("#icsFilters--workOrderTypeIds-all")
.addEventListener("change", (changeEvent) => {
workOrderTypeIdsElement.disabled = changeEvent.currentTarget.checked;
});
document
.querySelector("#icsFilters--workOrderMilestoneTypeIds-all")
.addEventListener("change", (changeEvent) => {
workOrderMilestoneTypeIdsElement.disabled = changeEvent.currentTarget.checked;
});
const inputSelectElements = document
.querySelector("#panel--icsFilters")
.querySelectorAll("input, select");
for (const element of inputSelectElements) {
element.addEventListener("change", updateCalendarURL);
}
updateCalendarURL();
})();

View File

@ -0,0 +1,89 @@
(() => {
const urlPrefix = document.querySelector("main").dataset.urlPrefix;
const apiKey = document.querySelector("main").dataset.apiKey;
const workOrderTypeIdsElement = document.querySelector(
"#icsFilters--workOrderTypeIds"
) as HTMLSelectElement;
const workOrderMilestoneTypeIdsElement = document.querySelector(
"#icsFilters--workOrderMilestoneTypeIds"
) as HTMLSelectElement;
const updateCalendarURL = () => {
let url =
window.location.href.slice(
0,
Math.max(
0,
window.location.href.indexOf(window.location.pathname) + 1
)
) +
urlPrefix +
"api/" +
apiKey +
"/" +
"milestoneICS/" +
"?";
if (
!workOrderTypeIdsElement.disabled &&
workOrderTypeIdsElement.selectedOptions.length > 0
) {
url += "workOrderTypeIds=";
for (const optionElement of workOrderTypeIdsElement.selectedOptions) {
url += optionElement.value + ",";
}
url = url.slice(0, -1) + "&";
}
if (
!workOrderMilestoneTypeIdsElement.disabled &&
workOrderMilestoneTypeIdsElement.selectedOptions.length > 0
) {
url += "workOrderMilestoneTypeIds=";
for (const optionElement of workOrderMilestoneTypeIdsElement.selectedOptions) {
url += optionElement.value + ",";
}
url = url.slice(0, -1) + "&";
}
(
document.querySelector(
"#icsFilters--calendarURL"
) as HTMLTextAreaElement
).value = url.slice(0, -1);
};
document
.querySelector("#icsFilters--workOrderTypeIds-all")
.addEventListener("change", (changeEvent) => {
workOrderTypeIdsElement.disabled = (
changeEvent.currentTarget as HTMLInputElement
).checked;
});
document
.querySelector("#icsFilters--workOrderMilestoneTypeIds-all")
.addEventListener("change", (changeEvent) => {
workOrderMilestoneTypeIdsElement.disabled = (
changeEvent.currentTarget as HTMLInputElement
).checked;
});
const inputSelectElements = document
.querySelector("#panel--icsFilters")
.querySelectorAll("input, select") as NodeListOf<
HTMLInputElement | HTMLSelectElement
>;
for (const element of inputSelectElements) {
element.addEventListener("change", updateCalendarURL);
}
updateCalendarURL();
})();

View File

@ -0,0 +1 @@
(()=>{const e=document.querySelector("main").dataset.urlPrefix,t=document.querySelector("main").dataset.apiKey,r=document.querySelector("#icsFilters--workOrderTypeIds"),o=document.querySelector("#icsFilters--workOrderMilestoneTypeIds"),c=()=>{let c=window.location.href.slice(0,Math.max(0,window.location.href.indexOf(window.location.pathname)+1))+e+"api/"+t+"/milestoneICS/?";if(!r.disabled&&r.selectedOptions.length>0){c+="workOrderTypeIds=";for(const e of r.selectedOptions)c+=e.value+",";c=c.slice(0,-1)+"&"}if(!o.disabled&&o.selectedOptions.length>0){c+="workOrderMilestoneTypeIds=";for(const e of o.selectedOptions)c+=e.value+",";c=c.slice(0,-1)+"&"}document.querySelector("#icsFilters--calendarURL").value=c.slice(0,-1)};document.querySelector("#icsFilters--workOrderTypeIds-all").addEventListener("change",e=>{r.disabled=e.currentTarget.checked}),document.querySelector("#icsFilters--workOrderMilestoneTypeIds-all").addEventListener("change",e=>{o.disabled=e.currentTarget.checked});const l=document.querySelector("#panel--icsFilters").querySelectorAll("input, select");for(const e of l)e.addEventListener("change",c);c()})();

2
routes/api.d.ts vendored 100644
View File

@ -0,0 +1,2 @@
export declare const router: import("express-serve-static-core").Router;
export default router;

5
routes/api.js 100644
View File

@ -0,0 +1,5 @@
import { Router } from "express";
import handler_milestoneICS from "../handlers/api-get/milestoneICS.js";
export const router = Router();
router.get("/milestoneICS", handler_milestoneICS);
export default router;

9
routes/api.ts 100644
View File

@ -0,0 +1,9 @@
import { Router } from "express";
import handler_milestoneICS from "../handlers/api-get/milestoneICS.js";
export const router = Router();
router.get("/milestoneICS", handler_milestoneICS);
export default router;

View File

@ -4,6 +4,7 @@ import handler_search from "../handlers/workOrders-get/search.js";
import handler_doSearchWorkOrders from "../handlers/workOrders-post/doSearchWorkOrders.js"; import handler_doSearchWorkOrders from "../handlers/workOrders-post/doSearchWorkOrders.js";
import handler_milestoneCalendar from "../handlers/workOrders-get/milestoneCalendar.js"; import handler_milestoneCalendar from "../handlers/workOrders-get/milestoneCalendar.js";
import handler_doGetWorkOrderMilestones from "../handlers/workOrders-post/doGetWorkOrderMilestones.js"; import handler_doGetWorkOrderMilestones from "../handlers/workOrders-post/doGetWorkOrderMilestones.js";
import handler_outlook from "../handlers/workOrders-get/outlook.js";
import handler_view from "../handlers/workOrders-get/view.js"; import handler_view from "../handlers/workOrders-get/view.js";
import handler_doReopenWorkOrder from "../handlers/workOrders-post/doReopenWorkOrder.js"; import handler_doReopenWorkOrder from "../handlers/workOrders-post/doReopenWorkOrder.js";
import handler_new from "../handlers/workOrders-get/new.js"; import handler_new from "../handlers/workOrders-get/new.js";
@ -26,6 +27,7 @@ router.get("/", handler_search);
router.post("/doSearchWorkOrders", handler_doSearchWorkOrders); router.post("/doSearchWorkOrders", handler_doSearchWorkOrders);
router.get("/milestoneCalendar", handler_milestoneCalendar); router.get("/milestoneCalendar", handler_milestoneCalendar);
router.post("/doGetWorkOrderMilestones", handler_doGetWorkOrderMilestones); router.post("/doGetWorkOrderMilestones", handler_doGetWorkOrderMilestones);
router.get("/outlook", handler_outlook);
router.get("/new", permissionHandlers.adminGetHandler, handler_new); router.get("/new", permissionHandlers.adminGetHandler, handler_new);
router.post("/doCreateWorkOrder", permissionHandlers.updatePostHandler, handler_doCreateWorkOrder); router.post("/doCreateWorkOrder", permissionHandlers.updatePostHandler, handler_doCreateWorkOrder);
router.get("/:workOrderId", handler_view); router.get("/:workOrderId", handler_view);

View File

@ -8,6 +8,8 @@ import handler_doSearchWorkOrders from "../handlers/workOrders-post/doSearchWork
import handler_milestoneCalendar from "../handlers/workOrders-get/milestoneCalendar.js"; import handler_milestoneCalendar from "../handlers/workOrders-get/milestoneCalendar.js";
import handler_doGetWorkOrderMilestones from "../handlers/workOrders-post/doGetWorkOrderMilestones.js"; import handler_doGetWorkOrderMilestones from "../handlers/workOrders-post/doGetWorkOrderMilestones.js";
import handler_outlook from "../handlers/workOrders-get/outlook.js";
import handler_view from "../handlers/workOrders-get/view.js"; import handler_view from "../handlers/workOrders-get/view.js";
import handler_doReopenWorkOrder from "../handlers/workOrders-post/doReopenWorkOrder.js"; import handler_doReopenWorkOrder from "../handlers/workOrders-post/doReopenWorkOrder.js";
@ -45,6 +47,10 @@ router.get("/milestoneCalendar", handler_milestoneCalendar);
router.post("/doGetWorkOrderMilestones", handler_doGetWorkOrderMilestones); router.post("/doGetWorkOrderMilestones", handler_doGetWorkOrderMilestones);
// Outlook Integration
router.get("/outlook", handler_outlook);
// New // New
router.get("/new", permissionHandlers.adminGetHandler, handler_new); router.get("/new", permissionHandlers.adminGetHandler, handler_new);

View File

@ -106,6 +106,8 @@
</nav> </nav>
<main class="container pt-2 px-3 mr-auto has-min-page-height" <main class="container pt-2 px-3 mr-auto has-min-page-height"
data-session-keep-alive-millis="<%= configFunctions.keepAliveMillis %>" data-url-prefix="<%= urlPrefix %>" data-session-keep-alive-millis="<%= configFunctions.keepAliveMillis %>"
data-url-prefix="<%= urlPrefix %>"
data-can-update="<%= user.userProperties.canUpdate ? "true" : "false" %>" data-can-update="<%= user.userProperties.canUpdate ? "true" : "false" %>"
data-is-admin="<%= user.userProperties.isAdmin ? "true" : "false" %>"> data-is-admin="<%= user.userProperties.isAdmin ? "true" : "false" %>"
data-api-key="<%= user.userProperties.apiKey %>">

View File

@ -9,11 +9,22 @@
<span>Work Order Search</span> <span>Work Order Search</span>
</a> </a>
</li> </li>
</ul>
<h2 class="menu-label">
Milestones
</h2>
<ul class="menu-list">
<li> <li>
<a class="<%= (headTitle.endsWith("Milestone Calendar") ? "is-active" : "") %>" href="<%= urlPrefix %>/workOrders/milestoneCalendar"> <a class="<%= (headTitle.endsWith("Milestone Calendar") ? "is-active" : "") %>" href="<%= urlPrefix %>/workOrders/milestoneCalendar">
<span class="icon is-small"><i class="fas fa-fw fa-calendar" aria-hidden="true"></i></span> <span class="icon is-small"><i class="fas fa-fw fa-calendar" aria-hidden="true"></i></span>
<span>Milestone Calendar</span> <span>Milestone Calendar</span>
</a> </a>
</li> </li>
<li>
<a class="<%= (headTitle.endsWith("Outlook Integration") ? "is-active" : "") %>" href="<%= urlPrefix %>/workOrders/outlook">
<span class="icon is-small"><i class="fas fa-fw fa-envelope-open-text" aria-hidden="true"></i></span>
<span>Outlook Integration</span>
</a>
</li>
</ul> </ul>
</aside> </aside>

View File

@ -0,0 +1,86 @@
<%- include('_header'); -%>
<div class="columns">
<div class="column is-3 is-hidden-mobile">
<%- include('_menu-workOrders'); -%>
</div>
<div class="column">
<nav class="breadcrumb">
<ul>
<li><a href="<%= urlPrefix %>/dashboard">Home</a></li>
<li>
<a href="<%= urlPrefix %>/workOrders">
<span class="icon is-small"><i class="fas fa-hard-hat" aria-hidden="true"></i></span>
<span>Work Orders</span>
</a>
</li>
<li class="is-active">
<a href="#" aria-current="page">
Outlook Integration
</a>
</li>
</ul>
</nav>
<h1 class="title is-1">
Outlook Integration
</h1>
<div class="panel" id="panel--icsFilters">
<div class="panel-block is-block">
<div class="columns">
<div class="column">
<label class="label" for="icsFilters--workOrderTypeIds">Work Order Types</label>
<label class="checkbox is-block">
<input id="icsFilters--workOrderTypeIds-all" type="checkbox" checked />
All Work Order Types
</label>
<div class="control mt-2">
<div class="select is-multiple is-fullwidth">
<select id="icsFilters--workOrderTypeIds" multiple size="<%= Math.min(Math.max(workOrderTypes.length, workOrderMilestoneTypes.length), 6) %>" disabled>
<% for (const workOrderType of workOrderTypes) { %>
<option value="<%= workOrderType.workOrderTypeId %>" selected>
<%= workOrderType.workOrderType %>
</option>
<% } %>
</select>
</div>
</div>
</div>
<div class="column">
<label class="label" for="icsFilters--workOrderMilestoneTypeIds">Milestone Types</label>
<label class="checkbox is-block">
<input id="icsFilters--workOrderMilestoneTypeIds-all" type="checkbox" checked />
All Work Order Milestone Types
</label>
<div class="control mt-2">
<div class="select is-multiple is-fullwidth">
<select id="icsFilters--workOrderMilestoneTypeIds" multiple size="<%= Math.min(Math.max(workOrderTypes.length, workOrderMilestoneTypes.length), 6) %>" disabled>
<% for (const workOrderMilestoneType of workOrderMilestoneTypes) { %>
<option value="<%= workOrderMilestoneType.workOrderMilestoneTypeId %>" selected>
<%= workOrderMilestoneType.workOrderMilestoneType %>
</option>
<% } %>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="panel-block is-block">
<div class="field">
<label class="label" for="icsFilters--calendarURL">ICS Calendar Link</label>
<div class="control">
<textarea class="textarea" id="icsFilters--calendarURL" name="calendarURL" style="cursor:text" disabled readonly></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('_footerA'); -%>
<script src="<%= urlPrefix %>/javascripts/workOrderOutlook.min.js"></script>
<%- include('_footerB'); -%>