From a15e2494da4861fd1713c92855cb3567acc42420 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 20:09:40 +0900 Subject: [PATCH 01/18] =?UTF-8?q?docs(design):AI=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4=EC=97=90=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Design.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Design.md b/Design.md index ed96d832..2f0bf543 100644 --- a/Design.md +++ b/Design.md @@ -21,6 +21,22 @@ - 특정 페이지 전용 컴포넌트는 `src/app/**/_components/`에 둔다 - 없으면 Tailwind로 직접 작업한다 +### 1-4. Figma 디자인 시스템 +[Design System](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System) 파일은 확정된 내용만 모아 둔 곳이다. 퍼블리싱할 때 해당 페이지의 컴포넌트·토큰을 기준으로 맞춘다. + +- 00 [Foundation](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=161-9) — Color(Primitive·Semantic), Typography, Icon, Spacing, Shadow, Radius, Grid, Logo +- 01 [Button](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-3575) — Button, Button Base (Size·State·Layout) +- 02 [Text Field](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-4296) — Text Field Emphasis, Textarea Field, Search Bar, Input Base +- 03·04·05 [Header · Footer · Side Bar](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5001-3046) — Header(Login·Logout), Footer, Side Bar +- 06 [Navigation](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5002-4797) — Bottom Navigation +- 07·08 [Select Field · Dropdown](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5030-540) — Select Field, Select Field_Emphasis, Dropdown, Item +- 09·10 [Date Picker · Time Picker](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5045-885) — Date Picker, Date Cell, Time Picker, Time Picker Modal, Period +- 11 [Tabs](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5138-2632) — Tabs Line, Tabs Chip, Tab Item +- 12 [Toast](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5285-4979) — Toast +- 13 [Alert Dialog](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5619-553) — Alert Dialog +- 14·15 [Chip · Badge](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5624-159) — Chip(Solid·Outline), Badge(Weak·Solid·Outline, 톤 6종) +- [Update Log](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5635-11) — 디자인 시스템 변경 기록 + --- ## 2. 아이콘의 사용 From 54109a70578110aa6ef2c6d3e2f45995ad7f493b 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:22:08 +0900 Subject: [PATCH 02/18] =?UTF-8?q?chore(icon):icon,test=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itinerary/PlanDaySection.test.tsx | 11 ++++--- src/assets/icons/index.tsx | 2 ++ src/assets/icons/memo-add.svg | 5 +++ .../expenses/ExpenseProvider.test.tsx | 19 ++++++----- src/lib/plan/mobilePlanFormat.test.ts | 32 +++++++------------ 5 files changed, 37 insertions(+), 32 deletions(-) create mode 100644 src/assets/icons/memo-add.svg diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx index a285e7ec..c1d592ed 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx @@ -5,13 +5,13 @@ import { describe, expect, it } from "vitest"; import { PlanDaySection } from "./PlanDaySection"; describe("PlanDaySection", () => { - it("펼침 토글 아이콘을 제목 왼쪽에 배치한다", () => { + it("펼침 토글 아이콘을 날짜·일차 배지 뒤에 배치한다", () => { const html = renderToStaticMarkup( createElement( PlanDaySection, { title: "1일차", - subtitle: "7월 16일 (목)", + subtitle: "07.16(목)", }, createElement("div", null, "일정"), ), @@ -24,8 +24,11 @@ describe("PlanDaySection", () => { expect(triggerMarkup).toBeDefined(); expect(triggerMarkup).toContain('class="shrink-0 text-dark-gray"'); expect(triggerMarkup).not.toContain("ml-auto"); - expect(triggerMarkup!.indexOf("lucide-chevron-up")).toBeLessThan( - triggerMarkup!.indexOf("7월 16일 (목)"), + expect(triggerMarkup!.indexOf("lucide-chevron-up")).toBeGreaterThan( + triggerMarkup!.indexOf("1일차"), + ); + expect(triggerMarkup!.indexOf("07.16(목)")).toBeLessThan( + triggerMarkup!.indexOf("1일차"), ); expect(html).not.toContain("absolute left-1/2"); }); diff --git a/src/assets/icons/index.tsx b/src/assets/icons/index.tsx index 5cf31785..f103e532 100644 --- a/src/assets/icons/index.tsx +++ b/src/assets/icons/index.tsx @@ -12,6 +12,7 @@ import LinkSvg from "@/assets/icons/link.svg"; import LocationAddSvg from "@/assets/icons/location-add.svg"; import LocationErrorSvg from "@/assets/icons/location-error.svg"; import LocationSvg from "@/assets/icons/location.svg"; +import MemoAddSvg from "@/assets/icons/memo-add.svg"; import MenuDotHorizontalSvg from "@/assets/icons/menu-dot-horizontal.svg"; import MenuDotVerticalSvg from "@/assets/icons/menu-dot-vertical.svg"; import MenuHamburgerSvg from "@/assets/icons/menu-hamburger.svg"; @@ -50,6 +51,7 @@ export const LinkIcon = createIcon(LinkSvg, "LinkIcon"); export const LocationAddIcon = createIcon(LocationAddSvg, "LocationAddIcon"); export const LocationErrorIcon = createIcon(LocationErrorSvg, "LocationErrorIcon"); export const LocationIcon = createIcon(LocationSvg, "LocationIcon"); +export const MemoAddIcon = createIcon(MemoAddSvg, "MemoAddIcon"); export const MenuDotHorizontalIcon = createIcon(MenuDotHorizontalSvg, "MenuDotHorizontalIcon"); export const MenuDotVerticalIcon = createIcon(MenuDotVerticalSvg, "MenuDotVerticalIcon"); export const MenuHamburgerIcon = createIcon(MenuHamburgerSvg, "MenuHamburgerIcon"); diff --git a/src/assets/icons/memo-add.svg b/src/assets/icons/memo-add.svg new file mode 100644 index 00000000..539443ba --- /dev/null +++ b/src/assets/icons/memo-add.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx index 89e72750..555ddc78 100644 --- a/src/components/expenses/ExpenseProvider.test.tsx +++ b/src/components/expenses/ExpenseProvider.test.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { setSessionUserCache } from "@/lib/session-user-cache"; @@ -12,6 +12,10 @@ const stomp = vi.hoisted(() => ({ unsubscribe: vi.fn(), })); const stompClient = { subscribe: stomp.subscribe }; +/** 버튼 안 `` 라벨까지 이어 붙인 글자 */ +const textOf = (node: ReactTestInstance): string => + node.children.map((child) => (typeof child === "string" ? child : textOf(child))).join(""); + vi.mock("@/contexts/StompContext", () => ({ useStompContext: () => ({ connected: stomp.connected, client: stompClient }), })); @@ -610,7 +614,7 @@ it("shares one room subscription across route children and resets it on selected gateState.roomContextReady = true; await act(async () => { renderer.update(tree("장소 비용")); }); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); - expect(renderer.root.findAllByType("button").some(b => b.children.join("").includes("장소 비용"))).toBe(true); + expect(renderer.root.findAllByType("button").some(b => textOf(b).includes("장소 비용"))).toBe(true); expect(stomp.subscribe).toHaveBeenCalledTimes(1); expect(stomp.subscribe.mock.calls[0][0]).toBe("/topic/rooms/r/expenses"); await act(async () => { renderer.update(tree("일차 비용")); }); @@ -648,7 +652,7 @@ it("mobile plan shares the provider with the cost page while day/place entries k expect(renderer.root.findAllByType("a").some(a => a.props.href === "/cost")).toBe(false); expect(stomp.subscribe).toHaveBeenCalledTimes(1); for (const [label, initial] of [["일차 비용", { scheduleId: 10 }], ["장소 비용", { scheduleId: 10, scheduleItemId: 20 }]] as const) { - const button = renderer.root.findAllByType("button").find(b => b.children.join("").includes(label))!; + const button = renderer.root.findAllByType("button").find(b => textOf(b).includes(label))!; await act(async () => button.props.onClick({ stopPropagation: () => {} })); expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(1); expect(renderer.root.findByType(ExpenseEditor).props.initial).toEqual(initial); @@ -754,8 +758,7 @@ it("opens a place cost list instead of editing only the latest linked expense", client.setQueryData(scheduleItemsQueryKey("r", 2), [{ itemId: 3, title: "경복궁" }]); }); const button = renderer.root.findByType("button"); - expect(button.children.join("")).toContain("비용 2건"); - expect(button.children.join("")).toContain("24,690 KRW"); + expect(button.findByType("span").children.join("")).toBe("24,690 KRW"); await act(async () => button.props.onClick({ stopPropagation() {} })); expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0); expect(renderer.root.findAllByType("h2").some(heading => heading.children.join("") === "장소 비용")).toBe(true); @@ -772,7 +775,7 @@ 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 with separate totals for each currency", async () => { +it("opens a day cost list from the KRW total only", async () => { await mountMutations(); await act(async () => { client.setQueryData(expenseKeys.list("r"), [ @@ -788,7 +791,7 @@ it("opens a day cost list with separate totals for each currency", async () => { }); const button = renderer.root.findByType("button"); - expect(button.children.join("")).toContain("비용 2건 · 1,000 KRW · 2.50 USD"); + expect(button.findByType("span").children.join("")).toBe("1,000 KRW"); await act(async () => button.props.onClick({ stopPropagation() {} })); const panel = renderer.root.findByType(ExpenseScopePanel); expect(panel.findAllByType("h2")[0].children.join("")).toBe("1일차 비용"); @@ -804,7 +807,7 @@ it("returns to add mode after the last linked expense is deleted", async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); const button = renderer.root.findByType("button"); - expect(button.children.join("")).toContain("비용 추가"); + expect(button.findByType("span").children.join("")).toBe("비용 추가"); await act(async () => button.props.onClick({ stopPropagation() {} })); expect(renderer.root.findByType(ExpenseEditor).props.initial).toEqual({ scheduleId: 2, scheduleItemId: 3 }); }); diff --git a/src/lib/plan/mobilePlanFormat.test.ts b/src/lib/plan/mobilePlanFormat.test.ts index 1ff51485..ca41908d 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, summarizeExpensesForMobile } from "./mobilePlanFormat"; +import { formatMobileDayDate, formatMonthDayKo, summarizeExpensesInKrw } from "./mobilePlanFormat"; function expense(currency: string, totalAmount: string, id: number): Expense { return { id, currency, totalAmount } as Expense; @@ -31,30 +31,22 @@ describe("formatMobileDayDate", () => { }); }); -describe("summarizeExpensesForMobile", () => { - it("지출이 없으면 null", () => { - expect(summarizeExpensesForMobile([])).toBeNull(); +describe("summarizeExpensesInKrw", () => { + it("비용이 없으면 null", () => { + expect(summarizeExpensesInKrw([])).toBeNull(); }); - it("통화가 하나면 합계만 보여준다", () => { + it("원화 비용 합계 하나만 보여준다", () => { expect( - summarizeExpensesForMobile([expense("KRW", "45000", 1), expense("KRW", "5000", 2)]), - ).toBe("50,000 KRW"); - }); - - it("지출이 가장 많은 통화 합계에 다른 통화 지출 건수를 붙인다", () => { - expect( - summarizeExpensesForMobile([ - expense("AFN", "123.00", 1), - expense("KRW", "300", 2), - expense("KRW", "57", 3), + summarizeExpensesInKrw([ + expense("KRW", "45000", 1), + expense("USD", "50.00", 2), + expense("KRW", "5000", 3), ]), - ).toBe("357 KRW 외 1건"); + ).toBe("50,000 KRW"); }); - it("건수가 같으면 먼저 나온 통화를 쓴다", () => { - expect( - summarizeExpensesForMobile([expense("JPY", "3600", 1), expense("KRW", "1000", 2)]), - ).toBe("3,600 JPY 외 1건"); + it("원화 비용이 없으면 null", () => { + expect(summarizeExpensesInKrw([expense("JPY", "3600", 1)])).toBeNull(); }); }); From 0ef38f923aa66b8a6e7a2bbb1c625a93d2aa0b7b 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:25:21 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(expenses):=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=C2=B7=EC=9D=BC=EC=B0=A8=20=EB=B9=84=EC=9A=A9?= =?UTF-8?q?=EC=9D=84=20=EC=9B=90=ED=99=94=20=ED=95=A9=EA=B3=84=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PC·모바일 일정 카드와 일차 비용에서 통화별 나열·외 N건 대신 KRW 합계만 표시 - 다른 통화 비용은 가계부에서 확인하고, 원화 비용이 없으면 기본 문구 유지 - 비용 버튼 라벨을 span으로 감싸 한 줄 말줄임 Co-Authored-By: Claude Opus 5.5 --- .../map/_components/MobileRoutePlaceCard.tsx | 2 +- .../map/_components/useMobileRouteView.tsx | 4 +-- .../mobile/MobilePlanDaySection.tsx | 4 +-- .../mobile/MobilePlanItinerary.tsx | 4 +-- .../mobile/MobilePlanPlaceCard.tsx | 2 +- src/components/expenses/ExpenseProvider.tsx | 14 ++++------ src/lib/plan/mobilePlanFormat.ts | 26 ++++--------------- 7 files changed, 18 insertions(+), 38 deletions(-) diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index 9ea90a49..5d0dc7fc 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 외 1건` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */ + /** 원화 합계 `45,000 KRW` — 없으면 비용 권한이 있을 때 "비용 추가"를 보인다 */ expenseSummary: string | null; /** 비용을 추가할 수 있는지(비용 권한) */ canAddExpense: boolean; diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx index 38d44d87..b4f32d23 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, summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat"; +import { formatMonthDayKo, summarizeExpensesInKrw } 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={summarizeExpensesForMobile(itemExpenses(entry.scheduleId, entry.itemId))} + expenseSummary={summarizeExpensesInKrw(itemExpenses(entry.scheduleId, entry.itemId))} canAddExpense={expenses.canManage} expanded={isExpanded} onExpand={() => { diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx index a2af8bcb..4b2101f4 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 { summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat"; +import { summarizeExpensesInKrw } 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 = summarizeExpensesForMobile(dayExpenses); + const dayExpenseSummary = summarizeExpensesInKrw(dayExpenses); function openDayExpenses() { setManageOpen(false); diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 4e2088ef..52e247ba 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 { summarizeExpensesForMobile } from "@/lib/plan/mobilePlanFormat"; +import { summarizeExpensesInKrw } 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={summarizeExpensesForMobile(itemExpenses(place.itemId))} + expenseSummary={summarizeExpensesInKrw(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 ccf3e337..1027671b 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 외 1건` — 없으면 비용 줄을 숨긴다 */ + /** 원화 합계 `45,000 KRW` — 없으면 비용 줄을 숨긴다 */ expenseSummary: string | null; /** 카드 본문 — 없으면 본문을 눌러도 아무 일도 없다(예: 경로 보기에서 펼친 카드) */ onOpen?: () => void; diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx index a95fd16b..df6e05b0 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -34,15 +34,14 @@ import { import { canManageExpenses } from "@/lib/expenses/expense-policy"; import { getExpenseRecoveryStore } from "@/lib/expenses/expense-recovery"; import { expenseKeys } from "@/lib/expenses/expense-queries"; -import { expensesInScope, totalsByCurrency, type ExpenseScope } from "@/lib/expenses/expense-scope"; +import { expensesInScope, type ExpenseScope } from "@/lib/expenses/expense-scope"; +import { summarizeExpensesInKrw } from "@/lib/plan/mobilePlanFormat"; import { useSessionUser } from "@/hooks/useSessionUser"; import { useRoomSchedules } from "@/hooks/useRooms"; import { useExpenseRecovery } from "@/hooks/useExpenseRecovery"; import { ExpenseEditor, type ExpenseEntry } from "./ExpenseEditor"; import { ExpenseScopePanel } from "./ExpenseScopePanel"; -import { formatExpenseAmount } from "./ExpenseViews"; import { cn } from "@/lib/utils"; -import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens"; function useExpenses(roomId: string) { const client = useQueryClient(); @@ -434,12 +433,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 totals = totalsByCurrency(scoped); const summaryLabel = !context.list.isSuccess ? "비용 보기" - : scoped.length - ? `비용 ${scoped.length}건 · ${totals.map(({ currency, amount }) => `${formatExpenseAmount(amount)} ${currency}`).join(" · ")}` - : label; + : (summarizeExpensesInKrw(scoped) ?? label); return ( ); } diff --git a/src/lib/plan/mobilePlanFormat.ts b/src/lib/plan/mobilePlanFormat.ts index 58a994b5..fe014305 100644 --- a/src/lib/plan/mobilePlanFormat.ts +++ b/src/lib/plan/mobilePlanFormat.ts @@ -22,26 +22,10 @@ export function formatMonthDayKo(ymd: string): string { } /** - * 모바일 비용 요약 — 지출이 가장 많은 통화의 합계만 보여주고, 다른 통화 지출은 건수로 붙인다. - * 예: `357 KRW`, `357 KRW 외 1건`. 지출이 없으면 null. + * 일정 비용 요약(PC·모바일 일정 카드와 일차) — 원화(KRW) 합계 하나만 보인다(예: `130,200 KRW`). + * 다른 통화 비용은 가계부에서 본다. 원화 비용이 없으면 null. */ -export function summarizeExpensesForMobile(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; +export function summarizeExpensesInKrw(expenses: readonly Expense[]): string | null { + const krw = totalsByCurrency(expenses).find((total) => total.currency === "KRW"); + return krw ? `${formatExpenseAmount(krw.amount)} KRW` : null; } From ca0231493793901f831b001ecb9b234539f0290a 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:28:24 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat(card):=EC=9E=A5=EC=86=8C=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=C2=B7=EB=A9=94=EB=AA=A8=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itinerary/PlanItemMemoForm.tsx | 14 +-- .../_components/itinerary/PlanPlaceCard.tsx | 116 +++++++----------- .../itinerary/PlanPlaceCardParts.tsx | 9 +- src/lib/layout-tokens.ts | 28 +++-- 4 files changed, 71 insertions(+), 96 deletions(-) diff --git a/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx b/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx index 168639d4..cadd1c3f 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx @@ -1,7 +1,6 @@ "use client"; -import { Trash2 } from "lucide-react"; - +import { TrashIcon } from "@/assets/icons"; import { MemoIcon } from "@/components/icons"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; @@ -116,9 +115,10 @@ export function PlanItemMemoReadOnly({ if (!text.length) return null; return ( -
-
- + // 아이콘·간격은 카드 액션 버튼(16px 아이콘, 6px 간격)과 맞추고, 글자 시작·높이·휴지통 위치는 디자인 값에 맞춘다 +
+
+

{renderTextWithLinks(text, { @@ -136,9 +136,9 @@ export function PlanItemMemoReadOnly({ e.stopPropagation(); onDelete(); }} - className="shrink-0 cursor-pointer self-start rounded-md p-1 text-dark-gray transition hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40" + className="-mr-[3px] shrink-0 cursor-pointer self-start rounded-md p-1 text-dark-gray transition hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40" > - + ) : null}

diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx index 8938dd26..79717dd0 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx @@ -1,14 +1,13 @@ "use client"; import { ExpenseEntryButton } from "@/components/expenses/ExpenseProvider"; -import { ExpenseIcon } from "@/components/icons/ExpenseIcon"; import type { DragEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import Image from "next/image"; -import { Clock, Loader2, MapPin } from "lucide-react"; +import { Loader2, MapPin } from "lucide-react"; -import { MemoIcon } from "@/components/icons"; +import { CoinIcon, MemoAddIcon, TimeClockIcon } from "@/assets/icons"; import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; import { toast } from "sonner"; @@ -270,16 +269,14 @@ export function PlanPlaceCard({ setMemoOpen((v) => !v); }} aria-expanded={memoOpen} + aria-label={hasMemo ? "메모 수정" : "메모 추가"} className={cn( - PLAN_PLACE_CARD_TW.triggerButton, - (memoOpen || hasMemo) && PLAN_PLACE_CARD_TW.triggerButtonActive, + PLAN_PLACE_CARD_TW.actionButton, + PLAN_PLACE_CARD_TW.actionButtonTrailing, )} > - - {hasMemo ? "메모 수정" : "메모 추가"} + + 메모 ) : null; @@ -293,17 +290,11 @@ export function PlanPlaceCard({ setTimeOpen((v) => !v); }} aria-expanded={timeOpen} - className={cn( - PLAN_PLACE_CARD_TW.triggerButton, - (timeOpen || hasTime) && PLAN_PLACE_CARD_TW.triggerButtonActive, - )} + aria-label={hasTime ? `시간 ${timeRange} 수정` : "시간 설정"} + className={cn(PLAN_PLACE_CARD_TW.actionButton, "tabular-nums")} > - - {timeRange || "시간 설정"} + + {timeRange || "시간"} ) : null; @@ -389,43 +380,30 @@ export function PlanPlaceCard({
-
-

- {place.title} -

- {place.primaryTypeDisplayName ? ( - - {place.primaryTypeDisplayName} - - ) : null} -
- - {place.subtitle ? ( -

- {place.subtitle} -

+

+ {place.title} +

+ {place.primaryTypeDisplayName ? ( + + {place.primaryTypeDisplayName} + ) : null}
{deleteButton ? ( - {deleteButton} + // 휴지통은 썸네일 윗선에 맞추고 오른쪽 테두리에서 17px에 둔다 + {deleteButton} ) : null}
+ {place.subtitle ? ( +

+ {place.subtitle} +

+ ) : null} + {timeRange && !canEditScheduleItem ? ( {canEditScheduleItem ? ( -
- {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/18] =?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" > 지도에 경로 표시