diff --git a/src/components/expenses/ExpenseDialog.drag.test.tsx b/src/components/expenses/ExpenseDialog.drag.test.tsx index 0b94dfd3..531b36bd 100644 --- a/src/components/expenses/ExpenseDialog.drag.test.tsx +++ b/src/components/expenses/ExpenseDialog.drag.test.tsx @@ -116,7 +116,7 @@ function mountScope() { act(() => root.render( {}} onEdit={() => {}} onRetry={() => {}} onClose={close} + onAdd={() => {}} onEdit={() => {}} onDelete={async () => {}} onRetry={() => {}} onClose={close} />)); } it("dismisses the day/place list sheet by dragging its header", () => { diff --git a/src/components/expenses/ExpenseNameFallback.test.tsx b/src/components/expenses/ExpenseNameFallback.test.tsx index f190963c..d2938af1 100644 --- a/src/components/expenses/ExpenseNameFallback.test.tsx +++ b/src/components/expenses/ExpenseNameFallback.test.tsx @@ -28,10 +28,10 @@ it.each([26, null])("uses category only in ledger titles regardless of linked pl expect(JSON.stringify(renderer.toJSON())).toContain("별도 메모"); }); it("keeps scoped expense titles and edit labels category-only, with memo and place separate", async () => { - await act(async () => { renderer = create(); }); + await act(async () => { renderer = create( {}} onRetry={vi.fn()} onClose={vi.fn()} />); }); const edit = renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 수정"))!; expect(edit.props["aria-label"]).toBe("식비 1,000 KRW 비용 수정"); - expect(edit.findAllByType("span")[1].children).toEqual(["식비"]); + expect(renderer.root.findByType("li").findAllByType("span").some(span => span.children.length === 1 && span.children[0] === "식비")).toBe(true); expect(JSON.stringify(renderer.toJSON())).toContain("별도 메모"); expect(JSON.stringify(renderer.toJSON())).toContain("경복궁"); }); diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx index b72227b4..89e72750 100644 --- a/src/components/expenses/ExpenseProvider.test.tsx +++ b/src/components/expenses/ExpenseProvider.test.tsx @@ -893,6 +893,83 @@ it("never reports a foreground load during successful tab-return revalidation", expect(context.syncStatus).toBe("ready"); }); + +it("confirms a scoped expense deletion, supports cancel, and updates the card total", async () => { + const linked = { ...record, scheduleId: 2, scheduleItemId: 3 }; + await mountPlaceExpenseButton([linked]); + mocks.remove.mockReset(); + mocks.remove.mockResolvedValue(undefined); + mocks.list.mockResolvedValue([]); + await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} })); + const deleteButton = () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!; + expect(deleteButton()).toBeDefined(); + await act(async () => deleteButton().props.onClick()); + expect(mocks.remove).not.toHaveBeenCalled(); + await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 취소" }).props.onClick()); + expect(mocks.remove).not.toHaveBeenCalled(); + await act(async () => deleteButton().props.onClick()); + await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick()); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(mocks.remove).toHaveBeenCalledExactlyOnceWith("r", 10, 2); + expect(renderer.root.findByType(ExpenseScopePanel).findAllByType("li")).toHaveLength(0); + expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0); + expect(renderer.root.findByProps({ "aria-label": "장소 비용 추가" })).toBeDefined(); +}); + + +it("keeps a scoped expense after a failed delete and prevents duplicate submissions", async () => { + await mountPlaceExpenseButton([{ ...record, scheduleId: 2, scheduleItemId: 3 }]); + mocks.remove.mockReset(); + let reject!: (reason: Error) => void; + mocks.remove.mockImplementationOnce(() => new Promise((_, fail) => { reject = fail; })); + await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} })); + const button = renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제")); + expect(button).toBeDefined(); + await act(async () => button!.props.onClick()); + const confirm = renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }); + await act(async () => { confirm.props.onClick(); confirm.props.onClick(); }); + expect(mocks.remove).toHaveBeenCalledTimes(1); + expect(renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.disabled).toBe(true); + await act(async () => reject(new Error("네트워크 오류"))); + expect(renderer.root.findByProps({ role: "alert" }).children.join("")).toContain("네트워크 오류"); + expect(renderer.root.findByType(ExpenseScopePanel).findAllByType("li")).toHaveLength(1); +}); + + +it("requires reviewing the refreshed scoped expense before deleting after a conflict", async () => { + const { ExpenseApiError } = await import("@/lib/api/rooms/expenses"); + const linked = { ...record, scheduleId: 2, scheduleItemId: 3 }; + await mountPlaceExpenseButton([linked]); + mocks.remove.mockReset(); + mocks.remove.mockRejectedValueOnce(new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed")); + mocks.list.mockResolvedValue([{ ...linked, version: 3, totalAmount: "200" }]); + await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} })); + const deleteButton = () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!; + await act(async () => deleteButton().props.onClick()); + await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick()); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(mocks.remove).toHaveBeenCalledTimes(1); + expect(renderer.root.findAllByProps({ "aria-label": "비용 삭제 확인" })).toHaveLength(0); + expect(deleteButton().props["aria-label"]).toContain("200 KRW"); + mocks.remove.mockResolvedValue(undefined); + mocks.list.mockResolvedValue([]); + await act(async () => deleteButton().props.onClick()); + await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick()); + expect(mocks.remove).toHaveBeenLastCalledWith("r", 10, 3); +}); + +it("hides scoped deletion controls when management permission is lost", async () => { + await mountPlaceExpenseButton([{ ...record, scheduleId: 2, scheduleItemId: 3 }]); + await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} })); + await act(async () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!.props.onClick()); + await act(async () => { + client.setQueryData(expenseKeys.members("r"), { members: [] }); + await new Promise(resolve => setTimeout(resolve, 20)); + }); + expect(renderer.root.findAllByProps({ "aria-label": "비용 삭제 확인" })).toHaveLength(0); + expect(renderer.root.findAllByType("button").filter(b => b.props["aria-label"]?.endsWith("비용 삭제"))).toHaveLength(0); +}); + it("emits no expense or budget analytics while pending, after rejected writes, or blocked duplicate writes", async () => { await mountMutations(); let reject!: (error: Error) => void; diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx index de03fe32..a95fd16b 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -392,7 +392,8 @@ function ExpenseProviderLifetime({ ? { scheduleId: scope.scheduleId } : { scheduleId: scope.scheduleId, scheduleItemId: scope.scheduleItemId })} onEdit={(expense) => setEntry({ expense })} - onRetry={() => void state.list.refetch()} + onDelete={state.remove} + onRetry={() => state.list.refetch({ throwOnError: true })} onClose={() => setScope(null)} /> )} diff --git a/src/components/expenses/ExpenseScopePanel.test.tsx b/src/components/expenses/ExpenseScopePanel.test.tsx new file mode 100644 index 00000000..3f86aa20 --- /dev/null +++ b/src/components/expenses/ExpenseScopePanel.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Expense } from "@/lib/api/rooms/expenses"; +import { ExpenseScopePanel } from "./ExpenseScopePanel"; + +const expense: Expense = { + id: 10, version: 2, expenseGroup: "TRIP_DAY", scheduleId: 2, scheduleItemId: null, + currency: "USD", totalAmount: "50", category: "OTHER", memo: null, name: "교통", + payerUserIds: [1], participantUserIds: [1], createdAt: "", updatedAt: "", +}; +let root: Root; +let host: HTMLDivElement; +const close = vi.fn(); +const remove = vi.fn<() => Promise>(); +function Harness({ canManage = true }: { canManage?: boolean }) { + const [expenses, setExpenses] = useState([expense]); + return {}} onEdit={() => {}} + onDelete={async () => { await remove(); setExpenses([]); }} onRetry={() => {}} onClose={close} />; +} +const button = (label: string) => host.querySelector(`[aria-label="${label}"]`)!; +const confirmation = () => host.querySelector('dialog[role="alertdialog"]'); +async function openDelete() { + const trigger = host.querySelector('[aria-label$="USD 비용 삭제"]')!; + trigger.focus(); + await act(async () => trigger.click()); + return trigger; +} +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + HTMLDialogElement.prototype.showModal = function () { this.open = true; }; + HTMLDialogElement.prototype.close = function () { this.open = false; }; + close.mockReset(); remove.mockReset(); remove.mockResolvedValue(undefined); + host = document.createElement("div"); document.body.append(host); root = createRoot(host); + await act(async () => root.render()); +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); }); + +it("opens a separate modal and restores the delete trigger after cancel", async () => { + const trigger = await openDelete(); + expect(confirmation()?.open).toBe(true); + expect(document.activeElement).toBe(button("비용 삭제 취소")); + await act(async () => button("비용 삭제 취소").click()); + expect(confirmation()).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(remove).not.toHaveBeenCalled(); +}); + +it("Escape cancels only the confirmation and preserves the expense list", async () => { + const trigger = await openDelete(); + expect(confirmation()).not.toBeNull(); + await act(async () => confirmation()!.dispatchEvent(new Event("cancel", { bubbles: true, cancelable: true }))); + expect(close).not.toHaveBeenCalled(); + expect(confirmation()).toBeNull(); + expect(document.activeElement).toBe(trigger); +}); + +it("blocks dismissal during deletion and restores focus to the list heading after success", async () => { + let finish!: () => void; + remove.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await openDelete(); + expect(confirmation()).not.toBeNull(); + await act(async () => button("비용 삭제 확인").click()); + await act(async () => confirmation()!.dispatchEvent(new Event("cancel", { bubbles: true, cancelable: true }))); + expect(confirmation()?.open).toBe(true); + expect(button("비용 삭제 취소").disabled).toBe(true); + expect(close).not.toHaveBeenCalled(); + await act(async () => finish()); + expect(confirmation()).toBeNull(); + expect(document.activeElement).toBe(host.querySelector("h2")); + expect(host.querySelectorAll("li")).toHaveLength(0); +}); + + +it("allows closing the list after management permission is lost during confirmation", async () => { + await openDelete(); + await act(async () => root.render()); + expect(confirmation()).toBeNull(); + await act(async () => host.querySelector("dialog")!.dispatchEvent(new Event("cancel", { cancelable: true }))); + expect(close).toHaveBeenCalledOnce(); + expect(remove).not.toHaveBeenCalled(); +}); diff --git a/src/components/expenses/ExpenseScopePanel.tsx b/src/components/expenses/ExpenseScopePanel.tsx index 83cf39d4..aeaa9dc6 100644 --- a/src/components/expenses/ExpenseScopePanel.tsx +++ b/src/components/expenses/ExpenseScopePanel.tsx @@ -4,10 +4,10 @@ import { useSheetDrag } from "@/components/mobile/useSheetDrag"; import { BottomSheetDragHandle } from "@/components/mobile/BottomSheetDragHandle"; import { expenseTitle } from "@/lib/expenses/expense-name"; -import { ChevronRight, Plus, X } from "lucide-react"; -import { useEffect, useId, useRef } from "react"; +import { Pencil, Plus, Trash2, X } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; -import { type Expense } from "@/lib/api/rooms/expenses"; +import { ExpenseApiError, type Expense } from "@/lib/api/rooms/expenses"; import { expensesInScope, totalsByCurrency, @@ -27,6 +27,7 @@ export function ExpenseScopePanel({ busy, onAdd, onEdit, + onDelete, onRetry, onClose, }: Readonly<{ @@ -39,10 +40,42 @@ export function ExpenseScopePanel({ busy: boolean; onAdd: () => void; onEdit: (expense: Expense) => void; - onRetry: () => void; + onDelete: (expense: Expense) => Promise; + onRetry: () => void | Promise; onClose: () => void; }>) { - const sheetDrag = useSheetDrag(onClose); + const [selected, setSelected] = useState(null); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(""); + const deleteLock = useRef(false); + const disabled = busy || deleting; + const confirming = selected !== null && canManage; + async function confirmDelete() { + if (!selected || !canManage || busy || deleteLock.current) return; + deleteLock.current = true; + setDeleting(true); + setError(""); + try { + await onDelete(selected); + } catch (error) { + if (error instanceof ExpenseApiError && + (error.code === "EXPENSE_CONFLICT" || error.code === "EXPENSE_NOT_FOUND")) { + try { + await onRetry(); + setError("비용이 변경되었거나 삭제되었어요. 최신 내역을 확인한 뒤 다시 선택해 주세요."); + } catch { + setError("최신 비용 조회에 실패했어요. 다시 시도해 주세요."); + } + } else { + setError(error instanceof Error ? error.message : "비용 삭제에 실패했어요."); + } + } finally { + setSelected(null); + setDeleting(false); + deleteLock.current = false; + } + } + const sheetDrag = useSheetDrag(onClose, disabled || confirming); const dialog = useRef(null); const title = useRef(null); const titleId = useId(); @@ -67,7 +100,7 @@ export function ExpenseScopePanel({ aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); - onClose(); + if (!disabled && !confirming) onClose(); }} className="fixed inset-auto left-1/2 top-1/2 m-0 max-h-[85dvh] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl border border-gray-border bg-white p-0 text-text shadow-2xl backdrop:bg-black/40 mobile:bottom-0 mobile:left-0 mobile:max-h-[60dvh] mobile:top-auto mobile:w-full mobile:max-w-none mobile:translate-x-0 mobile:translate-y-0 mobile:rounded-b-none mobile:rounded-t-[20px] mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200 mobile:border-x-0 mobile:border-b-0 max-sm:bottom-0 max-sm:left-0 max-sm:top-auto max-sm:w-full max-sm:max-w-none max-sm:max-h-[60dvh] max-sm:translate-x-0 max-sm:translate-y-0 max-sm:rounded-b-none max-sm:rounded-t-[20px] max-sm:border-x-0 max-sm:border-b-0" > @@ -87,6 +120,7 @@ export function ExpenseScopePanel({ type="button" aria-label="비용 목록 닫기" onClick={onClose} + disabled={disabled} className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-dark-gray transition-colors hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-primary" >