diff --git a/packages/api/prisma/migrations/20220130071539_/migration.sql b/packages/api/prisma/migrations/20220130071539_/migration.sql new file mode 100644 index 000000000..c18d1f3f0 --- /dev/null +++ b/packages/api/prisma/migrations/20220130071539_/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "_Call911ToDivisionValue" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_Call911ToDepartmentValue" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_Call911ToDivisionValue_AB_unique" ON "_Call911ToDivisionValue"("A", "B"); + +-- CreateIndex +CREATE INDEX "_Call911ToDivisionValue_B_index" ON "_Call911ToDivisionValue"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_Call911ToDepartmentValue_AB_unique" ON "_Call911ToDepartmentValue"("A", "B"); + +-- CreateIndex +CREATE INDEX "_Call911ToDepartmentValue_B_index" ON "_Call911ToDepartmentValue"("B"); + +-- AddForeignKey +ALTER TABLE "_Call911ToDivisionValue" ADD FOREIGN KEY ("A") REFERENCES "Call911"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_Call911ToDivisionValue" ADD FOREIGN KEY ("B") REFERENCES "DivisionValue"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_Call911ToDepartmentValue" ADD FOREIGN KEY ("A") REFERENCES "Call911"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_Call911ToDepartmentValue" ADD FOREIGN KEY ("B") REFERENCES "DepartmentValue"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 76700acda..3217830e9 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -358,6 +358,7 @@ model DivisionValue { officers Officer[] @relation("officerDivisionToDivision") officerDivisionsToDivision Officer[] @relation("officerDivisionsToDivision") deputies EmsFdDeputy[] @relation("emsFdDivisionToDivision") + Call911 Call911[] } model DepartmentValue { @@ -372,6 +373,7 @@ model DepartmentValue { Officer Officer[] @relation("officerDepartmentToDepartment") division DivisionValue[] @relation("divisionDepartmentToValue") LeoWhitelistStatus LeoWhitelistStatus[] + Call911 Call911[] } model DriversLicenseCategoryValue { @@ -698,19 +700,21 @@ model ActiveDispatchers { // 911 calls & bolos model Call911 { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - position Position? @relation(fields: [positionId], references: [id]) + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + position Position? @relation(fields: [positionId], references: [id]) positionId String? userId String? assignedUnits AssignedUnit[] - location String @db.Text - postal String? @db.Text - description String? @db.Text + location String @db.Text + postal String? @db.Text + description String? @db.Text descriptionData Json? - name String @db.VarChar(255) - ended Boolean? @default(false) + name String @db.VarChar(255) + ended Boolean? @default(false) + divisions DivisionValue[] + departments DepartmentValue[] events Call911Event[] incidents LeoIncident[] } diff --git a/packages/api/src/controllers/dispatch/911-calls/CallEventsController.ts b/packages/api/src/controllers/dispatch/911-calls/CallEventsController.ts new file mode 100644 index 000000000..a673715a8 --- /dev/null +++ b/packages/api/src/controllers/dispatch/911-calls/CallEventsController.ts @@ -0,0 +1,119 @@ +import { Controller } from "@tsed/di"; +import { Delete, Post, Put } from "@tsed/schema"; +import { CREATE_911_CALL_EVENT } from "@snailycad/schemas"; +import { BodyParams, PathParams } from "@tsed/platform-params"; +import { NotFound } from "@tsed/exceptions"; +import { prisma } from "lib/prisma"; +import { Socket } from "services/SocketService"; +import { UseBeforeEach } from "@tsed/platform-middlewares"; +import { IsAuth } from "middlewares/index"; +import { validateSchema } from "lib/validateSchema"; + +@Controller("/911-calls/events") +@UseBeforeEach(IsAuth) +export class Calls911Controller { + private socket: Socket; + constructor(socket: Socket) { + this.socket = socket; + } + + @Post("/:callId") + async createCallEvent(@PathParams("callId") callId: string, @BodyParams() body: unknown) { + const data = validateSchema(CREATE_911_CALL_EVENT, body); + + const call = await prisma.call911.findUnique({ + where: { id: callId }, + }); + + if (!call) { + throw new NotFound("callNotFound"); + } + + const event = await prisma.call911Event.create({ + data: { + call911Id: call.id, + description: data.description, + }, + }); + + this.socket.emitAddCallEvent(event); + + return event; + } + + @Put("/:callId/:eventId") + async updateCallEvent( + @PathParams("callId") callId: string, + @PathParams("eventId") eventId: string, + @BodyParams() body: unknown, + ) { + const data = validateSchema(CREATE_911_CALL_EVENT, body); + + const call = await prisma.call911.findUnique({ + where: { id: callId }, + }); + + if (!call) { + throw new NotFound("callNotFound"); + } + + const event = await prisma.call911Event.findFirst({ + where: { + id: eventId, + call911Id: callId, + }, + }); + + if (!event) { + throw new NotFound("eventNotFound"); + } + + const updated = await prisma.call911Event.update({ + where: { + id: event.id, + }, + data: { + description: data.description, + }, + }); + + this.socket.emitUpdateCallEvent(updated); + + return updated; + } + + @Delete("/:callId/:eventId") + async deleteCallEvent( + @PathParams("callId") callId: string, + @PathParams("eventId") eventId: string, + ) { + const call = await prisma.call911.findUnique({ + where: { id: callId }, + }); + + if (!call) { + throw new NotFound("callNotFound"); + } + + const event = await prisma.call911Event.findFirst({ + where: { + id: eventId, + call911Id: callId, + }, + }); + + if (!event) { + throw new NotFound("eventNotFound"); + } + + await prisma.call911Event.delete({ + where: { + id: event.id, + }, + }); + + this.socket.emitDeleteCallEvent(event); + + return true; + } +} diff --git a/packages/api/src/controllers/dispatch/Calls911Controller.ts b/packages/api/src/controllers/dispatch/Calls911Controller.ts index da3ebd397..cee2d1c8e 100644 --- a/packages/api/src/controllers/dispatch/Calls911Controller.ts +++ b/packages/api/src/controllers/dispatch/Calls911Controller.ts @@ -1,6 +1,6 @@ import { Controller } from "@tsed/di"; import { Delete, Get, Post, Put } from "@tsed/schema"; -import { CREATE_911_CALL, CREATE_911_CALL_EVENT, LINK_INCIDENT_TO_CALL } from "@snailycad/schemas"; +import { CREATE_911_CALL, LINK_INCIDENT_TO_CALL } from "@snailycad/schemas"; import { BodyParams, Context, PathParams, QueryParams } from "@tsed/platform-params"; import { BadRequest, NotFound } from "@tsed/exceptions"; import { prisma } from "lib/prisma"; @@ -10,16 +10,12 @@ import { IsAuth } from "middlewares/index"; import { ShouldDoType, CombinedLeoUnit, Officer, EmsFdDeputy } from ".prisma/client"; import { unitProperties, leoProperties } from "lib/officer"; import { validateSchema } from "lib/validateSchema"; -import type { User } from "@prisma/client"; +import type { DepartmentValue, DivisionValue, User } from "@prisma/client"; const assignedUnitsInclude = { include: { - officer: { - include: leoProperties, - }, - deputy: { - include: unitProperties, - }, + officer: { include: leoProperties }, + deputy: { include: unitProperties }, combinedUnit: { include: { status: { include: { value: true } }, @@ -36,6 +32,8 @@ export const callInclude = { assignedUnits: assignedUnitsInclude, events: true, incidents: true, + departments: { include: leoProperties.department.include }, + divisions: { include: leoProperties.division.include }, }; @Controller("/911-calls") @@ -77,6 +75,12 @@ export class Calls911Controller { const units = (data.assignedUnits ?? []) as string[]; await this.assignUnitsToCall(call.id, units); + await this.linkOrUnlinkCallDepartmentsAndDivisions({ + type: "connect", + departments: data.departments as string[], + divisions: data.divisions as string[], + callId: call.id, + }); const updated = await prisma.call911.findUnique({ where: { @@ -104,6 +108,8 @@ export class Calls911Controller { }, include: { assignedUnits: assignedUnitsInclude, + departments: true, + divisions: true, }, }); @@ -120,6 +126,13 @@ export class Calls911Controller { }), ); + await this.linkOrUnlinkCallDepartmentsAndDivisions({ + type: "disconnect", + departments: call.departments, + divisions: call.divisions, + callId: call.id, + }); + const positionData = data.position ?? null; const position = positionData @@ -155,6 +168,12 @@ export class Calls911Controller { const units = (data.assignedUnits ?? []) as string[]; await this.assignUnitsToCall(call.id, units); + await this.linkOrUnlinkCallDepartmentsAndDivisions({ + type: "connect", + departments: data.departments as string[], + divisions: data.divisions as string[], + callId: call.id, + }); const updated = await prisma.call911.findUnique({ where: { @@ -209,106 +228,6 @@ export class Calls911Controller { return true; } - @Post("/events/:callId") - async createCallEvent(@PathParams("callId") callId: string, @BodyParams() body: unknown) { - const data = validateSchema(CREATE_911_CALL_EVENT, body); - - const call = await prisma.call911.findUnique({ - where: { id: callId }, - }); - - if (!call) { - throw new NotFound("callNotFound"); - } - - const event = await prisma.call911Event.create({ - data: { - call911Id: call.id, - description: data.description, - }, - }); - - this.socket.emitAddCallEvent(event); - - return event; - } - - @Put("/events/:callId/:eventId") - async updateCallEvent( - @PathParams("callId") callId: string, - @PathParams("eventId") eventId: string, - @BodyParams() body: unknown, - ) { - const data = validateSchema(CREATE_911_CALL_EVENT, body); - - const call = await prisma.call911.findUnique({ - where: { id: callId }, - }); - - if (!call) { - throw new NotFound("callNotFound"); - } - - const event = await prisma.call911Event.findFirst({ - where: { - id: eventId, - call911Id: callId, - }, - }); - - if (!event) { - throw new NotFound("eventNotFound"); - } - - const updated = await prisma.call911Event.update({ - where: { - id: event.id, - }, - data: { - description: data.description, - }, - }); - - this.socket.emitUpdateCallEvent(updated); - - return updated; - } - - @Delete("/events/:callId/:eventId") - async deleteCallEvent( - @PathParams("callId") callId: string, - @PathParams("eventId") eventId: string, - ) { - const call = await prisma.call911.findUnique({ - where: { id: callId }, - }); - - if (!call) { - throw new NotFound("callNotFound"); - } - - const event = await prisma.call911Event.findFirst({ - where: { - id: eventId, - call911Id: callId, - }, - }); - - if (!event) { - throw new NotFound("eventNotFound"); - } - - await prisma.call911Event.delete({ - where: { - id: event.id, - }, - }); - - this.socket.emitDeleteCallEvent(event); - - return true; - } - @Post("/:type/:callId") async assignToCall( @PathParams("type") callType: "assign" | "unassign", @@ -403,7 +322,7 @@ export class Calls911Controller { return true; } - private officerOrDeputyToUnit(call: any & { assignedUnits: any[] }) { + protected officerOrDeputyToUnit(call: any & { assignedUnits: any[] }) { return { ...call, assignedUnits: (call.assignedUnits ?? [])?.map((v: any) => ({ @@ -416,7 +335,39 @@ export class Calls911Controller { }; } - private async assignUnitsToCall(callId: string, units: string[]) { + protected async linkOrUnlinkCallDepartmentsAndDivisions({ + type, + callId, + departments, + divisions, + }: { + type: "disconnect" | "connect"; + callId: string; + departments: (DepartmentValue["id"] | DepartmentValue)[]; + divisions: (DivisionValue["id"] | DivisionValue)[]; + }) { + await Promise.all( + departments.map(async (dep) => { + const id = typeof dep === "string" ? dep : dep.id; + await prisma.call911.update({ + where: { id: callId }, + data: { departments: { [type]: { id } } }, + }); + }), + ); + + await Promise.all( + divisions.map(async (division) => { + const id = typeof division === "string" ? division : division.id; + await prisma.call911.update({ + where: { id: callId }, + data: { divisions: { [type]: { id } } }, + }); + }), + ); + } + + protected async assignUnitsToCall(callId: string, units: string[]) { await Promise.all( units.map(async (id) => { const { unit, type } = await findUnit( diff --git a/packages/client/locales/en/calls.json b/packages/client/locales/en/calls.json index 4d066135d..4a5791ec6 100644 --- a/packages/client/locales/en/calls.json +++ b/packages/client/locales/en/calls.json @@ -31,6 +31,9 @@ "noEvents": "This call does not have any events", "deliveryAddress": "Delivery Address", "callTow": "Call Tow", + "departments": "Departments", + "divisions": "Divisions", + "callFilters": "Call Filters", "alert_deleteCallEvent": "Are you sure you want to delete this event? This action cannot be undone.", "alert_end911Call": "Are you sure you want to end this call?", "alert_endTowCall": "Are you sure you want to end this call?" diff --git a/packages/client/src/components/form/Select.tsx b/packages/client/src/components/form/Select.tsx index 77011059f..c4b27ed77 100644 --- a/packages/client/src/components/form/Select.tsx +++ b/packages/client/src/components/form/Select.tsx @@ -125,16 +125,22 @@ export function styles({ padding: "0.2rem", borderRadius: "2px 0 0 2px", }), - multiValueRemove: (base) => ({ - ...base, - backgroundColor: backgroundColor === "white" ? "#cccccc" : "#2f2f2f", - color, - borderRadius: "0 2px 2px 0", - cursor: "pointer", - ":hover": { - filter: "brightness(90%)", - }, - }), + multiValueRemove: (base, props) => { + if (props.isDisabled) { + return { ...base, display: "none" }; + } + + return { + ...base, + backgroundColor: backgroundColor === "white" ? "#cccccc" : "#2f2f2f", + color, + borderRadius: "0 2px 2px 0", + cursor: "pointer", + ":hover": { + filter: "brightness(90%)", + }, + }; + }, indicatorsContainer: (base) => ({ ...base, backgroundColor, diff --git a/packages/client/src/components/leo/ActiveCalls.tsx b/packages/client/src/components/leo/ActiveCalls.tsx index bac97aa36..a9f7db550 100644 --- a/packages/client/src/components/leo/ActiveCalls.tsx +++ b/packages/client/src/components/leo/ActiveCalls.tsx @@ -4,7 +4,6 @@ import { SocketEvents } from "@snailycad/config"; import { Button } from "components/Button"; import { Manage911CallModal } from "components/modals/Manage911CallModal"; import { useAuth } from "context/AuthContext"; -import format from "date-fns/format"; import { useRouter } from "next/router"; import { Full911Call, useDispatchState } from "state/dispatchState"; import type { AssignedUnit, Call911 } from "types/prisma"; @@ -21,16 +20,17 @@ import { DispatchCallTowModal } from "components/dispatch/modals/CallTowModal"; import compareDesc from "date-fns/compareDesc"; import { useFeatureEnabled } from "hooks/useFeatureEnabled"; import { useActiveDispatchers } from "hooks/realtime/useActiveDispatchers"; +import { CallsFilters, useActiveCallsFilters } from "./calls/CallsFilters"; +import { CallsFiltersProvider, useCallsFilters } from "context/CallsFiltersContext"; +import { Filter } from "react-bootstrap-icons"; +import { Table } from "components/shared/Table"; +import { FullDate } from "components/shared/FullDate"; const DescriptionModal = dynamic( async () => (await import("components/modal/DescriptionModal/DescriptionModal")).DescriptionModal, ); -const CallEventsModal = dynamic( - async () => (await import("components/modals/CallEventsModal")).CallEventsModal, -); - -export function ActiveCalls() { +function ActiveCallsInner() { const { hasActiveDispatchers } = useActiveDispatchers(); const [tempCall, setTempCall] = React.useState(null); @@ -46,6 +46,8 @@ export function ActiveCalls() { const { activeOfficer } = useLeoState(); const { activeDeputy } = useEmsFdState(); const { TOW, CALLS_911 } = useFeatureEnabled(); + const { setShowFilters, search } = useCallsFilters(); + const handleFilter = useActiveCallsFilters(); const unit = router.pathname === "/officer" @@ -84,6 +86,7 @@ export function ActiveCalls() { setCalls( calls.map((v) => { if (v.id === call.id) { + setTempCall({ ...v, ...call }); return call; } @@ -130,116 +133,111 @@ export function ActiveCalls() { return (
-
+

{t("active911Calls")}

+ +
+ +
+ + {calls.length <= 0 ? (

{t("no911Calls")}

) : ( -
- - - - - - - - - - - - - - {calls - .sort((a, b) => compareDesc(new Date(a.updatedAt), new Date(b.updatedAt))) - .map((call) => { - const isUnitAssigned = isUnitAssignedToCall(call); +
{t("caller")}{t("location")}{common("description")}{common("updatedAt")}{t("postal")}{t("assignedUnits")}{common("actions")}
compareDesc(new Date(a.updatedAt), new Date(b.updatedAt))) + .filter(handleFilter) + .map((call) => { + const isUnitAssigned = isUnitAssignedToCall(call); - return ( - + {call.description} + + ) : ( + + ), + updatedAt: {call.updatedAt}, + assignedUnits: call.assignedUnits.map(makeUnit).join(", ") || common("none"), + actions: ( + <> + - - - - - - - - ); - })} - -
{call.name}{call.location} - {call.description && !call.descriptionData ? ( - call.description - ) : ( - - )} - {format(new Date(call.updatedAt), "HH:mm:ss - yyyy-MM-dd")}{call.postal || common("none")}{call.assignedUnits.map(makeUnit).join(", ") || common("none")} - {isDispatch ? ( - <> - - - ) : ( - <> - - {isUnitAssigned ? ( - - ) : ( - - )} - - )} + {isDispatch ? common("manage") : common("view")} + - {TOW ? ( - - ) : null} -
-
+ {isDispatch ? null : isUnitAssigned ? ( + + ) : ( + + )} + + {TOW ? ( + + ) : null} + + ), + }; + })} + columns={[ + { Header: t("caller"), accessor: "name" }, + { Header: t("location"), accessor: "location" }, + { Header: common("description"), accessor: "description" }, + { Header: common("updatedAt"), accessor: "updatedAt" }, + { Header: t("assignedUnits"), accessor: "assignedUnits" }, + { Header: common("actions"), accessor: "actions" }, + ]} + /> )}
@@ -248,15 +246,15 @@ export function ActiveCalls() { setTempCall(null)} value={tempCall.descriptionData} /> ) : null} - {isDispatch ? ( - setTempCall(null)} - call={tempCall} - /> - ) : ( - setTempCall(null)} call={tempCall} /> - )} + setTempCall(null)} call={tempCall} />
); } + +export function ActiveCalls() { + return ( + + + + ); +} diff --git a/packages/client/src/components/leo/calls/CallsFilters.tsx b/packages/client/src/components/leo/calls/CallsFilters.tsx new file mode 100644 index 000000000..3c24a8cf8 --- /dev/null +++ b/packages/client/src/components/leo/calls/CallsFilters.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import type { Full911Call } from "state/dispatchState"; +import { makeUnitName } from "lib/utils"; +import { FormField } from "components/form/FormField"; +import { useTranslations } from "next-intl"; +import { Input } from "components/form/inputs/Input"; +import { useCallsFilters } from "context/CallsFiltersContext"; +import { Select, SelectValue } from "components/form/Select"; + +interface Props { + calls: Full911Call[]; +} + +export function CallsFilters({ calls }: Props) { + const { department, setDepartment, setDivision, division, search, setSearch, showFilters } = + useCallsFilters(); + + const common = useTranslations("Common"); + const t = useTranslations("Calls"); + + const departments = makeOptions(calls, "departments"); + const divisions = makeOptions(calls, "divisions"); + + React.useEffect(() => { + if (!showFilters) { + setDepartment(null); + setDivision(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showFilters]); + + return showFilters ? ( +
+ + setSearch(e.target.value)} value={search} /> + + + + setDivision(e.target)} + className="w-56" + values={divisions} + /> + +
+ ) : null; +} + +export type Call911Filters = "departments" | "divisions" | "assignedUnits"; + +function makeOptions(calls: Full911Call[], type: Call911Filters) { + const arr: SelectValue[] = []; + + calls.forEach((call) => { + const data = call[type]; + + if (Array.isArray(data)) { + data.forEach((v) => { + const label = "value" in v ? v.value.value : makeUnitName(v.unit); + const value = "value" in v ? v.id : v.unit.id; + + const obj = { + value, + label, + }; + + const existing = arr.some((v) => v.value === obj.value); + if (!existing) { + arr.push(obj); + } + }); + + return; + } + + if (data) { + arr.push({ value: data, label: data }); + } + }); + + return arr; +} + +export function useActiveCallsFilters() { + const { department, division } = useCallsFilters(); + + const handleFilter = React.useCallback( + (value: Full911Call) => { + const isInDepartments = includesInArray(value.departments, department?.value); + const isInDivisions = includesInArray(value.divisions, division?.value); + + /** + * show all calls if there is no filter + */ + if (!department?.value && !division?.value) return true; + + /** + * department and division selected? + * -> only show calls with that department and division + */ + if (department?.value && division?.value) { + return isInDepartments && isInDivisions; + } + + if (isInDepartments) return true; + if (isInDivisions) return true; + + return false; + }, + [department?.value, division?.value], + ); + + return handleFilter; +} + +function includesInArray(arr: { id: string }[], value: string | undefined) { + return arr.some((v) => v.id === value); +} diff --git a/packages/client/src/components/modals/911Call/EventsArea.tsx b/packages/client/src/components/modals/911Call/EventsArea.tsx index 3dab0bddc..c057036a6 100644 --- a/packages/client/src/components/modals/911Call/EventsArea.tsx +++ b/packages/client/src/components/modals/911Call/EventsArea.tsx @@ -19,9 +19,10 @@ import { FullDate } from "components/shared/FullDate"; interface Props { call: Full911Call; + disabled?: boolean; } -export function CallEventsArea({ call }: Props) { +export function CallEventsArea({ disabled, call }: Props) { const { state, execute } = useFetch(); const common = useTranslations("Common"); const t = useTranslations("Calls"); @@ -55,51 +56,66 @@ export function CallEventsArea({ call }: Props) { ) : ( call?.events .sort((a, b) => compareDesc(new Date(a.createdAt), new Date(b.createdAt))) - .map((event) => ) + .map((event) => ( + + )) )} - - {({ handleChange, values, errors }) => ( -
- -