From 89527036b903d8b71d001a51aaca91a20f3e6ab3 Mon Sep 17 00:00:00 2001 From: minbros Date: Sat, 10 Oct 2026 16:20:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(expenses):=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EB=B9=84=EC=9A=A9=20=EB=AA=A8=EB=8B=AC=EC=97=90=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EC=99=80=20=ED=98=B8=EB=B2=84=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../expenses/ExpenseDialog.drag.test.tsx | 2 +- .../expenses/ExpenseNameFallback.test.tsx | 2 +- .../expenses/ExpenseProvider.test.tsx | 77 +++++++++++++++++++ src/components/expenses/ExpenseProvider.tsx | 3 +- src/components/expenses/ExpenseScopePanel.tsx | 75 +++++++++++++++--- 5 files changed, 147 insertions(+), 12 deletions(-) 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..c61b2ba9 100644 --- a/src/components/expenses/ExpenseNameFallback.test.tsx +++ b/src/components/expenses/ExpenseNameFallback.test.tsx @@ -28,7 +28,7 @@ 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(["식비"]); diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx index 916b7695..ce5c486b 100644 --- a/src/components/expenses/ExpenseProvider.test.tsx +++ b/src/components/expenses/ExpenseProvider.test.tsx @@ -874,3 +874,80 @@ it("never reports a foreground load during successful tab-return revalidation", expect(statuses).not.toContain("pending"); 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); +}); diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx index e1bb79f0..6b20dcf6 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -324,7 +324,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.tsx b/src/components/expenses/ExpenseScopePanel.tsx index 83cf39d4..beeda985 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 { ChevronRight, 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,9 +40,40 @@ export function ExpenseScopePanel({ busy: boolean; onAdd: () => void; onEdit: (expense: Expense) => void; - onRetry: () => void; + onDelete: (expense: Expense) => Promise; + onRetry: () => void | Promise; onClose: () => void; }>) { + const [selected, setSelected] = useState(null); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(""); + const deleteLock = useRef(false); + const disabled = busy || deleting; + 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); const dialog = useRef(null); const title = useRef(null); @@ -105,7 +137,7 @@ export function ExpenseScopePanel({ {canManage ? ( ) : null} + {error &&

{error}

} + {selected && canManage ? ( +
+

{expenseTitle(selected)} 비용을 삭제할까요?

+

{formatExpenseAmount(selected.totalAmount)} {selected.currency}

+

삭제한 내역은 복구할 수 없어요.

+
+ + +
+
+ ) : null}

내역

{isPending && !scoped.length ? ( ) : isError && !scoped.length ? (

비용 조회에 실패했어요.

- +
) : scoped.length ? (
    {scoped.map((expense) => ( -
  • +
  • + {canManage ? ( + + ) : null}
  • ))}
From a2b47418c4e1dd06e4550efab27aac16836c7e73 Mon Sep 17 00:00:00 2001 From: minbros Date: Sat, 10 Oct 2026 17:58:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(expenses):=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=82=AD=EC=A0=9C=20=ED=9D=90=EB=A6=84?= =?UTF-8?q?=EA=B3=BC=20=EC=95=A1=EC=85=98=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제 확인창을 분리하고 취소 후 포커스 복원 - 수정·삭제 아이콘과 호버 스타일 통일 - 비용 추가 버튼을 목록 하단으로 이동 --- .../expenses/ExpenseNameFallback.test.tsx | 2 +- .../expenses/ExpenseScopePanel.test.tsx | 84 +++++++++ src/components/expenses/ExpenseScopePanel.tsx | 159 ++++++++++++------ 3 files changed, 197 insertions(+), 48 deletions(-) create mode 100644 src/components/expenses/ExpenseScopePanel.test.tsx diff --git a/src/components/expenses/ExpenseNameFallback.test.tsx b/src/components/expenses/ExpenseNameFallback.test.tsx index c61b2ba9..d2938af1 100644 --- a/src/components/expenses/ExpenseNameFallback.test.tsx +++ b/src/components/expenses/ExpenseNameFallback.test.tsx @@ -31,7 +31,7 @@ it("keeps scoped expense titles and edit labels category-only, with memo and pla 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/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 beeda985..aeaa9dc6 100644 --- a/src/components/expenses/ExpenseScopePanel.tsx +++ b/src/components/expenses/ExpenseScopePanel.tsx @@ -4,7 +4,7 @@ import { useSheetDrag } from "@/components/mobile/useSheetDrag"; import { BottomSheetDragHandle } from "@/components/mobile/BottomSheetDragHandle"; import { expenseTitle } from "@/lib/expenses/expense-name"; -import { ChevronRight, Plus, Trash2, X } from "lucide-react"; +import { Pencil, Plus, Trash2, X } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; import { ExpenseApiError, type Expense } from "@/lib/api/rooms/expenses"; @@ -49,6 +49,7 @@ export function ExpenseScopePanel({ 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; @@ -74,7 +75,7 @@ export function ExpenseScopePanel({ deleteLock.current = false; } } - const sheetDrag = useSheetDrag(onClose); + const sheetDrag = useSheetDrag(onClose, disabled || confirming); const dialog = useRef(null); const title = useRef(null); const titleId = useId(); @@ -99,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" > @@ -119,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" >