From 7d3cdc660bfd9fab02ff1073f03b83d30c7f142e Mon Sep 17 00:00:00 2001 From: BradyMitch Date: Wed, 20 May 2026 11:20:38 -0700 Subject: [PATCH] delete counter --- src/app/protected/settings/counters/page.tsx | 2 + .../ConfirmDeleteCounterModal.tsx | 95 ++++++++++++++++++ .../ConfirmDeleteCounterModal/index.ts | 1 + .../counters/CounterTable/CounterTable.tsx | 16 +++ .../EditCounterModal/EditCounterModal.tsx | 37 +++++-- .../useConfirmDeleteCounterModal/index.ts | 1 + .../useConfirmDeleteCounterModal.ts | 68 +++++++++++++ .../useEditCounterModal.ts | 11 +++ src/lib/prisma/counter/deleteCounter.test.ts | 99 +++++++++++++++++++ src/lib/prisma/counter/deleteCounter.ts | 33 +++++++ 10 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 src/components/settings/counters/ConfirmDeleteCounterModal/ConfirmDeleteCounterModal.tsx create mode 100644 src/components/settings/counters/ConfirmDeleteCounterModal/index.ts create mode 100644 src/hooks/settings/counters/useConfirmDeleteCounterModal/index.ts create mode 100644 src/hooks/settings/counters/useConfirmDeleteCounterModal/useConfirmDeleteCounterModal.ts create mode 100644 src/lib/prisma/counter/deleteCounter.test.ts create mode 100644 src/lib/prisma/counter/deleteCounter.ts diff --git a/src/app/protected/settings/counters/page.tsx b/src/app/protected/settings/counters/page.tsx index 49153a4..dcdc136 100644 --- a/src/app/protected/settings/counters/page.tsx +++ b/src/app/protected/settings/counters/page.tsx @@ -1,6 +1,7 @@ import { revalidatePath } from "next/cache" import { headers } from "next/headers" import { CounterTable } from "@/components/settings/counters/CounterTable" +import { deleteCounter } from "@/lib/prisma/counter/deleteCounter" import { getAllCounters } from "@/lib/prisma/counter/getAllCounters" import { insertCounter } from "@/lib/prisma/counter/insertCounter" import { updateCounter } from "@/lib/prisma/counter/updateCounter" @@ -37,6 +38,7 @@ export default async function Page() { staffUsers={staffUsers} updateCounter={updateCounter} insertCounter={insertCounter} + deleteCounter={deleteCounter} revalidateTable={revalidateTable} /> diff --git a/src/components/settings/counters/ConfirmDeleteCounterModal/ConfirmDeleteCounterModal.tsx b/src/components/settings/counters/ConfirmDeleteCounterModal/ConfirmDeleteCounterModal.tsx new file mode 100644 index 0000000..c7dff65 --- /dev/null +++ b/src/components/settings/counters/ConfirmDeleteCounterModal/ConfirmDeleteCounterModal.tsx @@ -0,0 +1,95 @@ +"use client" + +import { + CloseButton, + DialogActions, + DialogBody, + DialogHeader, + DialogTitle, + Modal, +} from "@/components/common/dialog" +import { useConfirmDeleteCounterModal } from "@/hooks/settings/counters/useConfirmDeleteCounterModal" +import type { CounterWithRelations } from "@/lib/prisma/counter/types" + +type ConfirmDeleteCounterModalProps = { + open: boolean + onClose: () => void + counter: CounterWithRelations | null + deleteCounter: (id: string) => Promise + revalidateTable: () => Promise +} + +export const ConfirmDeleteCounterModal = ({ + open, + onClose, + counter, + deleteCounter, + revalidateTable, +}: ConfirmDeleteCounterModalProps) => { + const { error, deleteConfirmation, setDeleteConfirmation, isDeleteDisabled, handleDelete } = + useConfirmDeleteCounterModal({ + open, + onClose, + counter, + deleteCounter, + revalidateTable, + }) + + if (!counter) return null + + return ( + + } className="bg-background-danger"> + Delete Counter + + + +
+ {error && ( +
+

{error}

+
+ )} + +

+ All users assigned to this counter will be set to the default counter. +

+ +
+ + setDeleteConfirmation(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !isDeleteDisabled) handleDelete() + }} + autoComplete="off" + className="mt-2 block w-full rounded-md border border-border-dark px-2 py-1 text-xs text-typography-primary" + /> +
+
+
+ + + + + +
+ ) +} diff --git a/src/components/settings/counters/ConfirmDeleteCounterModal/index.ts b/src/components/settings/counters/ConfirmDeleteCounterModal/index.ts new file mode 100644 index 0000000..58f45e0 --- /dev/null +++ b/src/components/settings/counters/ConfirmDeleteCounterModal/index.ts @@ -0,0 +1 @@ +export * from "./ConfirmDeleteCounterModal" diff --git a/src/components/settings/counters/CounterTable/CounterTable.tsx b/src/components/settings/counters/CounterTable/CounterTable.tsx index 141137f..7e16382 100644 --- a/src/components/settings/counters/CounterTable/CounterTable.tsx +++ b/src/components/settings/counters/CounterTable/CounterTable.tsx @@ -5,6 +5,7 @@ import { useCounterTable } from "@/hooks/settings/counters/useCounterTable" import type { CounterWithRelations } from "@/lib/prisma/counter/types" import type { LocationWithRelations } from "@/lib/prisma/location/types" import type { StaffUserWithRelations } from "@/lib/prisma/staff_user/types" +import { ConfirmDeleteCounterModal } from "../ConfirmDeleteCounterModal" import { CreateCounterModal } from "../CreateCounterModal" import { EditCounterModal } from "../EditCounterModal" import { columns } from "./columns" @@ -19,6 +20,7 @@ export type CounterTableProps = { prevCounter: Partial ) => Promise insertCounter: (counter: Partial) => Promise + deleteCounter: (id: string) => Promise revalidateTable: () => Promise } @@ -29,12 +31,14 @@ export const CounterTable = ({ staffUsers, updateCounter, insertCounter, + deleteCounter, revalidateTable, }: CounterTableProps) => { const { selectedCounter, canCreate, canEditSelectedCounter, + canDeleteSelectedCounter, countersToShow, handleRowClick, editCounterModalOpen, @@ -42,6 +46,9 @@ export const CounterTable = ({ createCounterModalOpen, openCreateCounterModal, closeCreateCounterModal, + deleteCounterModalOpen, + openDeleteCounterModal, + closeDeleteCounterModal, } = useCounterTable({ currentUser, counters, @@ -79,8 +86,10 @@ export const CounterTable = ({ locations={locations} staffUsers={staffUsers} canEdit={canEditSelectedCounter} + canDelete={canDeleteSelectedCounter} updateCounter={updateCounter} revalidateTable={revalidateTable} + openConfirmDeleteCounterModal={openDeleteCounterModal} /> + ) } diff --git a/src/components/settings/counters/EditCounterModal/EditCounterModal.tsx b/src/components/settings/counters/EditCounterModal/EditCounterModal.tsx index 8ebf53a..01e9173 100644 --- a/src/components/settings/counters/EditCounterModal/EditCounterModal.tsx +++ b/src/components/settings/counters/EditCounterModal/EditCounterModal.tsx @@ -21,11 +21,13 @@ type EditCounterModalProps = { locations: LocationWithRelations[] staffUsers: StaffUserWithRelations[] canEdit: boolean + canDelete: boolean updateCounter: ( counter: Partial, prevCounter: Partial ) => Promise revalidateTable: () => Promise + openConfirmDeleteCounterModal: () => void } export const EditCounterModal = ({ @@ -35,18 +37,30 @@ export const EditCounterModal = ({ locations, staffUsers, canEdit, + canDelete, updateCounter, revalidateTable, + openConfirmDeleteCounterModal, }: EditCounterModalProps) => { - const { isSaving, error, formData, setFormData, isReadonly, isSaveDisabled, handleSave } = - useEditCounterModal({ - open, - onClose, - counter, - canEdit, - updateCounter, - revalidateTable, - }) + const { + isSaving, + error, + formData, + setFormData, + isReadonly, + isSaveDisabled, + handleSave, + handleOpenDelete, + } = useEditCounterModal({ + open, + onClose, + counter, + canEdit, + canDelete, + updateCounter, + revalidateTable, + openConfirmDeleteCounterModal, + }) if (!counter || !formData) return null @@ -88,6 +102,11 @@ export const EditCounterModal = ({ + {canDelete && ( + + )} diff --git a/src/hooks/settings/counters/useConfirmDeleteCounterModal/index.ts b/src/hooks/settings/counters/useConfirmDeleteCounterModal/index.ts new file mode 100644 index 0000000..8a17c5a --- /dev/null +++ b/src/hooks/settings/counters/useConfirmDeleteCounterModal/index.ts @@ -0,0 +1 @@ +export { useConfirmDeleteCounterModal } from "./useConfirmDeleteCounterModal" diff --git a/src/hooks/settings/counters/useConfirmDeleteCounterModal/useConfirmDeleteCounterModal.ts b/src/hooks/settings/counters/useConfirmDeleteCounterModal/useConfirmDeleteCounterModal.ts new file mode 100644 index 0000000..d77bcfb --- /dev/null +++ b/src/hooks/settings/counters/useConfirmDeleteCounterModal/useConfirmDeleteCounterModal.ts @@ -0,0 +1,68 @@ +import { useRouter } from "next/navigation" +import { useEffect, useState } from "react" +import type { CounterWithRelations } from "@/lib/prisma/counter/types" + +type UseConfirmDeleteCounterModalProps = { + open: boolean + onClose: () => void + counter: CounterWithRelations | null + deleteCounter: (id: string) => Promise + revalidateTable: () => Promise +} + +/** + * Custom hook encapsulating all logic for the ConfirmDeleteCounterModal component. + * + * @param props - Hook configuration. + * @property props.open - Whether the modal is open. + * @property props.onClose - Callback to close the modal. + * @property props.counter - The counter to delete. + * @property props.deleteCounter - Async function to delete the counter. + * @property props.revalidateTable - Async function to refresh the table. + * @returns State, derived flags, and handlers for the modal. + */ +export const useConfirmDeleteCounterModal = ({ + open, + onClose, + counter, + deleteCounter, + revalidateTable, +}: UseConfirmDeleteCounterModalProps) => { + const router = useRouter() + const [error, setError] = useState(null) + const [deleteConfirmation, setDeleteConfirmation] = useState("") + + useEffect(() => { + if (open) { + setDeleteConfirmation("") + setError(null) + } + }, [open]) + + const handleDelete = async () => { + if (!counter) return + try { + await deleteCounter(counter.id) + await revalidateTable() + setDeleteConfirmation("") + onClose() + router.refresh() + } catch (e: unknown) { + if (e instanceof Error) { + setError(e.message) + } else { + setError("An unknown error occurred") + } + } + } + + const isDeleteDisabled = deleteConfirmation !== counter?.name + + return { + error, + deleteConfirmation, + setDeleteConfirmation, + isDeleteDisabled, + handleDelete, + } +} diff --git a/src/hooks/settings/counters/useEditCounterModal/useEditCounterModal.ts b/src/hooks/settings/counters/useEditCounterModal/useEditCounterModal.ts index 9db9f2b..98c654e 100644 --- a/src/hooks/settings/counters/useEditCounterModal/useEditCounterModal.ts +++ b/src/hooks/settings/counters/useEditCounterModal/useEditCounterModal.ts @@ -8,11 +8,13 @@ type UseEditCounterModalProps = { onClose: () => void counter: CounterWithRelations | null canEdit: boolean + canDelete: boolean updateCounter: ( counter: Partial, prevCounter: Partial ) => Promise revalidateTable: () => Promise + openConfirmDeleteCounterModal: () => void } const EditCounterSchema = z.object({ @@ -38,8 +40,10 @@ export const useEditCounterModal = ({ onClose, counter, canEdit, + canDelete, updateCounter, revalidateTable, + openConfirmDeleteCounterModal, }: UseEditCounterModalProps) => { const router = useRouter() const [isSaving, setIsSaving] = useState(false) @@ -92,6 +96,11 @@ export const useEditCounterModal = ({ const isSaveDisabled = isReadonly || isSaving || !isFormValid || !hasMadeChanges + const handleOpenDelete = () => { + openConfirmDeleteCounterModal() + onClose() + } + return { isSaving, error, @@ -99,6 +108,8 @@ export const useEditCounterModal = ({ setFormData, isReadonly, isSaveDisabled, + canDelete, handleSave, + handleOpenDelete, } } diff --git a/src/lib/prisma/counter/deleteCounter.test.ts b/src/lib/prisma/counter/deleteCounter.test.ts new file mode 100644 index 0000000..7b99d89 --- /dev/null +++ b/src/lib/prisma/counter/deleteCounter.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { prisma } from "@/utils/db/prisma" +import { deleteCounter } from "./deleteCounter" + +vi.mock("@/utils/db/prisma", () => ({ + prisma: { + counter: { + findUnique: vi.fn(), + delete: vi.fn(), + }, + staffUser: { + updateMany: vi.fn(), + }, + }, +})) + +describe("deleteCounter", () => { + const mockCounter = { + id: "COUNTER-001", + name: "My Counter", + createdAt: new Date(), + updatedAt: new Date(), + } + + const mockDefaultCounter = { + id: "DEFAULT-COUNTER", + name: "Counter", + createdAt: new Date(), + updatedAt: new Date(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns false when the counter does not exist", async () => { + vi.mocked(prisma.counter.findUnique).mockResolvedValueOnce(null) + + const result = await deleteCounter("COUNTER-001") + + expect(result).toBe(false) + expect(prisma.staffUser.updateMany).not.toHaveBeenCalled() + expect(prisma.counter.delete).not.toHaveBeenCalled() + }) + + it("reassigns staff users to the default counter and deletes the counter", async () => { + vi.mocked(prisma.counter.findUnique) + .mockResolvedValueOnce(mockCounter) + .mockResolvedValueOnce(mockDefaultCounter) + vi.mocked(prisma.staffUser.updateMany).mockResolvedValueOnce({ count: 2 }) + vi.mocked(prisma.counter.delete).mockResolvedValueOnce(mockCounter) + + const result = await deleteCounter("COUNTER-001") + + expect(result).toBe(true) + expect(prisma.staffUser.updateMany).toHaveBeenCalledWith({ + where: { counterId: "COUNTER-001" }, + data: { counterId: "DEFAULT-COUNTER" }, + }) + expect(prisma.counter.delete).toHaveBeenCalledWith({ where: { id: "COUNTER-001" } }) + }) + + it("sets counterId to null when the default counter does not exist", async () => { + vi.mocked(prisma.counter.findUnique) + .mockResolvedValueOnce(mockCounter) + .mockResolvedValueOnce(null) + vi.mocked(prisma.staffUser.updateMany).mockResolvedValueOnce({ count: 1 }) + vi.mocked(prisma.counter.delete).mockResolvedValueOnce(mockCounter) + + await deleteCounter("COUNTER-001") + + expect(prisma.staffUser.updateMany).toHaveBeenCalledWith({ + where: { counterId: "COUNTER-001" }, + data: { counterId: null }, + }) + }) + + it("proceeds with deletion even when no staff users are assigned", async () => { + vi.mocked(prisma.counter.findUnique) + .mockResolvedValueOnce(mockCounter) + .mockResolvedValueOnce(mockDefaultCounter) + vi.mocked(prisma.staffUser.updateMany).mockResolvedValueOnce({ count: 0 }) + vi.mocked(prisma.counter.delete).mockResolvedValueOnce(mockCounter) + + const result = await deleteCounter("COUNTER-001") + + expect(result).toBe(true) + expect(prisma.counter.delete).toHaveBeenCalledWith({ where: { id: "COUNTER-001" } }) + }) + + it("propagates database errors", async () => { + vi.mocked(prisma.counter.findUnique).mockResolvedValueOnce(mockCounter) + vi.mocked(prisma.counter.findUnique).mockResolvedValueOnce(mockDefaultCounter) + vi.mocked(prisma.staffUser.updateMany).mockResolvedValueOnce({ count: 0 }) + vi.mocked(prisma.counter.delete).mockRejectedValueOnce(new Error("Database connection failed")) + + await expect(deleteCounter("COUNTER-001")).rejects.toThrow("Database connection failed") + }) +}) diff --git a/src/lib/prisma/counter/deleteCounter.ts b/src/lib/prisma/counter/deleteCounter.ts new file mode 100644 index 0000000..fc2d0a5 --- /dev/null +++ b/src/lib/prisma/counter/deleteCounter.ts @@ -0,0 +1,33 @@ +"use server" + +import { prisma } from "@/utils/db/prisma" + +const DEFAULT_COUNTER_NAME = "Counter" + +/** + * Function to delete a counter from the database. + * + * Before deletion, any staff users assigned to this counter are moved to the + * default "Counter" counter (or have their counterId set to null if the default + * counter cannot be found). + * + * @param id The id of the counter to delete + * @returns Promise resolving to true if the counter was deleted, false if not found + */ +export const deleteCounter = async (id: string): Promise => { + const counter = await prisma.counter.findUnique({ where: { id } }) + if (!counter) return false + + const defaultCounter = await prisma.counter.findUnique({ + where: { name: DEFAULT_COUNTER_NAME }, + }) + + // Reassign all staff users of this counter to the default counter before deletion + await prisma.staffUser.updateMany({ + where: { counterId: id }, + data: { counterId: defaultCounter?.id ?? null }, + }) + + await prisma.counter.delete({ where: { id } }) + return true +}