diff --git a/apps/api/prisma/migrations/20231223155548_draft_records/migration.sql b/apps/api/prisma/migrations/20231223155548_draft_records/migration.sql new file mode 100644 index 000000000..333540ba0 --- /dev/null +++ b/apps/api/prisma/migrations/20231223155548_draft_records/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "PublishStatus" AS ENUM ('DRAFT', 'PUBLISHED'); + +-- AlterTable +ALTER TABLE "Record" ADD COLUMN "publishStatus" "PublishStatus" NOT NULL DEFAULT 'DRAFT'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a0e3dce70..bcf0ea4c5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1434,6 +1434,7 @@ model Bolo { // tickets, arrest reports, warrants, written warnings model Record { id String @id @default(uuid()) + publishStatus PublishStatus @default(DRAFT) caseNumber Int @default(autoincrement()) type RecordType citizen Citizen? @relation(fields: [citizenId], references: [id], onDelete: Cascade) @@ -1724,6 +1725,11 @@ model ActiveTone { createdById String } +enum PublishStatus { + DRAFT + PUBLISHED +} + enum ActiveToneType { LEO EMS_FD diff --git a/apps/api/src/controllers/leo/search/SearchController.ts b/apps/api/src/controllers/leo/search/SearchController.ts index 8b6cda4cc..ae52fd675 100644 --- a/apps/api/src/controllers/leo/search/SearchController.ts +++ b/apps/api/src/controllers/leo/search/SearchController.ts @@ -44,6 +44,8 @@ export const vehicleSearchInclude = { export const recordsInclude = (isRecordApprovalEnabled: boolean) => ({ where: isRecordApprovalEnabled ? { status: WhitelistStatus.ACCEPTED } : undefined, include: { + citizen: true, + business: { select: { name: true, id: true } }, officer: { include: leoProperties }, seizedItems: true, courtEntry: { include: { dates: true } }, diff --git a/apps/api/src/controllers/record/records-controller.ts b/apps/api/src/controllers/record/records-controller.ts index 3f6b13b9a..042ba952a 100644 --- a/apps/api/src/controllers/record/records-controller.ts +++ b/apps/api/src/controllers/record/records-controller.ts @@ -25,6 +25,7 @@ import { type User, type Business, PaymentStatus, + type RecordType, } from "@prisma/client"; import { validateSchema } from "lib/data/validate-schema"; import { combinedUnitProperties, leoProperties } from "utils/leo/includes"; @@ -68,6 +69,47 @@ export class RecordsController { this.socket = socket; } + @Get("/drafts") + @Description("Get draft records that a user created") + async getUserDraftRecords( + @Context("user") user: User, + @Context("cad") cad: cad & { features: Record }, + @QueryParams("type") type: string, + ) { + const isEnabled = isFeatureEnabled({ + feature: Feature.CITIZEN_RECORD_APPROVAL, + features: cad.features, + defaultReturn: false, + }); + + const draftRecords = await prisma.record.findMany({ + // todo: visualize in the FE + take: 12, + where: { + publishStatus: "DRAFT", + officer: { userId: user.id }, + type: type as RecordType, + }, + include: recordsInclude(isEnabled).include, + }); + + return draftRecords as APITypes.GetCitizenByIdRecordsData; + } + + @Delete("/drafts/:id") + async deleteDraftRecordById(@Context("user") user: User, @PathParams("id") id: string) { + const record = await prisma.record + .delete({ + where: { + id, + officer: { userId: user.id }, + }, + }) + .catch(() => null); + + return !!record; + } + @Get("/active-warrants") @Description("Get all active warrants (ACTIVE_WARRANTS must be enabled)") @IsFeatureEnabled({ feature: Feature.ACTIVE_WARRANTS }) diff --git a/apps/api/src/lib/leo/records/upsert-record.ts b/apps/api/src/lib/leo/records/upsert-record.ts index 3445e90e0..84c419769 100644 --- a/apps/api/src/lib/leo/records/upsert-record.ts +++ b/apps/api/src/lib/leo/records/upsert-record.ts @@ -4,6 +4,7 @@ import { type CourtEntry, type SeizedItem, type Violation, + PublishStatus, } from "@prisma/client"; import type { CREATE_TICKET_SCHEMA, CREATE_TICKET_SCHEMA_BUSINESS } from "@snailycad/schemas"; import { type PaymentStatus, type RecordType, WhitelistStatus } from "@snailycad/types"; @@ -27,19 +28,6 @@ interface UpsertRecordOptions { } export async function upsertRecord(options: UpsertRecordOptions) { - if (options.recordId) { - const record = await prisma.record.findUnique({ - where: { id: options.recordId }, - include: { violations: true, seizedItems: true }, - }); - - if (!record) { - throw new NotFound("notFound"); - } - - await Promise.all([unlinkViolations(record.violations), unlinkSeizedItems(record.seizedItems)]); - } - let citizen; let business; @@ -104,6 +92,8 @@ export async function upsertRecord(options: UpsertRecordOptions) { call911Id: options.data.call911Id || null, incidentId: options.data.incidentId || null, descriptionData: options.data.descriptionData || undefined, + publishStatus: + (options.data.publishStatus as PublishStatus | null) ?? PublishStatus.PUBLISHED, }, update: { notes: options.data.notes, @@ -120,6 +110,8 @@ export async function upsertRecord(options: UpsertRecordOptions) { vehicleSpeed: options.data.vehicleSpeed || null, vehiclePaceType: options.data.vehiclePaceType || null, speedLimit: options.data.speedLimit || null, + publishStatus: + (options.data.publishStatus as PublishStatus | null) ?? PublishStatus.PUBLISHED, }, include: { officer: { include: leoProperties }, @@ -192,6 +184,19 @@ export async function upsertRecord(options: UpsertRecordOptions) { throw new ExtendedBadRequest(errors); } + if (options.recordId) { + const record = await prisma.record.findUnique({ + where: { id: options.recordId }, + include: { violations: true, seizedItems: true }, + }); + + if (!record) { + throw new NotFound("notFound"); + } + + await Promise.all([unlinkViolations(record.violations), unlinkSeizedItems(record.seizedItems)]); + } + const violations = await prisma.$transaction( fullFilledValidatedViolations.map((item) => { return prisma.violation.create({ diff --git a/apps/client/locales/en/leo.json b/apps/client/locales/en/leo.json index 9e9f50dfd..b95e7fa09 100644 --- a/apps/client/locales/en/leo.json +++ b/apps/client/locales/en/leo.json @@ -388,7 +388,10 @@ "counts": "Counts", "stats": "Stats", "deletedPenalCode": "Deleted Penal Code", - "exportCriminalRecord": "Export Criminal Record" + "exportCriminalRecord": "Export Criminal Record", + "savedAsDraft": "Successfully saved this record as a draft.", + "drafts": "Drafts", + "publishDraft": "Publish Draft" }, "Bolos": { "activeBolos": "Active Bolos", diff --git a/apps/client/src/components/leo/modals/manage-record/manage-record-modal.tsx b/apps/client/src/components/leo/modals/manage-record/manage-record-modal.tsx index d907451de..c4e34d2de 100644 --- a/apps/client/src/components/leo/modals/manage-record/manage-record-modal.tsx +++ b/apps/client/src/components/leo/modals/manage-record/manage-record-modal.tsx @@ -10,17 +10,24 @@ import { SwitchField, FormRow, FullDate, + Alert, } from "@snailycad/ui"; import { FormField } from "components/form/FormField"; import { Modal } from "components/modal/Modal"; import { useModal } from "state/modalState"; import { useValues } from "context/ValuesContext"; -import { Form, Formik, type FormikHelpers } from "formik"; +import { Form, Formik, useFormikContext, type FormikHelpers } from "formik"; import { handleValidate } from "lib/handleValidate"; import useFetch from "lib/useFetch"; import { ModalIds } from "types/modal-ids"; import { useTranslations } from "use-intl"; -import { RecordType, type PenalCode, type Record, PaymentStatus } from "@snailycad/types"; +import { + RecordType, + type PenalCode, + type Record, + PaymentStatus, + PublishStatus, +} from "@snailycad/types"; import { toastMessage } from "lib/toastMessage"; import { useFeatureEnabled } from "hooks/useFeatureEnabled"; import type { PostRecordsData, PutRecordsByIdData } from "@snailycad/types/api"; @@ -30,6 +37,10 @@ import dynamic from "next/dynamic"; import type { BusinessSearchResult } from "state/search/business-search-state"; import { Editor, dataToSlate } from "components/editor/editor"; import { useInvalidateQuery } from "hooks/use-invalidate-query"; +import { useMutation } from "@tanstack/react-query"; +import { useDebounce } from "react-use"; +import { InfoCircleFill } from "react-bootstrap-icons"; +import { DraftsTab } from "./tabs/drafts-tab/drafts-tab"; const ManageCourtEntryModal = dynamic( async () => @@ -90,6 +101,8 @@ interface CreateInitialRecordValuesOptions { export function createInitialRecordValues(options: CreateInitialRecordValuesOptions) { return { + id: options.record?.id ?? null, + publishStatus: options.record?.publishStatus, type: options.type, citizenId: options.record?.citizenId ?? options.payload?.citizenId ?? "", citizenName: options.payload?.citizenName ?? "", @@ -142,7 +155,38 @@ export function createInitialRecordValues(options: CreateInitialRecordValuesOpti }; } +interface GetRequestDataOptions { + values: ReturnType; + features: ReturnType; + props: Props; + publishStatus: PublishStatus; +} + +function getRequestData(options: GetRequestDataOptions) { + return { + ...options.values, + type: options.props.type, + publishStatus: options.publishStatus, + violations: options.values.violations.map(({ value }: { value: any }) => ({ + penalCodeId: value.id, + bail: options.features.LEO_BAIL && value.jailTime?.enabled ? value.bail?.value : null, + jailTime: value.jailTime?.enabled ? value.jailTime?.value : null, + fine: value.fine?.enabled ? value.fine?.value : null, + counts: value.counts?.value ?? null, + communityService: value.communityService?.enabled ? value.communityService?.value : null, + })), + }; +} + +interface MutationState { + isPending?: boolean; + data?: PostRecordsData | PutRecordsByIdData | null; +} + export function ManageRecordModal(props: Props) { + const [mutationState, setMutationState] = React.useState(null); + const [activeTab, setActiveTab] = React.useState("general-information-tab"); + const [isBusinessRecord, setIsBusinessRecord] = React.useState( Boolean(props.record?.businessId && !props.record.citizenId), ); @@ -150,7 +194,7 @@ export function ManageRecordModal(props: Props) { const common = useTranslations("Common"); const t = useTranslations("Leo"); const tCourt = useTranslations("Courthouse"); - const { LEO_BAIL } = useFeatureEnabled(); + const features = useFeatureEnabled(); const { invalidateQuery } = useInvalidateQuery(["officer", "notifications"]); React.useEffect(() => { @@ -196,19 +240,12 @@ export function ManageRecordModal(props: Props) { ) { if (props.isReadOnly) return; - const requestData = { - ...values, - type: props.type, - violations: values.violations.map(({ value }: { value: any }) => ({ - penalCodeId: value.id, - bail: LEO_BAIL && value.jailTime?.enabled ? value.bail?.value : null, - jailTime: value.jailTime?.enabled ? value.jailTime?.value : null, - fine: value.fine?.enabled ? value.fine?.value : null, - counts: value.counts?.value ?? null, - communityService: value.communityService?.enabled ? value.communityService?.value : null, - })), - }; - + const requestData = getRequestData({ + props, + features, + values, + publishStatus: PublishStatus.PUBLISHED, + }); validateRecords(values.violations, helpers); if (props.customSubmitHandler) { @@ -219,9 +256,9 @@ export function ManageRecordModal(props: Props) { return; } - if (props.record) { + if (values.id) { const { json } = await execute({ - path: `/records/record/${props.record.id}`, + path: `/records/record/${values.id}`, method: "PUT", data: requestData, helpers, @@ -269,7 +306,7 @@ export function ManageRecordModal(props: Props) { type: props.type, record: props.record, penalCodes, - isLeoBailEnabled: LEO_BAIL, + isLeoBailEnabled: features.LEO_BAIL, payload, }); @@ -289,9 +326,12 @@ export function ManageRecordModal(props: Props) { {({ setFieldValue, setValues, errors, values, isValid }) => (
+ {props.hideCitizenField ? null : ( @@ -412,8 +458,10 @@ export function ManageRecordModal(props: Props) { disabled={!isValid || state === "loading"} type="submit" > - {state === "loading" ? : null} - {props.record ? common("save") : common("create")} + {state === "loading" || mutationState?.isPending ? ( + + ) : null} + {props.record || mutationState?.data ? common("save") : common("create")} )} @@ -425,6 +473,7 @@ export function ManageRecordModal(props: Props) { }} courtEntry={values.courtEntry} /> + )} @@ -432,6 +481,83 @@ export function ManageRecordModal(props: Props) { ); } +function AutoSaveDraft( + props: Props & { setMutationState: React.Dispatch> }, +) { + const form = useFormikContext>(); + const { execute } = useFetch(); + const features = useFeatureEnabled(); + const t = useTranslations("Leo"); + + const mutation = useMutation({ + mutationKey: ["save-draft-record", form.values], + mutationFn: async () => { + const requestData = getRequestData({ + props, + features, + values: form.values, + publishStatus: PublishStatus.DRAFT, + }); + + const isValid = form.isValid; + if (!isValid || form.values.publishStatus === PublishStatus.PUBLISHED) return null; + + if (form.values.id) { + // Update the existing draft record + const { json } = await execute({ + path: `/records/record/${form.values.id}`, + method: "PUT", + data: requestData, + noToast: true, + }); + + if (json.id) { + return json; + } + } else { + // Save as a new draft + const { json } = await execute({ + path: "/records", + method: "POST", + data: requestData, + noToast: true, + }); + + if (json.id) { + form.setFieldValue("id", json.id); + return json; + } + } + + return null; + }, + }); + + useDebounce(() => mutation.mutate(), 1000, [form.values]); + + React.useEffect(() => { + props.setMutationState((prev) => ({ + ...prev, + data: mutation.data, + })); + }, [mutation.data]); // eslint-disable-line react-hooks/exhaustive-deps + + React.useEffect(() => { + props.setMutationState((prev) => ({ + ...prev, + isPending: mutation.isPending, + })); + }, [mutation.isPending]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ {form.values.id && mutation.data ? ( + } type="info" message={t("savedAsDraft")} /> + ) : null} +
+ ); +} + function validateRecords(data: any[], helpers: FormikHelpers) { data.forEach(({ value }) => { const isFinesEnabled = value.fine?.enabled; diff --git a/apps/client/src/components/leo/modals/manage-record/tabs/drafts-tab/drafts-tab.tsx b/apps/client/src/components/leo/modals/manage-record/tabs/drafts-tab/drafts-tab.tsx new file mode 100644 index 000000000..97ed64b94 --- /dev/null +++ b/apps/client/src/components/leo/modals/manage-record/tabs/drafts-tab/drafts-tab.tsx @@ -0,0 +1,114 @@ +import type { PenalCode, RecordType } from "@snailycad/types"; +import type { GetCitizenByIdRecordsData } from "@snailycad/types/api"; +import { Button, FullDate, TabsContent } from "@snailycad/ui"; +import { Table, useAsyncTable, useTableState } from "components/shared/Table"; +import { useFormikContext } from "formik"; +import useFetch from "lib/useFetch"; +import { useTranslations } from "use-intl"; +import { createInitialRecordValues } from "../../manage-record-modal"; +import { useFeatureEnabled } from "hooks/useFeatureEnabled"; + +interface DraftsTabProps { + type: RecordType; + payload: { + citizenId?: string | undefined; + citizenName?: string | undefined; + businessId?: string | undefined; + businessName?: string | undefined; + } | null; + penalCodes: PenalCode[]; + setActiveTab(str: string): void; +} + +export function DraftsTab(props: DraftsTabProps) { + const t = useTranslations("Leo"); + const { state, execute } = useFetch(); + const form = useFormikContext>(); + const features = useFeatureEnabled(); + + const asyncTable = useAsyncTable({ + totalCount: 12, + fetchOptions: { + pageSize: 12, + path: `/records/drafts?type=${props.type}`, + onResponse(json: GetCitizenByIdRecordsData) { + return { data: json, totalCount: json.length }; + }, + }, + }); + const tableState = useTableState(asyncTable); + + function onContinueClick(record: GetCitizenByIdRecordsData[number]) { + form.setValues( + createInitialRecordValues({ + record: { + ...record, + publishStatus: "DRAFT", + }, + type: props.type, + t, + isLeoBailEnabled: features.LEO_BAIL, + payload: { + citizenName: record.citizen + ? `${record.citizen.name} ${record.citizen.surname}` + : undefined, + citizenId: record.citizen?.id, + businessId: record.business?.id, + businessName: record.business?.name, + }, + penalCodes: props.penalCodes, + }), + ); + props.setActiveTab("general-information-tab"); + } + + async function onDeleteClick(recordId: string) { + const { json } = await execute({ + path: `/records/drafts/${recordId}`, + method: "DELETE", + }); + + if (typeof json === "boolean" && json) { + asyncTable.remove(recordId); + } + } + + return ( + +

{t("drafts")}

+ + ({ + id: record.id, + name: `${record.citizen?.name} ${record.citizen?.surname}`, + updatedAt: {new Date(record.updatedAt)}, + actions: ( + <> + + + + ), + }))} + columns={[ + { header: "Name", accessorKey: "name" }, + { header: "Last updated", accessorKey: "updatedAt" }, + { header: "actions", accessorKey: "actions" }, + ]} + /> + + ); +} diff --git a/packages/schemas/src/records.ts b/packages/schemas/src/records.ts index 9a5abbaa2..3755522a2 100644 --- a/packages/schemas/src/records.ts +++ b/packages/schemas/src/records.ts @@ -19,8 +19,10 @@ export const SEIZED_ITEM_SCHEMA = z.object({ }); const recordTypeRegex = /ARREST_REPORT|TICKET|WRITTEN_WARNING/; +const publishStatus = /DRAFT|PUBLISHED/; export const CREATE_TICKET_SCHEMA = z.object({ type: z.string().min(2).max(255).regex(recordTypeRegex), + publishStatus: z.string().regex(publishStatus).nullish(), citizenId: z.string().min(2).max(255), violations: z.array(VIOLATION).min(1), seizedItems: z.array(SEIZED_ITEM_SCHEMA).optional(), diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 158b073d5..031b86323 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -356,3 +356,10 @@ export const VehiclePaceType = { } as const; export type VehiclePaceType = (typeof VehiclePaceType)[keyof typeof VehiclePaceType]; + +export const PublishStatus = { + DRAFT: "DRAFT", + PUBLISHED: "PUBLISHED", +} as const; + +export type PublishStatus = (typeof PublishStatus)[keyof typeof PublishStatus]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4ed0fcb7b..980b0478f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -395,6 +395,8 @@ export type Bolo = Prisma.Bolo & { type _Record = Prisma.Record & { officer?: Officer | null; + citizen?: BaseCitizen | null; + business?: Pick | null; violations: Violation[]; seizedItems?: Prisma.SeizedItem[]; courtEntry?: CourtEntry | null; diff --git a/packages/ui/src/components/alert/alert.tsx b/packages/ui/src/components/alert/alert.tsx index bd18e529a..15c14186d 100644 --- a/packages/ui/src/components/alert/alert.tsx +++ b/packages/ui/src/components/alert/alert.tsx @@ -9,6 +9,7 @@ interface AlertProps extends AlertVariantsProps { message?: React.ReactNode; children?: React.ReactNode; className?: string; + icon?: React.ReactNode; } const alertVariants = cva("flex flex-col p-2 px-4 text-black rounded-md shadow border", { @@ -17,6 +18,7 @@ const alertVariants = cva("flex flex-col p-2 px-4 text-black rounded-md shadow b warning: "bg-orange-400 border-orange-500/80", error: "bg-red-400 border-red-500/80", success: "bg-green-400 border-green-500/80", + info: "bg-slate-900 border-slate-500 text-white", }, }, }); @@ -32,13 +34,13 @@ export function Alert(props: AlertProps) { > {props.title ? (
- + {props.icon ?? }
{props.title}
) : null} {props.message ? (
- {!props.title ? : null} + {!props.title ? props.icon ?? : null}

{props.message}

) : null} diff --git a/packages/ui/src/components/tab-list.tsx b/packages/ui/src/components/tab-list.tsx index d3af8446d..35f71b395 100644 --- a/packages/ui/src/components/tab-list.tsx +++ b/packages/ui/src/components/tab-list.tsx @@ -27,6 +27,7 @@ interface Props { children: React.ReactNode; queryState?: boolean; onValueChange?(value: string): void; + activeTab?: string; } export function TabList({ @@ -35,10 +36,11 @@ export function TabList({ defaultValue = tabs[0]?.value, onValueChange, queryState = true, + activeTab: _activeTab, }: Props) { const [titles, setTitles] = React.useState>({}); const router = useRouter(); - const activeTab = router.query.activeTab as string | undefined; + const activeTab = queryState ? (router.query.activeTab as string | undefined) : _activeTab; function upsertTabTitle(value: string, name?: string) { if (!name) return; @@ -62,6 +64,7 @@ export function TabList({ return (