- {memoTrigger}
- {timeTrigger}
- {scheduleTimeEdit && scheduleItemId !== null && (
-
}
- />
- )}
+ // 디자인: 구분선 위아래 8px, 버튼 아래 카드 테두리까지 5px
+
+
+
+ {timeTrigger}
+ {scheduleTimeEdit && scheduleItemId !== null && (
+ }
+ />
+ )}
+
+ {memoTrigger}
+
) : null}
diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCardParts.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCardParts.tsx
index 01156097..de0de2c1 100644
--- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCardParts.tsx
+++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCardParts.tsx
@@ -1,5 +1,4 @@
-import { Trash2 } from "lucide-react";
-
+import { TrashIcon } from "@/assets/icons";
import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens";
import { cn } from "@/lib/utils";
@@ -71,11 +70,7 @@ export function PlanScheduleItemDeleteButton({
onDelete();
}}
>
-
+
);
}
diff --git a/src/lib/layout-tokens.ts b/src/lib/layout-tokens.ts
index 1253a15b..ddb33c1e 100644
--- a/src/lib/layout-tokens.ts
+++ b/src/lib/layout-tokens.ts
@@ -30,29 +30,31 @@ export const PLAN_PLACE_CARD_MIN_SIZE_REM = "6rem" as const;
*/
export const PLAN_PLACE_CARD_TW = {
article:
- "relative grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3.5 rounded-xl border border-gray-border bg-white p-3.5 shadow-sm mobile:gap-x-2.5 mobile:p-2.5",
+ "relative grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3 rounded-xl border border-gray-border bg-white p-3.5 shadow-sm mobile:gap-x-2.5 mobile:p-2.5",
thumbnail:
"relative h-[6rem] w-[6rem] shrink-0 overflow-hidden rounded-lg bg-fill-strong mobile:h-[4.5rem] mobile:w-[4.5rem]",
+ /** 장소명·카테고리는 위, 주소는 썸네일 아래 끝에 맞춘다 */
contentColumn:
- "flex min-h-[6rem] min-w-0 flex-col justify-start gap-2 mobile:min-h-[4.5rem]",
+ "flex min-h-[6rem] min-w-0 flex-col justify-between gap-2 pb-1 mobile:min-h-[4.5rem]",
titleRow: "flex min-w-0 items-start gap-2.5",
- subtitle: "text-body-xs-regular leading-snug text-dark-gray/85",
+ subtitle: "truncate text-caption-l-regular text-text-subtle",
controlsStack: "flex min-w-0 flex-col gap-0.5",
orderBadgeCompact:
"absolute -left-2 -top-2 z-10 h-8 w-8 rounded-lg border-2 border-white text-body-s-regular mobile:text-body-xs-regular shadow-md",
titleCompact: "text-body-m-emphasis mobile:text-body-s-emphasis font-semibold leading-snug text-gray-900",
- titleClamp: "line-clamp-2",
- subtitleClamp: "line-clamp-2",
+ title: "truncate text-body-l-emphasis mobile:text-body-s-emphasis text-text",
primaryTypeBadge:
- "shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-body-xs-emphasis font-medium leading-none text-dark-gray",
+ "w-fit max-w-full shrink-0 truncate rounded-full bg-fill px-1.5 py-0.5 text-body-xs-regular text-text-subtle",
deleteButtonCompact: "p-1 @min-[370px]/plan:p-1.5",
- deleteIconCompact: "h-5 w-5",
- triggerRow:
- "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1",
- triggerButton:
- "flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-label-m-regular mobile:text-label-s-regular font-medium text-dark-gray/85 transition hover:bg-sky-50 hover:text-sky-600",
- triggerButtonActive: "text-sky-600 font-semibold",
- triggerIcon: "h-4 w-4 shrink-0",
+ deleteIconSize: 16,
+ /** 카드 아래 시간·비용(왼쪽)과 메모(오른쪽) 버튼 줄 — 버튼 배경이 카드 안쪽 여백보다 바깥에 오도록 좌우를 넓힌다 */
+ actionBar: "-mx-1.5 flex min-w-0 items-center justify-between gap-2",
+ /** 디자인 시스템 Button Base(sm) — 버튼 좌우 여백 10, 아이콘 16, 라벨(`span`) 좌우 여백 6 */
+ actionButton:
+ "flex h-9 shrink-0 cursor-pointer items-center rounded-md bg-fill-subtle px-2.5 text-body-s-emphasis text-text transition-colors hover:bg-fill disabled:cursor-not-allowed disabled:opacity-50 [&>span]:px-1.5",
+ /** 메모 버튼만 Button Base 좌우 여백이 4다(디자인) */
+ actionButtonTrailing: "px-1",
+ actionIcon: "shrink-0 text-primary",
editorModule:
"relative flex w-full items-start gap-3 rounded-2xl bg-gray-100/70 px-4 py-3 text-body-s-regular mobile:text-body-xs-regular text-gray-900",
editorSideLabelWrapper:
From c50b514fd4af804e467398e76487f76c5847f079 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”>
Date: Mon, 12 Oct 2026 22:36:55 +0900
Subject: [PATCH 05/11] =?UTF-8?q?feat(plan):=EC=9D=BC=EC=B0=A8=20=ED=97=A4?=
=?UTF-8?q?=EB=8D=94=C2=B7=EB=B9=84=EC=9A=A9=20=EC=A4=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../_components/itinerary/PlanDaySection.tsx | 37 ++++++++++---------
.../_components/itinerary/PlanPageView.tsx | 1 +
.../itinerary/PlanScheduleDayBlock.tsx | 12 ++++--
src/lib/plan/scheduleMerge.ts | 2 +
src/lib/plan/tripRange.ts | 7 ++++
src/lib/plan/types.ts | 3 ++
6 files changed, 40 insertions(+), 22 deletions(-)
diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx
index e72ba82d..b13f43ff 100644
--- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx
+++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx
@@ -170,7 +170,7 @@ export function PlanDaySection({
+
+
+ {subtitle || title}
+
+ {subtitle ? (
+ // 디자인 시스템 Badge / Weak / Brand / md — 배경색은 시맨틱 토큰이 없어 원시 토큰을 쓴다
+
+ {title}
+
+ ) : null}
+
{expanded ? (
@@ -215,18 +227,6 @@ export function PlanDaySection({
)}
-
-
-
- {subtitle || title}
-
- {subtitle ? (
-
- {title}
-
- ) : null}
-
-
{showScheduleMenu ? (
@@ -315,7 +315,7 @@ export function PlanDaySection({
{summaryAction || trackedSid !== undefined ? (
{summaryAction}
@@ -328,7 +328,7 @@ export function PlanDaySection({
toggleRouteVisible();
}}
aria-checked={routeVisible}
- className="flex min-h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 text-label-s-regular text-dark-gray transition-colors hover:bg-bubble-gray/60 focus-visible:outline-2 focus-visible:outline-primary"
+ className="flex min-h-8 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 text-label-xs-regular text-text-subtle transition-colors hover:bg-bubble-gray/60 focus-visible:outline-2 focus-visible:outline-primary"
>
지도에 경로 표시
handleDeleteScheduleDay(dayIndex)
diff --git a/src/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock.tsx b/src/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock.tsx
index ec6da677..ffb08212 100644
--- a/src/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock.tsx
+++ b/src/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock.tsx
@@ -1,7 +1,7 @@
"use client";
+import { CoinIcon } from "@/assets/icons";
import { ExpenseEntryButton, useExpenseContext } from "@/components/expenses/ExpenseProvider";
-import { ExpenseIcon } from "@/components/icons/ExpenseIcon";
import type { CSSProperties, Ref } from "react";
@@ -18,7 +18,10 @@ type PlanScheduleDayBlockProps = {
roomId: string;
scheduleId: number;
title: string;
+ /** 가계부 패널 부제목에 쓰는 날짜 — `10월 12일 (월)` */
subtitle?: string;
+ /** 일차 헤더에 보이는 날짜 — `10.12(월)` */
+ dateHeading?: string;
onRequestDeleteSchedule?: () => void;
onRequestInsertScheduleAfter?: () => void;
isScheduleMenuDisabled?: boolean;
@@ -33,6 +36,7 @@ export function PlanScheduleDayBlock({
scheduleId,
title,
subtitle,
+ dateHeading,
onRequestDeleteSchedule,
onRequestInsertScheduleAfter,
isScheduleMenuDisabled = false,
@@ -55,15 +59,15 @@ export function PlanScheduleDayBlock({
return (
}
+ className="inline-flex min-h-8 max-w-full cursor-pointer items-center gap-1 rounded-lg py-1 pl-[3px] pr-1 text-body-s-emphasis text-primary transition-colors enabled:hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary"
+ icon={}
/>
) : undefined}
onRequestAddExpense={expenses.canManage ? () => expenses.open({ scheduleId }) : undefined}
diff --git a/src/lib/plan/scheduleMerge.ts b/src/lib/plan/scheduleMerge.ts
index 35f3a9b0..66de0599 100644
--- a/src/lib/plan/scheduleMerge.ts
+++ b/src/lib/plan/scheduleMerge.ts
@@ -3,6 +3,7 @@ import type { PlanDayData } from "@/lib/plan/types";
import {
formatKoreanDateLabel,
+ formatShortDateLabel,
parseLocalYmd,
} from "@/lib/plan/tripRange";
@@ -23,6 +24,7 @@ export function mergeSchedulesWithPlaces(schedules: RoomSchedule[]): PlanDayData
id: `schedule-${s.scheduleId}`,
dayLabel: `${i + 1}일차`,
dateLabel: formatKoreanDateLabel(parseLocalYmd(s.date)),
+ shortDateLabel: formatShortDateLabel(parseLocalYmd(s.date)),
places: [],
}));
}
diff --git a/src/lib/plan/tripRange.ts b/src/lib/plan/tripRange.ts
index acd2c46e..764d58a8 100644
--- a/src/lib/plan/tripRange.ts
+++ b/src/lib/plan/tripRange.ts
@@ -35,6 +35,13 @@ export function formatKoreanDateLabel(d: Date): string {
return `${d.getMonth() + 1}월 ${d.getDate()}일 (${w})`;
}
+/** 일정 일차 헤더용 짧은 날짜 — `10.12(월)` */
+export function formatShortDateLabel(d: Date): string {
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${m}.${day}(${WEEKDAYS_KO[d.getDay()]})`;
+}
+
function formatYmdKoShort(ymd: string): string {
const d = parseLocalYmd(ymd);
return `${d.getMonth() + 1}월 ${d.getDate()}일`;
diff --git a/src/lib/plan/types.ts b/src/lib/plan/types.ts
index f5573e59..7ea2c1ac 100644
--- a/src/lib/plan/types.ts
+++ b/src/lib/plan/types.ts
@@ -26,6 +26,9 @@ export type PlanPlace = {
export type PlanDayData = {
id: string;
dayLabel: string;
+ /** `10월 12일 (월)` — 가계부 패널 부제목 등 */
dateLabel: string;
+ /** `10.12(월)` — 일차 헤더 */
+ shortDateLabel: string;
places: PlanPlace[];
};
From f2ae1428f3d61f3ce7c7de8db53d802e9c0983a8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”>
Date: Mon, 12 Oct 2026 22:51:22 +0900
Subject: [PATCH 06/11] =?UTF-8?q?feat(plan):=20Google=20=EA=B8=B8=EC=B0=BE?=
=?UTF-8?q?=EA=B8=B0=20=EB=A7=81=ED=81=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../plan/_components/itinerary/PlanItinerary.tsx | 2 ++
.../plan/_components/travel-time/PlanTravelTime.tsx | 1 +
.../_components/travel-time/TravelDirectionsCard.tsx | 12 ++++++++++--
3 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx
index 916356f0..43e1b919 100644
--- a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx
+++ b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx
@@ -259,6 +259,8 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) {
>
{
setDirectionsHidden(true);
diff --git a/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx b/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx
index a1cba967..aed19eef 100644
--- a/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx
+++ b/src/app/(main)/plan/_components/travel-time/TravelDirectionsCard.tsx
@@ -43,6 +43,8 @@ export type TravelDirectionsCardProps = {
/** 지도 polyline을 만들 수 없을 때 요약 우측에 표시 */
renderWarning?: string;
googleMapsDirectionsUrl?: string;
+ /** PC 일정 — 구간마다 반복되는 길찾기 링크가 튀지 않게 회색 12px로 낮춘다 */
+ subtleDirectionsLink?: boolean;
/** true면 목록은 참고용(클릭 선택 불가), `onSelectTravelMode` 미사용 */
readOnly?: boolean;
onSelectTravelMode?: (next: ScheduleTravelModeValue) => void;
@@ -63,6 +65,7 @@ export function TravelDirectionsCard({
modeRaw,
renderWarning,
googleMapsDirectionsUrl,
+ subtleDirectionsLink = false,
readOnly = false,
onSelectTravelMode,
onHideDirections,
@@ -109,10 +112,15 @@ export function TravelDirectionsCard({
target="_blank"
rel="noopener noreferrer"
aria-label="Google Maps에서 길찾기"
- className="inline-flex h-10 shrink-0 items-center gap-1 px-2 text-label-xs-regular font-medium text-primary-strong underline-offset-2 hover:underline"
+ className={cn(
+ "inline-flex h-10 shrink-0 items-center gap-1 underline-offset-2 hover:underline",
+ subtleDirectionsLink
+ ? "px-1 text-body-xs-regular text-text-subtle"
+ : "px-2 text-label-xs-regular font-medium text-primary-strong",
+ )}
>
Google 길찾기
-
+
) : null}
From 5ea899d13af990e06d4e1479e8b0171e5197a71c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”>
Date: Mon, 12 Oct 2026 22:52:34 +0900
Subject: [PATCH 07/11] =?UTF-8?q?feat(plan):=20=ED=8E=98=EC=9D=B4=EC=A7=80?=
=?UTF-8?q?=20=EC=97=AC=EB=B0=B1=C2=B7=EC=84=A4=EB=AA=85=20=EB=AC=B8?=
=?UTF-8?q?=EA=B5=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../(main)/plan/_components/itinerary/PlanPageView.tsx | 9 ++++++++-
src/components/layout/MainContentScrollArea.tsx | 9 +++++++--
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx
index 4b2e0cc8..91ec25a4 100644
--- a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx
+++ b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx
@@ -169,7 +169,14 @@ export function PlanPageView() {
const pageContentClassName =
"@container/plan space-y-2.5 overflow-x-auto pb-8";
- const pageHeader = ;
+ // 제목과 첫 일차 사이는 20px(목록 간격 10px + 아래 여백 10px)
+ const pageHeader = (
+
+ );
const deleteDayDialog = dayToDelete ? (
(null);
const searchPage = isSearchPath(pathname);
const ledgerPage = pathname === "/cost" && !fill;
+ // 일정은 디자인 여백(위 14 · 왼쪽 20 · 오른쪽은 스크롤바 포함 24)에 맞춰 스크롤바 자리를 오른쪽에만 둔다
+ const planPage = isPlanPath(pathname) && !fill;
useEffect(() => {
scrollRef.current?.scrollTo(0, 0);
@@ -41,7 +44,7 @@ export function MainContentScrollArea({
data-main-content-scroll
className={cn(
"min-h-0 min-w-0 flex-1",
- !fill && !ledgerPage && "pt-2.5",
+ !fill && !ledgerPage && (planPage ? "pt-3.5" : "pt-2.5"),
ledgerPage && "bg-fill-subtle mobile:bg-fill max-sm:bg-fill",
fill
? "flex flex-col overflow-hidden"
@@ -50,6 +53,7 @@ export function MainContentScrollArea({
: cn(
"overflow-x-hidden overflow-y-auto",
MAIN_SCROLLBAR_GUTTER_CLASS,
+ planPage && "[scrollbar-gutter:stable]",
),
)}
>
@@ -57,7 +61,8 @@ export function MainContentScrollArea({
className={cn(
"min-w-0",
(fill || searchPage) && "flex min-h-0 flex-1 flex-col",
- !fill && !searchPage && !ledgerPage && MAIN_PAGE_INLINE_PADDING_CLASS,
+ !fill && !searchPage && !ledgerPage && !planPage && MAIN_PAGE_INLINE_PADDING_CLASS,
+ planPage && "pl-5 pr-2.5",
ledgerPage && "p-8 mobile:p-5 max-sm:p-5",
)}
>
From d5fa35ce8ab909e4259245dcc1fe6c92321af0d2 Mon Sep 17 00:00:00 2001
From: minbros
Date: Tue, 13 Oct 2026 23:46:45 +0900
Subject: [PATCH 08/11] =?UTF-8?q?fix(plan):=20=EC=9E=A5=EC=86=8C=20?=
=?UTF-8?q?=EC=B9=B4=EB=93=9C=EC=9D=98=20=EC=9B=90=ED=86=B5=ED=99=94=20?=
=?UTF-8?q?=EB=B9=84=EC=9A=A9=20=EC=9A=94=EC=95=BD=20=EB=B3=B5=EC=9B=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- PC·모바일 일정과 경로 카드에 대표 통화 및 외 지출 건수 표시
- 일차 원화 요약 유지 및 외화·0원·목록 열기 회귀 검증
---
.../_components/useMobileRouteView.test.tsx | 85 ++++++++++++++++---
.../map/_components/useMobileRouteView.tsx | 4 +-
.../mobile/MobilePlanItinerary.tsx | 4 +-
.../mobile/MobilePlanPlaceCard.tsx | 5 +-
.../expenses/ExpenseProvider.test.tsx | 23 +++++
src/components/expenses/ExpenseProvider.tsx | 8 +-
src/lib/plan/mobilePlanFormat.test.ts | 22 ++++-
src/lib/plan/mobilePlanFormat.ts | 28 +++++-
8 files changed, 157 insertions(+), 22 deletions(-)
diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx
index 996ec086..7468b689 100644
--- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx
+++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
-import { act, useEffect } from "react";
+import { act, useEffect, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { expect, it, vi } from "vitest";
@@ -10,6 +10,9 @@ const fixtures = vi.hoisted(() => ({
{ id: "item-101", itemId: 101, title: "첫 장소", location: { lat: 37.5, lng: 127 } },
{ id: "item-102", itemId: 102, title: "먼 장소", location: { lat: 37.7, lng: 127.2 } },
],
+ expenses: [] as import("@/lib/api/rooms/expenses").Expense[],
+ open: vi.fn(),
+ openScope: vi.fn(),
setSelectedPlace: vi.fn(),
router: { replace: vi.fn() },
}));
@@ -22,7 +25,7 @@ vi.mock("@/hooks/useRooms", () => ({
}));
vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true }));
vi.mock("@/components/expenses/ExpenseProvider", () => ({
- useExpenseContext: () => ({ list: { data: [] }, canManage: true }),
+ useExpenseContext: () => ({ list: { data: fixtures.expenses }, canManage: true, open: fixtures.open, openScope: fixtures.openScope }),
}));
vi.mock("@/contexts/SelectedPlaceContext", () => ({
useSelectedPlace: () => ({ setSelectedPlace: fixtures.setSelectedPlace }),
@@ -32,20 +35,17 @@ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ readSchedulePlanPlacesFromCach
vi.mock("@/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch", () => ({
useMobileAddPlaceSearch: () => ({ recentlyAddedItemId: null, screen: null }),
}));
-vi.mock("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets", () => ({
+vi.mock("@/app/(main)/plan/_components/mobile/MobilePlanPlaceSheets", async (importOriginal) => ({
+ ...await importOriginal(),
MobilePlanPlaceSheets: () => null,
- openPlaceExpenses: vi.fn(),
}));
vi.mock("@/components/map/MapCloseButton", () => ({ MapCloseButton: () => null }));
vi.mock("./MobileRouteCandidateCard", () => ({ MobileRouteCandidateCard: () => null }));
-vi.mock("./MobileRouteCardCarousel", () => ({ MobileRouteCardCarousel: () => null }));
-vi.mock("./MobileRoutePlaceDetail", () => ({ MobileRoutePlaceDetail: () => null }));
-vi.mock("./MobileRoutePlaceCard", () => ({
- MobileRouteEmptyDayCard: () => null,
- MobileRouteMessageCard: () => null,
- MobileRoutePlaceCard: () => null,
- MobileRoutePlaceCardSkeleton: () => null,
+vi.mock("./MobileRouteCardCarousel", () => ({
+ MobileRouteCardCarousel: ({ cards }: { cards: { key: string; node: ReactNode }[] }) => <>{cards.map(card => )}>,
}));
+vi.mock("./MobileRoutePlaceDetail", () => ({ MobileRoutePlaceDetail: () => null }));
+
import { readMapRouteParams } from "@/lib/mobile-view";
import { scheduleItemsQueryKey } from "@/lib/query-keys";
@@ -85,3 +85,66 @@ it.each([
host.remove();
}
});
+
+
+import { MobilePlanItinerary } from "@/app/(main)/plan/_components/mobile/MobilePlanItinerary";
+import { MobilePlanDragProvider } from "@/app/(main)/plan/_components/mobile/mobilePlanDrag";
+vi.mock("@/app/(main)/plan/_components/travel-time/PlanTravelTime", () => ({ PlanTravelTime: () => null }));
+
+it.each([
+ { rows: [["KRW", "1000"]], label: "1,000 KRW" },
+ { rows: [["JPY", "3600"]], label: "3,600 JPY" },
+ { rows: [["USD", "10"], ["USD", "20"], ["KRW", "1000"]], label: "30 USD 외 1건" },
+ { rows: [["USD", "10.10"], ["USD", "20.20"]], label: "30.30 USD" },
+ { rows: [["JPY", "0"]], label: "0 JPY" },
+ { rows: [["USD", "12345678901234567890.12"], ["EUR", "1"]], label: "12,345,678,901,234,567,890.12 USD 외 1건" },
+ { rows: [], label: null },
+].flatMap(testCase => ["itinerary", "route"].map(mode => ({ ...testCase, mode }))))("$mode shows $label with scoped list/empty-add behavior", async ({ rows, label, mode }) => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTo");
+ Object.defineProperty(HTMLElement.prototype, "scrollTo", { configurable: true, value: vi.fn() });
+ fixtures.expenses = rows.map(([currency, totalAmount], id) => ({
+ id, version: 1, expenseGroup: "TRIP_DAY", scheduleId: 10, scheduleItemId: 101,
+ currency, totalAmount, category: "OTHER", memo: null, name: null,
+ payerUserIds: [1], participantUserIds: [1], createdAt: "", updatedAt: "",
+ }));
+ // Another place must not affect either summary or empty/add decisions.
+ fixtures.expenses.push({ id: 100, version: 1, expenseGroup: "TRIP_DAY", scheduleId: 10, scheduleItemId: 102, currency: "KRW", totalAmount: "999999", category: "OTHER", memo: null, name: null, payerUserIds: [1], participantUserIds: [1], createdAt: "", updatedAt: "" });
+ fixtures.open.mockClear();
+ fixtures.openScope.mockClear();
+ window.history.replaceState(null, "", "/map?view=route&day=1&item=101");
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), fixtures.places);
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ function Route() { return useMobileRouteView({ active: true, day: 1, initialItemId: 101 }).overlay; }
+ const drag = { editing: false, dragDisabled: false, pending: null, dropTargetScheduleId: null,
+ registerList: vi.fn(), registerRow: vi.fn(), registerPlaces: vi.fn(), startDrag: vi.fn(), rowStyle: () => undefined };
+ try {
+ await act(async () => root.render(
+ {mode === "route" ? : }
+ ));
+ const button = host.querySelector(`button[aria-label="첫 장소 비용 ${label ? "보기" : "추가"}"]`);
+ if (label) {
+ expect(button?.textContent).toContain(label);
+ expect(button?.getAttribute("aria-description")).toBe(label);
+ await act(async () => button!.click());
+ expect(fixtures.openScope).toHaveBeenLastCalledWith({ scheduleId: 10, scheduleItemId: 101, label: "첫 장소" });
+ expect(fixtures.open).not.toHaveBeenCalled();
+ } else if (mode === "route") {
+ expect(button).not.toBeNull();
+ await act(async () => button!.click());
+ expect(fixtures.open).toHaveBeenCalledWith({ scheduleId: 10, scheduleItemId: 101 });
+ expect(fixtures.openScope).not.toHaveBeenCalled();
+ } else {
+ // Itinerary keeps empty expense rows hidden; its action sheet owns adding.
+ expect(button).toBeNull();
+ }
+ } finally {
+ await act(async () => root.unmount());
+ queryClient.clear(); host.remove(); fixtures.expenses = [];
+ if (originalScrollTo) Object.defineProperty(HTMLElement.prototype, "scrollTo", originalScrollTo);
+ else Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
+ }
+});
diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx
index b4f32d23..51e30d53 100644
--- a/src/app/(main)/map/_components/useMobileRouteView.tsx
+++ b/src/app/(main)/map/_components/useMobileRouteView.tsx
@@ -16,7 +16,7 @@ import { expensesInScope } from "@/lib/expenses/expense-scope";
import { normalizeGooglePlaceResourceId } from "@/lib/maps";
import { buildMapRouteHref, MOBILE_MAP_PATH } from "@/lib/mobile-view";
import { insertAnchorAfterItem } from "@/lib/plan/insertPosition";
-import { formatMonthDayKo, summarizeExpensesInKrw } from "@/lib/plan/mobilePlanFormat";
+import { formatMonthDayKo, summarizePlaceExpenses } from "@/lib/plan/mobilePlanFormat";
import {
scheduleIdsToRouteColors,
sortedScheduleIdsForRouteColors,
@@ -401,7 +401,7 @@ export function useMobileRouteView({
orderNumber={entry.orderNumber}
badgeColor={dayColor(entry.scheduleId)}
highlighted={addPlace.recentlyAddedItemId === entry.itemId}
- expenseSummary={summarizeExpensesInKrw(itemExpenses(entry.scheduleId, entry.itemId))}
+ expenseSummary={summarizePlaceExpenses(itemExpenses(entry.scheduleId, entry.itemId))}
canAddExpense={expenses.canManage}
expanded={isExpanded}
onExpand={() => {
diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx
index 52e247ba..0b8d765c 100644
--- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx
+++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx
@@ -13,7 +13,7 @@ import { useRoomSchedules, useSchedulePlanPlaces } from "@/hooks/useRooms";
import { expensesInScope } from "@/lib/expenses/expense-scope";
import { buildMapRouteHref } from "@/lib/mobile-view";
import type { InsertAnchor } from "@/lib/plan/insertPosition";
-import { summarizeExpensesInKrw } from "@/lib/plan/mobilePlanFormat";
+import { summarizePlaceExpenses } from "@/lib/plan/mobilePlanFormat";
import { planCopy } from "@/lib/plan/planCopy";
import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage";
import {
@@ -151,7 +151,7 @@ export function MobilePlanItinerary({
orderNumber={index + 1}
badgeColor={badgeColor}
highlighted={recentlyAddedItemId !== null && place.itemId === recentlyAddedItemId}
- expenseSummary={summarizeExpensesInKrw(itemExpenses(place.itemId))}
+ expenseSummary={summarizePlaceExpenses(itemExpenses(place.itemId))}
onOpen={() => openPlace(place)}
onOpenActions={() => setSheet({ kind: "actions", place })}
onOpenExpenses={() => openPlaceExpenses(expenses, scheduleId, place)}
diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
index 1027671b..a3bd063d 100644
--- a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
+++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
@@ -188,10 +188,11 @@ function PlaceTimeAndExpense({ place, expenseSummary, addWhenEmpty, onEditTime,
onOpenExpenses();
}}
aria-label={`${place.title} 비용 보기`}
- className="pointer-events-auto flex min-w-0 items-center gap-1 text-body-s-regular tabular-nums text-text-subtle"
+ aria-description={expenseSummary}
+ className="pointer-events-auto flex min-w-0 max-w-full items-center gap-1 text-body-s-regular tabular-nums text-text-subtle"
>
- {expenseSummary}
+ {expenseSummary}
) : null}
{!expenseSummary && addExpense ? (
diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx
index 555ddc78..737082af 100644
--- a/src/components/expenses/ExpenseProvider.test.tsx
+++ b/src/components/expenses/ExpenseProvider.test.tsx
@@ -1110,3 +1110,26 @@ it.each(["account", "logout", "room", "room-loss", "account-roundtrip", "room-ro
]);
}
});
+
+
+it.each([
+ { rows: [["KRW", "1000"]], label: "1,000 KRW" },
+ { rows: [["JPY", "3600"]], label: "3,600 JPY" },
+ { rows: [["USD", "10"], ["USD", "20"], ["KRW", "1000"]], label: "30 USD 외 1건" },
+ { rows: [["JPY", "0"]], label: "0 JPY" },
+])("place summary $label opens the complete scoped original-currency list", async ({ rows, label }) => {
+ const records = rows.map(([currency, totalAmount], id) => ({ ...record, id, currency, totalAmount, scheduleId: 2, scheduleItemId: 3 }));
+ await mountPlaceExpenseButton([...records, { ...record, id: 100, scheduleId: 9, scheduleItemId: 3 }]);
+ const button = renderer.root.findByType("button");
+ expect(button.findByType("span").children.join("")).toBe(label);
+ expect(button.props["aria-label"]).toBe("장소 비용 목록 열기");
+ expect(button.props["aria-description"]).toBe(label);
+ await act(async () => button.props.onClick({ stopPropagation() {} }));
+ expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0);
+ const panel = renderer.root.findByType(ExpenseScopePanel);
+ expect(panel.findAllByType("li")).toHaveLength(rows.length);
+ for (const { currency, totalAmount } of records) {
+ expect(textOf(panel)).toContain(currency);
+ expect(panel.props.expenses).toContainEqual(expect.objectContaining({ currency, totalAmount }));
+ }
+});
diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx
index df6e05b0..b3fcd322 100644
--- a/src/components/expenses/ExpenseProvider.tsx
+++ b/src/components/expenses/ExpenseProvider.tsx
@@ -35,7 +35,7 @@ import { canManageExpenses } from "@/lib/expenses/expense-policy";
import { getExpenseRecoveryStore } from "@/lib/expenses/expense-recovery";
import { expenseKeys } from "@/lib/expenses/expense-queries";
import { expensesInScope, type ExpenseScope } from "@/lib/expenses/expense-scope";
-import { summarizeExpensesInKrw } from "@/lib/plan/mobilePlanFormat";
+import { summarizeExpensesInKrw, summarizePlaceExpenses } from "@/lib/plan/mobilePlanFormat";
import { useSessionUser } from "@/hooks/useSessionUser";
import { useRoomSchedules } from "@/hooks/useRooms";
import { useExpenseRecovery } from "@/hooks/useExpenseRecovery";
@@ -433,15 +433,17 @@ export function ExpenseEntryButton({
if (!context?.canManage) return null;
const scope = { scheduleId, scheduleItemId, label: scopeLabel, subtitle: scopeSubtitle };
const scoped = expensesInScope(context.list.data ?? [], scope);
+ const summarize = scheduleItemId === undefined ? summarizeExpensesInKrw : summarizePlaceExpenses;
const summaryLabel = !context.list.isSuccess
? "비용 보기"
- : (summarizeExpensesInKrw(scoped) ?? label);
+ : (summarize(scoped) ?? label);
return (
);
}
diff --git a/src/lib/plan/mobilePlanFormat.test.ts b/src/lib/plan/mobilePlanFormat.test.ts
index ca41908d..442d59f3 100644
--- a/src/lib/plan/mobilePlanFormat.test.ts
+++ b/src/lib/plan/mobilePlanFormat.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { Expense } from "@/lib/api/rooms/expenses";
-import { formatMobileDayDate, formatMonthDayKo, summarizeExpensesInKrw } from "./mobilePlanFormat";
+import { formatMobileDayDate, formatMonthDayKo, summarizeExpensesInKrw, summarizePlaceExpenses } from "./mobilePlanFormat";
function expense(currency: string, totalAmount: string, id: number): Expense {
return { id, currency, totalAmount } as Expense;
@@ -50,3 +50,23 @@ describe("summarizeExpensesInKrw", () => {
expect(summarizeExpensesInKrw([expense("JPY", "3600", 1)])).toBeNull();
});
});
+
+
+describe("summarizePlaceExpenses", () => {
+ it.each([
+ { name: "empty", rows: [], want: null },
+ { name: "single KRW", rows: [["KRW", "1000"]], want: "1,000 KRW" },
+ { name: "single foreign", rows: [["JPY", "3600"]], want: "3,600 JPY" },
+ { name: "same currency", rows: [["USD", "10.10"], ["USD", "20.20"]], want: "30.30 USD" },
+ { name: "most entries, not largest amount", rows: [["KRW", "1000000"], ["USD", "10"], ["USD", "20"]], want: "30 USD 외 1건" },
+ { name: "tie uses first occurrence; suffix counts entries not currencies", rows: [["USD", "10"], ["EUR", "20"], ["EUR", "30"], ["USD", "5"]], want: "15 USD 외 2건" },
+ { name: "zero is an expense", rows: [["JPY", "0"]], want: "0 JPY" },
+ { name: "zero entries participate in counts", rows: [["USD", "0"], ["USD", "0"], ["KRW", "1000"]], want: "0 USD 외 1건" },
+ { name: "large precise amount", rows: [["USD", "12345678901234567890.12"], ["USD", "0.01"]], want: "12,345,678,901,234,567,890.13 USD" },
+ ])("$name", ({ rows, want }) => {
+ const records = rows.map(([currency, amount], index) => expense(currency, amount, index));
+ const before = structuredClone(records);
+ expect(summarizePlaceExpenses(records)).toBe(want);
+ expect(records).toEqual(before);
+ });
+});
diff --git a/src/lib/plan/mobilePlanFormat.ts b/src/lib/plan/mobilePlanFormat.ts
index fe014305..19538193 100644
--- a/src/lib/plan/mobilePlanFormat.ts
+++ b/src/lib/plan/mobilePlanFormat.ts
@@ -22,10 +22,36 @@ export function formatMonthDayKo(ymd: string): string {
}
/**
- * 일정 비용 요약(PC·모바일 일정 카드와 일차) — 원화(KRW) 합계 하나만 보인다(예: `130,200 KRW`).
+ * 일차 비용 요약(PC·모바일) — 원화(KRW) 합계 하나만 보인다(예: `130,200 KRW`).
* 다른 통화 비용은 가계부에서 본다. 원화 비용이 없으면 null.
*/
export function summarizeExpensesInKrw(expenses: readonly Expense[]): string | null {
const krw = totalsByCurrency(expenses).find((total) => total.currency === "KRW");
return krw ? `${formatExpenseAmount(krw.amount)} KRW` : null;
}
+
+/**
+ * 장소 비용 요약 — 가장 많은 지출 건수의 통화를 원금액으로 합산한다.
+ * 동률이면 처음 등장한 통화, `외 N건`은 나머지 통화의 지출 건수다.
+ * 예: `30 USD 외 1건`. 0원도 지출이며, 빈 목록만 null이다.
+ */
+export function summarizePlaceExpenses(expenses: readonly Expense[]): string | null {
+ if (expenses.length === 0) return null;
+ const countByCurrency = new Map();
+ for (const expense of expenses) {
+ countByCurrency.set(expense.currency, (countByCurrency.get(expense.currency) ?? 0) + 1);
+ }
+ const totals = totalsByCurrency(expenses);
+ const [first, ...rest] = totals;
+ if (!first) return null;
+ // 건수가 같으면 먼저 나온 통화를 쓴다(totalsByCurrency는 등장 순서를 유지)
+ const primary = rest.reduce((best, current) =>
+ (countByCurrency.get(current.currency) ?? 0) > (countByCurrency.get(best.currency) ?? 0)
+ ? current
+ : best,
+ first,
+ );
+ const others = expenses.length - (countByCurrency.get(primary.currency) ?? 0);
+ const label = `${formatExpenseAmount(primary.amount)} ${primary.currency}`;
+ return others > 0 ? `${label} 외 ${others}건` : label;
+}
From a5fce3dc7d9dc5add826d070de05f45476c1ebef Mon Sep 17 00:00:00 2001
From: minbros
Date: Tue, 13 Oct 2026 23:48:53 +0900
Subject: [PATCH 09/11] =?UTF-8?q?docs(plan):=20=EC=9E=A5=EC=86=8C=20?=
=?UTF-8?q?=EB=B9=84=EC=9A=A9=20=EC=9A=94=EC=95=BD=20=EC=86=8D=EC=84=B1=20?=
=?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=A0=95=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/(main)/map/_components/MobileRoutePlaceCard.tsx | 2 +-
src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
index 5d0dc7fc..98122c1d 100644
--- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
+++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx
@@ -19,7 +19,7 @@ type MobileRoutePlaceCardProps = Readonly<{
badgeColor: string;
/** 방금 추가된 카드 — 잠깐 강조한다 */
highlighted: boolean;
- /** 원화 합계 `45,000 KRW` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */
+ /** 대표 통화 원금액 요약 `30 USD 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */
expenseSummary: string | null;
/** 비용을 추가할 수 있는지(비용 권한) */
canAddExpense: boolean;
diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
index a3bd063d..800a8dcd 100644
--- a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
+++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
@@ -22,7 +22,7 @@ type MobilePlanPlaceCardProps = Readonly<{
badgeColor: string;
/** 방금 추가된 카드 — 잠깐 강조한다 */
highlighted?: boolean;
- /** 원화 합계 `45,000 KRW` — 없으면 비용 줄을 숨긴다 */
+ /** 대표 통화 원금액 요약 `30 USD 외 1건` — 없으면 비용 줄을 숨긴다 */
expenseSummary: string | null;
/** 카드 본문 — 없으면 본문을 눌러도 아무 일도 없다(예: 경로 보기에서 펼친 카드) */
onOpen?: () => void;
From 42e629d5b3e863bea32696e754b16765e3e6eb65 Mon Sep 17 00:00:00 2001
From: minbros
Date: Tue, 13 Oct 2026 23:58:27 +0900
Subject: [PATCH 10/11] =?UTF-8?q?fix(plan):=20=EB=B9=84=EC=9A=A9=20?=
=?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9D=98=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?=
=?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=97=B0=EA=B2=B0=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/(main)/map/_components/useMobileRouteView.test.tsx | 6 +++++-
.../(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx | 7 ++++---
src/components/expenses/ExpenseProvider.test.tsx | 4 +++-
src/components/expenses/ExpenseProvider.tsx | 6 ++++--
4 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx
index 7468b689..3db17c64 100644
--- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx
+++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx
@@ -128,7 +128,11 @@ it.each([
const button = host.querySelector(`button[aria-label="첫 장소 비용 ${label ? "보기" : "추가"}"]`);
if (label) {
expect(button?.textContent).toContain(label);
- expect(button?.getAttribute("aria-description")).toBe(label);
+ const descriptionId = button?.getAttribute("aria-describedby");
+ expect(descriptionId).toBeTruthy();
+ expect(document.getElementById(descriptionId!)?.textContent).toBe(label);
+ const expenseButtons = [...host.querySelectorAll("button[aria-describedby]")];
+ expect(new Set(expenseButtons.map(node => node.getAttribute("aria-describedby"))).size).toBe(expenseButtons.length);
await act(async () => button!.click());
expect(fixtures.openScope).toHaveBeenLastCalledWith({ scheduleId: 10, scheduleItemId: 101, label: "첫 장소" });
expect(fixtures.open).not.toHaveBeenCalled();
diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
index 800a8dcd..6bbba996 100644
--- a/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
+++ b/src/app/(main)/plan/_components/mobile/MobilePlanPlaceCard.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, type PointerEvent as ReactPointerEvent } from "react";
+import { useId, useState, type PointerEvent as ReactPointerEvent } from "react";
import {
ChevronDownIcon,
@@ -141,6 +141,7 @@ export function MobilePlanPlaceCard({
function PlaceTimeAndExpense({ place, expenseSummary, addWhenEmpty, onEditTime, onOpenExpenses }:
Pick) {
+ const summaryId = useId();
const timeRange = formatScheduleTimeRange(place.startTime ?? "", place.endTime);
const addTime = !timeRange && Boolean(addWhenEmpty?.time);
const addExpense = !expenseSummary && Boolean(addWhenEmpty?.expense);
@@ -188,11 +189,11 @@ function PlaceTimeAndExpense({ place, expenseSummary, addWhenEmpty, onEditTime,
onOpenExpenses();
}}
aria-label={`${place.title} 비용 보기`}
- aria-description={expenseSummary}
+ aria-describedby={summaryId}
className="pointer-events-auto flex min-w-0 max-w-full items-center gap-1 text-body-s-regular tabular-nums text-text-subtle"
>
- {expenseSummary}
+ {expenseSummary}
) : null}
{!expenseSummary && addExpense ? (
diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx
index 737082af..8cea5a38 100644
--- a/src/components/expenses/ExpenseProvider.test.tsx
+++ b/src/components/expenses/ExpenseProvider.test.tsx
@@ -1123,7 +1123,9 @@ it.each([
const button = renderer.root.findByType("button");
expect(button.findByType("span").children.join("")).toBe(label);
expect(button.props["aria-label"]).toBe("장소 비용 목록 열기");
- expect(button.props["aria-description"]).toBe(label);
+ const descriptionId = button.props["aria-describedby"];
+ expect(descriptionId).toBeTruthy();
+ expect(textOf(renderer.root.findByProps({ id: descriptionId }))).toBe(label);
await act(async () => button.props.onClick({ stopPropagation() {} }));
expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0);
const panel = renderer.root.findByType(ExpenseScopePanel);
diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx
index b3fcd322..6bb44e3f 100644
--- a/src/components/expenses/ExpenseProvider.tsx
+++ b/src/components/expenses/ExpenseProvider.tsx
@@ -3,6 +3,7 @@
import {
createContext,
useContext,
+ useId,
useLayoutEffect,
useRef,
useState,
@@ -429,6 +430,7 @@ export function ExpenseEntryButton({
className?: string;
icon?: ReactNode;
}>) {
+ const summaryId = useId();
const context = useContext(ExpenseContext);
if (!context?.canManage) return null;
const scope = { scheduleId, scheduleItemId, label: scopeLabel, subtitle: scopeSubtitle };
@@ -443,7 +445,7 @@ export function ExpenseEntryButton({
data-plan-card-no-drag
disabled={context.busy}
aria-label={`${scopeLabel} ${context.list.isSuccess && !scoped.length ? "비용 추가" : "비용 목록 열기"}`}
- aria-description={scheduleItemId === undefined ? undefined : summaryLabel}
+ aria-describedby={scheduleItemId === undefined ? undefined : summaryId}
aria-haspopup="dialog"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -458,7 +460,7 @@ export function ExpenseEntryButton({
}}
className={cn(className, "min-w-0 max-w-full text-left", "disabled:cursor-not-allowed disabled:opacity-50")}
>
- {icon}{summaryLabel}
+ {icon}{summaryLabel}
);
}
From 1d28267cb93632ded65922c18daa6e481d341c3e Mon Sep 17 00:00:00 2001
From: minbros
Date: Wed, 14 Oct 2026 15:48:20 +0900
Subject: [PATCH 11/11] =?UTF-8?q?fix(plan):=20=EC=9D=BC=EC=B0=A8=20?=
=?UTF-8?q?=EB=B9=84=EC=9A=A9=EC=97=90=20=ED=98=BC=ED=95=A9=20=ED=86=B5?=
=?UTF-8?q?=ED=99=94=20=EC=9A=94=EC=95=BD=20=ED=91=9C=EC=8B=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../mobile/MobilePlanDaySection.tsx | 4 ++--
.../expenses/ExpenseProvider.test.tsx | 18 ++++++++++-----
src/components/expenses/ExpenseProvider.tsx | 5 ++--
src/lib/plan/mobilePlanFormat.test.ts | 23 +------------------
src/lib/plan/mobilePlanFormat.ts | 11 +--------
5 files changed, 18 insertions(+), 43 deletions(-)
diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx
index 4b2101f4..1131f381 100644
--- a/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx
+++ b/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx
@@ -16,7 +16,7 @@ import {
} from "@/components/mobile/MobileBottomSheet";
import { expensesInScope } from "@/lib/expenses/expense-scope";
import type { InsertAnchor } from "@/lib/plan/insertPosition";
-import { summarizeExpensesInKrw } from "@/lib/plan/mobilePlanFormat";
+import { summarizePlaceExpenses } from "@/lib/plan/mobilePlanFormat";
import { MobilePlanItinerary } from "./MobilePlanItinerary";
@@ -64,7 +64,7 @@ export function MobilePlanDaySection({
const [manageOpen, setManageOpen] = useState(false);
const dayExpenses = expensesInScope(expenses.list.data ?? [], { scheduleId, label: dayLabel });
- const dayExpenseSummary = summarizeExpensesInKrw(dayExpenses);
+ const dayExpenseSummary = summarizePlaceExpenses(dayExpenses);
function openDayExpenses() {
setManageOpen(false);
diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx
index 8cea5a38..cc6a1f65 100644
--- a/src/components/expenses/ExpenseProvider.test.tsx
+++ b/src/components/expenses/ExpenseProvider.test.tsx
@@ -775,13 +775,19 @@ it("opens a place cost list instead of editing only the latest linked expense",
expect(renderer.root.findByType(ExpenseEditor).props.initial.expense.id).toBe(99);
});
-it("opens a day cost list from the KRW total only", async () => {
+it.each([
+ { rows: [["KRW", "1000"], ["USD", "2.50"]], label: "1,000 KRW 외 1건" },
+ { rows: [["JPY", "3600"]], label: "3,600 JPY" },
+ { rows: [["KRW", "1000"], ["USD", "10"], ["USD", "20"]], label: "30 USD 외 1건" },
+ { rows: [["USD", "10"], ["EUR", "20"], ["EUR", "30"], ["USD", "5"]], label: "15 USD 외 2건" },
+])("day summary $label opens all expenses for that day", async ({ rows, label }) => {
await mountMutations();
await act(async () => {
client.setQueryData(expenseKeys.list("r"), [
- { ...record, id: 20, scheduleId: 2, scheduleItemId: 3, totalAmount: "1000", currency: "KRW" },
- { ...record, id: 21, scheduleId: 2, scheduleItemId: null, totalAmount: "2.50", currency: "USD" },
- { ...record, id: 22, scheduleId: 3, scheduleItemId: null, totalAmount: "500", currency: "KRW" },
+ ...rows.map(([currency, totalAmount], id) => ({
+ ...record, id: 20 + id, scheduleId: 2, scheduleItemId: id === 0 ? 3 : null, currency, totalAmount,
+ })),
+ { ...record, id: 100, scheduleId: 3, scheduleItemId: null, totalAmount: "500", currency: "KRW" },
]);
renderer.update(
@@ -791,11 +797,11 @@ it("opens a day cost list from the KRW total only", async () => {
});
const button = renderer.root.findByType("button");
- expect(button.findByType("span").children.join("")).toBe("1,000 KRW");
+ expect(button.findByType("span").children.join("")).toBe(label);
await act(async () => button.props.onClick({ stopPropagation() {} }));
const panel = renderer.root.findByType(ExpenseScopePanel);
expect(panel.findAllByType("h2")[0].children.join("")).toBe("1일차 비용");
- expect(panel.findAllByType("li")).toHaveLength(2);
+ expect(panel.findAllByType("li")).toHaveLength(rows.length);
await act(async () => panel.findAllByType("button").find(b => b.children.join("").includes("비용 추가"))!.props.onClick());
expect(renderer.root.findByType(ExpenseEditor).props.initial).toEqual({ scheduleId: 2 });
});
diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx
index 6bb44e3f..6192dae9 100644
--- a/src/components/expenses/ExpenseProvider.tsx
+++ b/src/components/expenses/ExpenseProvider.tsx
@@ -36,7 +36,7 @@ import { canManageExpenses } from "@/lib/expenses/expense-policy";
import { getExpenseRecoveryStore } from "@/lib/expenses/expense-recovery";
import { expenseKeys } from "@/lib/expenses/expense-queries";
import { expensesInScope, type ExpenseScope } from "@/lib/expenses/expense-scope";
-import { summarizeExpensesInKrw, summarizePlaceExpenses } from "@/lib/plan/mobilePlanFormat";
+import { summarizePlaceExpenses } from "@/lib/plan/mobilePlanFormat";
import { useSessionUser } from "@/hooks/useSessionUser";
import { useRoomSchedules } from "@/hooks/useRooms";
import { useExpenseRecovery } from "@/hooks/useExpenseRecovery";
@@ -435,10 +435,9 @@ export function ExpenseEntryButton({
if (!context?.canManage) return null;
const scope = { scheduleId, scheduleItemId, label: scopeLabel, subtitle: scopeSubtitle };
const scoped = expensesInScope(context.list.data ?? [], scope);
- const summarize = scheduleItemId === undefined ? summarizeExpensesInKrw : summarizePlaceExpenses;
const summaryLabel = !context.list.isSuccess
? "비용 보기"
- : (summarize(scoped) ?? label);
+ : (summarizePlaceExpenses(scoped) ?? label);
return (