Skip to content
Merged
1 change: 0 additions & 1 deletion frontend/src/api/types/dashboardApi.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export type GetCompareLastMonthComparisonResponse =
export type LastMonthComparisonResponse = GetCompareLastMonthComparisonResponse;

export type GetExpenseByPeriodExpense = {
selected?: boolean | null;
expenseId: number;
spentAt: string;
usage: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useRef } from "react";
import type { EditableDataTableProps, ExpenseData } from "@/app/(sidebar)/dashboard/_types";
import type { EditableDataTableProps, EditableExpenseRow } from "@/app/(sidebar)/dashboard/_types";
import { useExpenseTable } from "@/app/(sidebar)/dashboard/_hooks";
import { useInfiniteScroll } from "@/app/(sidebar)/dashboard/_hooks/useInfiniteScroll";
import ClientDataTable from "@/components/ui/DataTable/ClientDataTable";
Expand All @@ -17,6 +17,7 @@ import type { ServerSortField } from "@/api/types/dashboardApi.type";
*/
const EditableDataTable = ({
initialData,
total,
className,
sortConfig,
onSort,
Expand Down Expand Up @@ -48,7 +49,7 @@ const EditableDataTable = ({
handleSave,
hasUnsavedChanges,
selectedCount,
totalExpense,
costDelta,
onCellClick,
onKeyDown,
} = useExpenseTable({
Expand All @@ -69,13 +70,13 @@ const EditableDataTable = ({
// SortConfigV2 → DataTable의 sortConfig 형태로 변환
// ClientDataTable은 공용 컴포넌트이므로 형태를 맞춰줌
const tableSortConfig = sortConfig.map(({ field, order }) => ({
sortBy: field as keyof ExpenseData,
sortBy: field as keyof EditableExpenseRow,
sortOrder: order,
}));

return (
<div className={cn("flex flex-col flex-1 min-h-0", className ?? "")}>
<ClientDataTable<ExpenseData>
<ClientDataTable<EditableExpenseRow>
mode="edit"
columns={columns}
data={sortedRows}
Expand All @@ -84,7 +85,9 @@ const EditableDataTable = ({
selectedCell={selectedCell}
sortConfig={tableSortConfig}
onSort={(accessor) => onSort(accessor as ServerSortField)}
onCellClick={onCellClick}
onCellClick={(rowIndex, accessor) =>
onCellClick(rowIndex, accessor as keyof EditableExpenseRow)
}
onKeyDown={onKeyDown}
scrollContainerRef={scrollContainerRef}
bottomSlot={
Expand Down Expand Up @@ -119,7 +122,7 @@ const EditableDataTable = ({

<div className="sticky bottom-0 shrink-0 -mx-8">
<ExpenseTableToolbar
totalExpense={totalExpense}
totalExpense={total + costDelta}
selectedCount={selectedCount}
hasUnsavedChanges={hasUnsavedChanges}
onSave={handleSave}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,20 @@ const DashboardTable = ({ tableClassName }: DashboardTableProps) => {
const { mainCategoryFilter, handleFilterChange } = useMainCategoryFilter();

// 정렬 파라미터를 데이터 조회에 전달
const { startDate, endDate, expenses, refetch, hasNext, isLoadingMore, loadMore, resetKey } =
useExpensesV2({
toSortParams,
mainCategoryFilter,
});
const {
startDate,
endDate,
expenses,
total,
refetch,
hasNext,
isLoadingMore,
loadMore,
resetKey,
} = useExpensesV2({
toSortParams,
mainCategoryFilter,
});
const setRangeToUrl = useSetRangeToUrl();

return (
Expand All @@ -30,7 +39,8 @@ const DashboardTable = ({ tableClassName }: DashboardTableProps) => {
onRangeChange={(start, end) => setRangeToUrl(start, end)}
/>
<EditableDataTable
initialData={expenses} // 1) 초기 데이터를 테이블에 전달
initialData={expenses} // 0) 초기 데이터를 테이블에 전달
total={total} // 1) 서버에서 계산한 기간 내 총 지출 금액
startDate={startDate} // 2) 시작 날짜를 테이블에 전달
endDate={endDate} // 3) 종료 날짜를 테이블에 전달
className={tableClassName ?? ""} // 4) 테이블 컴포넌트 클래스 전달
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { DataTableColumn } from "@/components/ui/DataTable/dataTable.type";
import type { ExpenseData } from "@/app/(sidebar)/dashboard/_types";
import type { EditableExpenseRow, ExpenseData } from "@/app/(sidebar)/dashboard/_types";
import {
DATE_PICKER_HEIGHT,
DATE_PICKER_GAP,
} from "@/components/common/DatePicker/datePicker.constants";

/** Tab/Enter 네비게이션 순서 (useExpenseTableSelection) */
export const EDITABLE_ACCESSORS: (keyof ExpenseData)[] = [
"selected",
export const EDITABLE_ACCESSORS: (keyof EditableExpenseRow)[] = [
"isSelected",
"spentAt",
"usage",
"cost",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ describe("useExpenseRowsState", () => {
const { result } = renderHook(() => useExpenseRowsState(serverData, 0));

act(() => {
result.current.updateCellByLocalId("exp-1", "selected", true);
result.current.updateCellByLocalId("exp-1", "isSelected", true);
});

const updatedRow = result.current.displayInitialRows.find((r) => r.localId === "exp-1");
expect(updatedRow?.selected).toBe(true);
expect(updatedRow?.isSelected).toBe(true);
expect(updatedRow?.isDirty).toBe(false);
});

Expand Down Expand Up @@ -139,11 +139,11 @@ describe("useExpenseRowsState", () => {
const { result } = renderHook(() => useExpenseRowsState(serverData, 0));

act(() => {
result.current.updateAllCells("selected", true);
result.current.updateAllCells("isSelected", true);
});

const rows = result.current.displayInitialRows.slice(0, -1);
expect(rows.every((r) => r.selected === true)).toBe(true);
expect(rows.every((r) => r.isSelected === true)).toBe(true);
});
});

Expand All @@ -153,7 +153,7 @@ describe("useExpenseRowsState", () => {
const { result } = renderHook(() => useExpenseRowsState(serverData, 0));

act(() => {
result.current.updateAllCells("selected", true);
result.current.updateAllCells("isSelected", true);
result.current.deleteSelectedRows();
});

Expand All @@ -166,7 +166,7 @@ describe("useExpenseRowsState", () => {
const { result } = renderHook(() => useExpenseRowsState(serverData, 0));

act(() => {
result.current.updateCellByLocalId("exp-1", "selected", true);
result.current.updateCellByLocalId("exp-1", "isSelected", true);
result.current.deleteSelectedRows();
});

Expand All @@ -184,7 +184,7 @@ describe("useExpenseRowsState", () => {
const { result } = renderHook(() => useExpenseRowsState(serverData, 0));

act(() => {
result.current.updateAllCells("selected", true);
result.current.updateAllCells("isSelected", true);
result.current.mergeSelectedRows();
});

Expand All @@ -206,7 +206,7 @@ describe("useExpenseRowsState", () => {

act(() => {
result.current.updateCellByLocalId("exp-1", "usage", "수정된 항목");
result.current.updateCellByLocalId("exp-2", "selected", true);
result.current.updateCellByLocalId("exp-2", "isSelected", true);
result.current.deleteSelectedRows();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe("useExpenseTableSelection", () => {
});

expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(result.current.selectedCell).toEqual({ rowIndex: 1, accessor: "selected" });
expect(result.current.selectedCell).toEqual({ rowIndex: 1, accessor: "isSelected" });
});

it("마지막 행 마지막 컬럼에서 Tab을 누르면 이동하지 않는다", () => {
Expand Down Expand Up @@ -183,7 +183,7 @@ describe("useExpenseTableSelection", () => {
});

expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(result.current.selectedCell).toEqual({ rowIndex: 0, accessor: "selected" });
expect(result.current.selectedCell).toEqual({ rowIndex: 0, accessor: "isSelected" });
});

it("유효하지 않은 accessor를 가진 셀에서 Tab을 누르면 변경되지 않는다", () => {
Expand Down Expand Up @@ -280,7 +280,7 @@ describe("useExpenseTableSelection", () => {
const { result } = renderHook(() => useExpenseTableSelectionTestWrapper(1));

act(() => {
result.current.setSelectedCell({ rowIndex: 0, accessor: "selected" });
result.current.setSelectedCell({ rowIndex: 0, accessor: "isSelected" });
});

const mockEvent = createKeyboardEvent("Tab");
Expand Down Expand Up @@ -364,7 +364,7 @@ describe("useExpenseTableSelection", () => {
act(() => {
result.current.handleKeyDown(mockEvent);
});
expect(result.current.selectedCell).toEqual({ rowIndex: 0, accessor: "selected" });
expect(result.current.selectedCell).toEqual({ rowIndex: 0, accessor: "isSelected" });

// 계속 Tab으로 이동
act(() => {
Expand Down Expand Up @@ -396,7 +396,7 @@ describe("useExpenseTableSelection", () => {
act(() => {
result.current.handleKeyDown(mockEvent);
});
expect(result.current.selectedCell).toEqual({ rowIndex: 1, accessor: "selected" });
expect(result.current.selectedCell).toEqual({ rowIndex: 1, accessor: "isSelected" });
});

it("사용자가 컬럼 내에서 Enter로 아래로 이동하며 데이터를 입력한다", () => {
Expand Down

This file was deleted.

37 changes: 30 additions & 7 deletions frontend/src/app/(sidebar)/dashboard/_hooks/useExpenseRowsState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import {
serverToEditableRow,
buildPatchPayload,
mergeSelectedRowsLogic,
calculateTotalExpense,
getExpenseRowKey,
} from "@/app/(sidebar)/dashboard/_lib";

export function useExpenseRowsState(initialData: ExpenseData[], resetKey: number) {
const [rows, setRows] = useState<EditableExpenseRow[]>(() =>
initialData.map(serverToEditableRow),
);
const [costDelta, setCostDelta] = useState(0);

const prevResetKeyRef = useRef(resetKey);

Expand All @@ -34,6 +34,10 @@ export function useExpenseRowsState(initialData: ExpenseData[], resetKey: number
if (newItems.length === 0) return prev;
return [...prev, ...newItems];
});

if (isReset) {
setCostDelta(0);
}
}, [initialData, resetKey]);

const visibleRows = useMemo(() => rows.filter((row) => !row.isDeleted), [rows]);
Expand All @@ -56,6 +60,12 @@ export function useExpenseRowsState(initialData: ExpenseData[], resetKey: number
const row = prev[idx];
if (!row) return prev;

if (accessor === "cost") {
const prevCost = Number(row.cost) || 0;
const newCost = Number(value) || 0;
setCostDelta((d) => d + newCost - prevCost);
}

const isSyncField =
SYNC_FIELDS.includes(accessor as (typeof SYNC_FIELDS)[number]) ||
(subAccessor !== undefined &&
Expand All @@ -72,6 +82,11 @@ export function useExpenseRowsState(initialData: ExpenseData[], resetKey: number
}

// displayInitialRows 맨 아래 빈 행(아직 state에 없음) 편집 시 → 새 행 추가
if (accessor === "cost") {
const newCost = Number(value) || 0;
setCostDelta((d) => d + newCost);
}

return [
...prev,
{
Expand All @@ -96,7 +111,18 @@ export function useExpenseRowsState(initialData: ExpenseData[], resetKey: number

/** 선택된 셀 삭제 핸들러 */
const deleteSelectedRows = useCallback(() => {
setRows((prev) => prev.map((row) => (row.selected ? { ...row, isDeleted: true } : row)));
setRows((prev) => {
let costToSubtract = 0;
const next = prev.map((row) => {
if (row.isSelected && !row.isDeleted) {
costToSubtract += Number(row.cost) || 0;
return { ...row, isDeleted: true };
}
return row;
});
if (costToSubtract !== 0) setCostDelta((d) => d - costToSubtract);
return next;
});
}, []);

/** 선택된 셀 병합 핸들러 */
Expand All @@ -118,13 +144,10 @@ export function useExpenseRowsState(initialData: ExpenseData[], resetKey: number

/** 선택된 셀 개수 체크 핸들러 */
const selectedCount = useMemo(
() => rows.filter((row) => row.selected && !row.isDeleted).length,
() => rows.filter((row) => row.isSelected && !row.isDeleted).length,
[rows],
);

/** 총 소비 금액 계산 */
const totalExpense = useMemo(() => calculateTotalExpense(visibleRows), [visibleRows]);

/** 행 식별 키 (DataTable rowKey prop) */
const rowKey = getExpenseRowKey;

Expand All @@ -138,6 +161,6 @@ export function useExpenseRowsState(initialData: ExpenseData[], resetKey: number
getPatchPayload,
hasUnsavedChanges,
selectedCount,
totalExpense,
costDelta,
};
}
Loading