diff --git a/api/src/data/migrations/019.create-actions.ts b/api/src/data/migrations/019.create-actions.ts index d3ffe0f..89130df 100644 --- a/api/src/data/migrations/019.create-actions.ts +++ b/api/src/data/migrations/019.create-actions.ts @@ -18,6 +18,9 @@ export async function up(knex: knex.Knex) { table.string("sensitivity_code", 8).nullable(); table.string("status_code", 8).nullable(); table.string("notes", 4000).nullable(); + table.datetime("complete_date").nullable(); + table.string("complete_name", 200).nullable(); + table.integer("complete_user_id").nullable(); table.foreign("hazard_id").references("hazards.id"); table.foreign("incident_id").references("incidents.id"); diff --git a/api/src/data/models/action-model.ts b/api/src/data/models/action-model.ts index 2febb5d..25580a8 100644 --- a/api/src/data/models/action-model.ts +++ b/api/src/data/models/action-model.ts @@ -15,4 +15,9 @@ export interface Action { sensitivity_code?: string; status_code?: string; notes?: string; + complete_date?: Date; + complete_name?: string; + complete_user_id?: number; + + actor_display_name?: string; } diff --git a/api/src/data/models/incident-step-model.ts b/api/src/data/models/incident-step-model.ts index 8988249..1b5293d 100644 --- a/api/src/data/models/incident-step-model.ts +++ b/api/src/data/models/incident-step-model.ts @@ -1,10 +1,12 @@ +import { Knex } from "knex"; + export interface IncidentStep { id?: number; incident_id: number; step_title: string; order: number; - activate_date?: Date; - complete_date?: Date; + activate_date?: Date | Knex.Raw; + complete_date?: Date | Knex.Raw; complete_name?: string; complete_user_id?: number; } diff --git a/api/src/routes/directory-router.ts b/api/src/routes/directory-router.ts index a4eb49a..7fb0070 100644 --- a/api/src/routes/directory-router.ts +++ b/api/src/routes/directory-router.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from "express"; import { DirectoryService } from "../services"; +import { db as knex } from "../data"; export const directoryRouter = express.Router(); @@ -13,3 +14,16 @@ directoryRouter.post("/search-directory", async (req: Request, res: Response) => return res.json({ data: [...results] }); }); + +directoryRouter.post("/search-action-directory", async (req: Request, res: Response) => { + let { terms } = req.body; + + await directoryService.connect(); + let data = await directoryService.search(terms); + + const allUsers = await knex("users"); + + data.map((d: any) => (d.user_id = allUsers.find((u) => u.email == d.email)?.id)); + + return res.json({ data }); +}); diff --git a/api/src/routes/report-router.ts b/api/src/routes/report-router.ts index 12cc1a7..7b89674 100644 --- a/api/src/routes/report-router.ts +++ b/api/src/routes/report-router.ts @@ -4,6 +4,9 @@ import { isArray } from "lodash"; import { db as knex } from "../data"; import { DepartmentService, DirectoryService, EmailService, IncidentService } from "../services"; import { + Action, + ActionStatuses, + ActionTypes, Hazard, HazardStatuses, Incident, @@ -110,8 +113,6 @@ reportRouter.post("/", async (req: Request, res: Response) => { const insertedIncidents = await trx("incidents").insert(incident).returning("*"); const insertedHazards = await trx("hazards").insert(hazard).returning("*"); - const supervisorUser = await trx("users").where({ email: supervisor_email }).first(); - let insertedIncidentId = insertedIncidents[0].id; let insertedHazardId = insertedHazards[0].id; @@ -143,26 +144,15 @@ reportRouter.post("/", async (req: Request, res: Response) => { activate_date: i == i ? new Date() : null, } as IncidentStep; + if (i == 1) { + (step as any).complete_date = InsertableDate(new Date().toISOString()); + step.complete_name = req.user.display_name; + step.complete_user_id = req.user.id; + } + await trx("incident_steps").insert(step); } - /* const basicActions = ["Complete Initial Investigation", "Complete Action Plan"]; - - for (let i = 1; i <= basicActions.length; i++) { - const action = { - incident_id: insertedIncidentId, - created_at: new Date(), - description: basicActions[i - 1], - action_type_code: ActionTypes.SYSTEM_GENERATED.code, - sensitivity_code: SensitivityLevels.NOT_SENSITIVE.code, - status_code: ActionStatuses.OPEN.code, - actor_user_email: supervisor_email, - actor_user_id: supervisorUser?.id ?? null, - } as Action; - - await trx("actions").insert(action); - } */ - if (req.files && req.files.files) { let files = req.files.files; @@ -216,3 +206,122 @@ reportRouter.post("/", async (req: Request, res: Response) => { return res.status(400).json({ data: {} }); }); + +reportRouter.put("/:id/step/:step_id/:operation", async (req: Request, res: Response) => { + const { id, step_id, operation } = req.params; + + const step = await knex("incident_steps").where({ incident_id: id, id: step_id }).first(); + + if (step) { + if (operation == "complete") { + await knex("incident_steps") + .where({ incident_id: id, id: step_id }) + .update({ + complete_name: req.user.display_name, + complete_date: InsertableDate(new Date().toISOString()), + complete_user_id: req.user.id, + }); + } else if (operation == "revert") { + await knex("incident_steps").where({ incident_id: id, id: step_id }).update({ + complete_name: null, + complete_date: null, + complete_user_id: null, + }); + } + } + + const allSteps = await knex("incident_steps").where({ incident_id: id }); + let allComplete = true; + + for (const step of allSteps) { + if (!step.complete_date) allComplete = false; + } + + if (allComplete) { + await knex("incidents").where({ id }).update({ status_code: IncidentStatuses.CLOSED.code }); + } else { + await knex("incidents").where({ id }).update({ status_code: IncidentStatuses.IN_PROGRESS.code }); + } + + return res.json({ data: {} }); +}); + +reportRouter.post("/:id/action", async (req: Request, res: Response) => { + const { id } = req.params; + const { description, notes, actor_user_email, actor_user_id, actor_role_type_id, due_date } = req.body; + + const action = { + incident_id: parseInt(id), + created_at: InsertableDate(new Date().toISOString()), + description, + notes, + action_type_code: ActionTypes.USER_GENERATED.code, + sensitivity_code: SensitivityLevels.NOT_SENSITIVE.code, + status_code: ActionStatuses.OPEN.code, + actor_user_email, + actor_user_id, + actor_role_type_id, + due_date: InsertableDate(due_date), + } as Action; + + await knex("actions").insert(action); + + return res.json({ data: {} }); +}); + +reportRouter.put("/:id/action/:action_id", async (req: Request, res: Response) => { + const { id, action_id } = req.params; + const { description, notes, actor_user_email, actor_user_id, actor_role_type_id, due_date } = req.body; + + const action = await knex("actions").where({ incident_id: id, id: action_id }).first(); + if (!action) return res.status(404).send(); + + await knex("actions").where({ id: action_id }).update({ + description, + notes, + actor_user_email, + actor_user_id, + actor_role_type_id, + due_date, + }); + + return res.json({ data: {} }); +}); + +reportRouter.delete("/:id/action/:action_id", async (req: Request, res: Response) => { + const { id, action_id } = req.params; + + await knex("actions").where({ incident_id: id, id: action_id }).delete(); + + return res.json({ data: {} }); +}); + +reportRouter.delete("/:id/action/:action_id", async (req: Request, res: Response) => { + const { id, action_id } = req.params; + + await knex("actions").where({ incident_id: id, id: action_id }).delete(); + + return res.json({ data: {} }); +}); + +reportRouter.put("/:id/action/:action_id/:operation", async (req: Request, res: Response) => { + const { id, action_id, operation } = req.params; + + if (operation == "complete") { + await knex("actions") + .where({ incident_id: id, id: action_id }) + .update({ + complete_date: InsertableDate(new Date().toISOString()), + complete_name: req.user.display_name, + complete_user_id: req.user.id, + }); + } else if (operation == "revert") { + await knex("actions").where({ incident_id: id, id: action_id }).update({ + complete_date: null, + complete_name: null, + complete_user_id: null, + }); + } + + return res.json({ data: {} }); +}); diff --git a/api/src/services/incident-service.ts b/api/src/services/incident-service.ts index 34f3bf3..40c05f7 100644 --- a/api/src/services/incident-service.ts +++ b/api/src/services/incident-service.ts @@ -37,8 +37,8 @@ export class IncidentService { .where({ incident_id: item.id }) .select("id", "incident_id", "added_by_email", "file_name", "file_type", "file_size", "added_date"); - item.steps = await db("incident_steps").where({ incident_id: item.id }); - item.actions = await db("actions").where({ incident_id: item.id }); + item.steps = await db("incident_steps").where({ incident_id: item.id }).orderBy("order"); + item.actions = await db("actions").where({ incident_id: item.id }).orderBy("due_date"); item.hazards = await db("incident_hazards").where({ incident_id: item.id }); for (let hazard of item.hazards) { @@ -49,6 +49,18 @@ export class IncidentService { hazard.hazard = await db("hazards").where({ id: hazard.hazard_id }).first(); } + for (let action of item.actions) { + if (action.actor_role_type_id) { + action.actor_display_name = ( + await db("role_types").where({ id: action.actor_role_type_id }).first() + ).description; + } else if (action.actor_user_id) { + action.actor_display_name = (await db("users").where({ id: action.actor_user_id }).first()).display_name; + } else if (action.actor_user_email) { + action.actor_display_name = action.actor_user_email; + } + } + return item; } diff --git a/web/src/components/action/ActionCreate.vue b/web/src/components/action/ActionCreate.vue index ae3cdf4..0671e35 100644 --- a/web/src/components/action/ActionCreate.vue +++ b/web/src/components/action/ActionCreate.vue @@ -1,9 +1,9 @@ - diff --git a/web/src/components/action/ActionUserSelector.vue b/web/src/components/action/ActionUserSelector.vue new file mode 100644 index 0000000..0a3e3a6 --- /dev/null +++ b/web/src/components/action/ActionUserSelector.vue @@ -0,0 +1,106 @@ + + + diff --git a/web/src/components/incident/DetailsPage.vue b/web/src/components/incident/DetailsPage.vue index 8919e05..337ff84 100644 --- a/web/src/components/incident/DetailsPage.vue +++ b/web/src/components/incident/DetailsPage.vue @@ -3,15 +3,17 @@
-

{{ selectedReport.incident_type_description }} Details - + Status:   {{ selectedReport.status_name }}

@@ -22,67 +24,35 @@ -->
+ + + + + + + + :class="{ unpadded: !smAndDown }"> - - - - - {{ lockDate }} - - - - - {{ uploadDate }} - - - - - {{ financeApproveDate }} - {{ financeApproveName }} - - - - - Ready to be activated - - - - - Ready to be activated + v-for="(step, idx) of selectedReport.steps" + :value="idx + 1" + :complete="step.complete_date" + :title="step.step_title" + :color="step.complete_date ? 'success' : ''" + :subtitle="step.complete_date ? `${formatDate(step.complete_date)} by ${step.complete_name}` : ''"> @@ -94,26 +64,28 @@
-

Incident Basics

+

Incident Information

- Reported by: + Department - Department: + Reported by - Supervisor: + Supervisor
@@ -127,8 +99,14 @@
- + + + + @@ -149,14 +127,6 @@ - Description of event @@ -192,11 +162,6 @@ - - - - -
@@ -206,10 +171,14 @@ import { storeToRefs } from "pinia"; import { DateTime } from "luxon"; import { useRoute } from "vue-router"; +import { useDisplay } from "vuetify"; +const { smAndDown } = useDisplay(); + import OperationMenu from "@/components/incident/OperationMenu.vue"; import ActionList from "@/components/action/ActionList.vue"; import HazardList from "@/components/hazard/HazardList.vue"; import ActionCreate from "@/components/action/ActionCreate.vue"; +import ActionEdit from "@/components/action/ActionEdit.vue"; import { useReportStore } from "@/store/ReportStore"; @@ -224,27 +193,66 @@ await initialize(); await loadReport(reportId); const showActionAdd = ref(false); +const showActionEdit = ref(false); +const actionToEdit = ref(null); + +setTimeout(() => { + let list = document.getElementsByClassName("v-stepper-item"); + + for (let i = 0; i < list.length - 1; i++) { + let item = list[i]; + let hr = document.createElement("hr"); + hr.classList.add("v-divider"); + hr.classList.add("v-theme--light"); + hr.setAttribute("area-orientation", "horizontal"); + hr.setAttribute("role", "separator"); + item.after(hr); + } +}, 100); const stepperValue = computed(() => { - if (!selectedReport.value) return 0; + if (selectedReport.value) { + let current = null; + + for (let i = 1; i < selectedReport.value.steps.length; i++) { + const step = selectedReport.value.steps[i - 1]; - if (selectedReport.value.status_name == "Initial Report") return 1; - if (selectedReport.value.status_name == "Supervisor Review") return 2; + if (step.complete_date) continue; + return i; + } + } return 0; }); function formatDate(input) { if (!input) return ""; - return DateTime.fromISO(input.toString()).toFormat("MMMM dd, yyyy"); -} - -function supervisorClick() { - selectedReport.value.status_code = "SUP"; - selectedReport.value.status_name = "Investigation Completed"; + return DateTime.fromISO(input.toString()).toFormat("yyyy/MM/dd @ h:ma"); } function addActionClick() { showActionAdd.value = true; } + +function doShowActionEdit(action) { + actionToEdit.value = action; + showActionEdit.value = true; +} + + diff --git a/web/src/components/incident/OperationMenu.vue b/web/src/components/incident/OperationMenu.vue index a4e95db..7c79550 100644 --- a/web/src/components/incident/OperationMenu.vue +++ b/web/src/components/incident/OperationMenu.vue @@ -1,17 +1,67 @@ - + diff --git a/web/src/components/report/ReportListCard.vue b/web/src/components/report/ReportListCard.vue index 7dd681e..2842711 100644 --- a/web/src/components/report/ReportListCard.vue +++ b/web/src/components/report/ReportListCard.vue @@ -41,7 +41,7 @@ function makeTitle(input: Incident) { function makeSubtitle(input: Incident) { return `Created: ${DateTime.fromISO(input.created_at.toString(), { - zone: "America/Whitehorse", + zone: "UTC", }).toRelative()}, Status: ${input.status_name}`; } diff --git a/web/src/routes.ts b/web/src/routes.ts index f69c585..e3fb8a9 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -3,7 +3,7 @@ import { createRouter, createWebHistory, RouteLocation, RouteRecordRaw } from "v import adminRoutes from "@/modules/administration/router"; import { authGuard } from "@auth0/auth0-vue"; -const routes: Array = [ +const routes: RouteRecordRaw[] = [ { path: "/", component: () => import("@/layouts/DefaultNoAuth.vue"), @@ -27,6 +27,9 @@ const routes: Array = [ { path: "reports/:id", component: () => import("@/components/incident/DetailsPage.vue"), + meta: { + requiresAuth: true, + }, }, { @@ -80,3 +83,14 @@ export const router = createRouter({ history: createWebHistory(), routes, }); + +router.beforeEach(async (to) => { + //document.title = `${APPLICATION_NAME} ${to.meta.title ? " - " + to.meta.title : ""}` + + if (to.meta.requiresAuth === false) return true; + + const isAuthenticated = await authGuard(to); + if (isAuthenticated) return true; + + return false; +}); diff --git a/web/src/store/DirectoryStore.ts b/web/src/store/DirectoryStore.ts index 72266cd..9181ed4 100644 --- a/web/src/store/DirectoryStore.ts +++ b/web/src/store/DirectoryStore.ts @@ -1,7 +1,8 @@ -import { defineStore } from "pinia"; +import { defineStore, storeToRefs } from "pinia"; import { useApiStore } from "@/store/ApiStore"; import { DIRECTORY_URL } from "@/urls"; +import { useRoleStore } from "./RoleStore"; export const useDirectoryStore = defineStore("directory", { state: () => ({ @@ -16,5 +17,34 @@ export const useDirectoryStore = defineStore("directory", { return resp.data; }); }, + + async searchActionDirectory({ terms, showRoles = true }: { terms: string; showRoles: boolean }) { + const roleStore = useRoleStore(); + const { roles } = storeToRefs(roleStore); + const { initialize } = roleStore; + + if (roles.value.length < 1) await initialize(); + + const roleItems = new Array(); + + for (const role of roles.value) { + if (role.name == "System Admin") continue; + if (role.name == "Monitor") continue; + + roleItems.push({ long_name: role.description, actor_role_type_id: role.id }); + } + + if (!terms || terms.length < 2) { + return roleItems; + } + + const api = useApiStore(); + + this.isLoading = true; + return api.call("post", `${DIRECTORY_URL}/search-action-directory`, { terms }).then((resp) => { + if (showRoles) return [...roleItems, ...resp.data]; + return resp.data; + }); + }, }, }); diff --git a/web/src/store/ReportStore.ts b/web/src/store/ReportStore.ts index 9828911..9f71f38 100644 --- a/web/src/store/ReportStore.ts +++ b/web/src/store/ReportStore.ts @@ -109,6 +109,113 @@ export const useReportStore = defineStore("reports", { console.log(`Error in loadReportsForRole/${roleName}`); }); }, + + async completeStep(step: any) { + if (!this.selectedReport) return; + + const api = useApiStore(); + return api + .secureCall("put", `${REPORTS_URL}/${this.selectedReport.id}/step/${step.id}/complete`) + .then((resp) => { + if (this.selectedReport) this.loadReport(this.selectedReport.id); + }) + .catch(() => { + console.log(`Error in completeStep /step/${step.id}/complete`); + }); + }, + + async revertStep(step: any) { + if (!this.selectedReport) return; + + const api = useApiStore(); + return api + .secureCall("put", `${REPORTS_URL}/${this.selectedReport.id}/step/${step.id}/revert`) + .then((resp) => { + if (this.selectedReport) this.loadReport(this.selectedReport.id); + }) + .catch(() => { + console.log(`Error in revertStep /step/${step.id}/complete`); + }); + }, + + async saveAction(action: any) { + if (!this.selectedReport) return; + let reportId = this.selectedReport.id; + + const api = useApiStore(); + + if (action.id) { + return api + .secureCall("put", `${REPORTS_URL}/${reportId}/action/${action.id}`, action) + .then((resp) => { + if (this.selectedReport) this.loadReport(reportId); + }) + .catch(() => { + console.log(`Error in completeStep /${reportId}/action/${action.id}`); + }); + } else { + return api + .secureCall("post", `${REPORTS_URL}/${reportId}/action`, action) + .then((resp) => { + if (this.selectedReport) this.loadReport(reportId); + }) + .catch(() => { + console.log(`Error in saveAction /${reportId}/action/${action.id}`); + }); + } + }, + async deleteAction(action: any) { + if (!this.selectedReport) return; + let reportId = this.selectedReport.id; + + const api = useApiStore(); + + if (action.id) { + return api + .secureCall("delete", `${REPORTS_URL}/${reportId}/action/${action.id}`) + .then((resp) => { + if (this.selectedReport) this.loadReport(reportId); + }) + .catch(() => { + console.log(`Error in deleteAction /${reportId}/action/${action.id}`); + }); + } + }, + + async completeAction(action: any) { + if (!this.selectedReport) return; + let reportId = this.selectedReport.id; + + const api = useApiStore(); + + if (action.id) { + return api + .secureCall("put", `${REPORTS_URL}/${reportId}/action/${action.id}/complete`) + .then((resp) => { + if (this.selectedReport) this.loadReport(reportId); + }) + .catch(() => { + console.log(`Error in deleteAction /${reportId}/action/${action.id}`); + }); + } + }, + async revertAction(action: any) { + if (!this.selectedReport) return; + let reportId = this.selectedReport.id; + + const api = useApiStore(); + + if (action.id) { + return api + .secureCall("put", `${REPORTS_URL}/${reportId}/action/${action.id}/revert`) + .then((resp) => { + if (this.selectedReport) this.loadReport(reportId); + }) + .catch(() => { + console.log(`Error in deleteAction /${reportId}/action/${action.id}`); + }); + } + }, }, }); diff --git a/web/src/store/RoleStore.ts b/web/src/store/RoleStore.ts index 7939cb2..6a40f63 100644 --- a/web/src/store/RoleStore.ts +++ b/web/src/store/RoleStore.ts @@ -6,7 +6,7 @@ import { ROLE_URL } from "@/urls"; export const useRoleStore = defineStore("role", { state: () => ({ isLoading: false, - roles: [], + roles: [] as any[], }), actions: { async initialize() {