From 38ced33a425f372ae4168d9dca48ec3a9a142364 Mon Sep 17 00:00:00 2001 From: OhChangEun <131477920+OhChangEun@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:00:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(wholesale):=20=EC=A0=95=EC=82=B0=20?= =?UTF-8?q?=EC=84=A0=EC=88=98=EA=B8=88=20=EC=A1=B0=ED=9A=8C=EC=99=80=20?= =?UTF-8?q?=EC=9E=94=EC=95=A1=203=EC=B9=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#138?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/settlement/api/keys.ts | 7 +- .../src/features/settlement/api/mutations.ts | 8 +- .../src/features/settlement/api/queries.ts | 18 +++ .../components/PrepaidSummaryPanel.tsx | 104 ++++++++++++++++++ .../components/SettlementListView.tsx | 38 ++++--- .../src/features/settlement/derive.ts | 27 +++-- .../src/features/settlement/types.ts | 18 ++- 7 files changed, 193 insertions(+), 27 deletions(-) create mode 100644 apps/wholesale/src/features/settlement/components/PrepaidSummaryPanel.tsx diff --git a/apps/wholesale/src/features/settlement/api/keys.ts b/apps/wholesale/src/features/settlement/api/keys.ts index 27bc35d..41feab5 100644 --- a/apps/wholesale/src/features/settlement/api/keys.ts +++ b/apps/wholesale/src/features/settlement/api/keys.ts @@ -8,10 +8,11 @@ import type { LedgerQuery } from "./queries"; * `all` ⊃ `retailers()` ← 소매처별 미수(아코디언 머리) * ⊃ `orders(retailerId)` ← 확정 주문(정산 상태 표 + 배분 표가 **같은 키**) * ⊃ `ledgers(retailerId)` ⊃ `ledger(q)` ← 원장(구분 필터별) + * ⊃ `prepaid(retailerId)` ← 선수금 3카드 * ⊃ `bankAccounts()` * - * 입금(POST /payments)은 그 소매처의 미수·주문 미수·원장을 전부 바꾸므로 `retailers()`·`orders(id)`·`ledgers(id)`를 - * 비운다. 판매 줄은 출고 탭이 만드는데 feature끼리 키를 못 비우니 탭 진입 때 `all`을 한 번 비운다(`useInvalidateOnMount`). + * 입금(POST /payments)은 그 소매처의 미수·주문 미수·원장·선수금을 전부 바꾸므로 `retailers()`·`orders(id)`· + * `ledgers(id)`·`prepaid(id)`를 비운다. 판매 줄은 출고 탭이 만드는데 feature끼리 키를 못 비우니 탭 진입 때 `all`을 한 번 비운다(`useInvalidateOnMount`). */ export const settlementKeys = { all: ["settlement"] as const, @@ -22,5 +23,7 @@ export const settlementKeys = { [...settlementKeys.all, "ledger", retailerId] as const, ledger: (query: LedgerQuery) => [...settlementKeys.ledgers(query.retailerId), query.entryType] as const, + prepaid: (retailerId: number) => + [...settlementKeys.all, "prepaid", retailerId] as const, bankAccounts: () => [...settlementKeys.all, "bank-accounts"] as const, }; diff --git a/apps/wholesale/src/features/settlement/api/mutations.ts b/apps/wholesale/src/features/settlement/api/mutations.ts index 1c86659..1a43510 100644 --- a/apps/wholesale/src/features/settlement/api/mutations.ts +++ b/apps/wholesale/src/features/settlement/api/mutations.ts @@ -45,12 +45,18 @@ function refetch( ); } -/** 입금 뒤 낡는 것 — 소매처 미수(행), 그 소매처의 주문 미수·정산 상태(표·배분 표), 원장 */ +/** + * 입금 뒤 낡는 것 — 소매처 미수(행), 그 소매처의 주문 미수·정산 상태(표·배분 표), 원장, 선수금 3카드. + * + * 응답의 `ledgerBalance`·`prepaidRemaining`으로 캐시를 직접 고치지 않는다 — 3카드의 `totalPaid`·`totalAllocated`는 + * 응답에 없어 화면이 더해야 하고, 그 순간 취소분 처리가 서버와 갈릴 수 있다. 원장 잔액과 같은 방식으로 전부 다시 받는다. + */ function refetchAfterPayment(queryClient: QueryClient, retailerId: number) { return refetch(queryClient, [ settlementKeys.retailers(), settlementKeys.orders(retailerId), settlementKeys.ledgers(retailerId), + settlementKeys.prepaid(retailerId), ]); } diff --git a/apps/wholesale/src/features/settlement/api/queries.ts b/apps/wholesale/src/features/settlement/api/queries.ts index 34567a9..29b84bd 100644 --- a/apps/wholesale/src/features/settlement/api/queries.ts +++ b/apps/wholesale/src/features/settlement/api/queries.ts @@ -12,6 +12,7 @@ import { toBankAccountView, toLedgerView, toOrderView, + toPrepaidView, toRetailerRow, } from "../derive"; import type { @@ -20,6 +21,8 @@ import type { LedgerEntryType, LedgerView, OrderRowView, + PrepaidSummary, + PrepaidView, ReceivableLedgerPage, ReceivableRetailer, RetailerRowView, @@ -35,6 +38,8 @@ import type { export const SETTLEMENT_PATH = { receivableRetailers: "/api/wholesale/receivables/retailers", receivables: "/api/wholesale/receivables", + prepaid: (retailerId: number) => + `/api/wholesale/receivables/retailers/${retailerId}/prepaid`, payments: "/api/wholesale/payments", orders: "/api/wholesale/orders", bankAccounts: "/api/wholesale/bank-accounts", @@ -108,6 +113,19 @@ export function useLedgerQuery(query: LedgerQuery) { }); } +/** + * 소매처 하나의 선수금 3카드(`GET /receivables/retailers/{id}/prepaid`). 거래 관계가 없는 소매처는 404지만 + * 이 탭은 미수 목록에 있는 소매처만 펼치므로 오지 않는다 — 와도 경계의 기본 빈 상태로 그린다. + */ +export function usePrepaidQuery(retailerId: number) { + return useSuspenseQuery({ + queryKey: settlementKeys.prepaid(retailerId), + queryFn: () => + apiFetch(SETTLEMENT_PATH.prepaid(retailerId)), + select: (summary): PrepaidView => toPrepaidView(summary), + }); +} + /** 정산 계좌 전부. 페이징 없음(스펙: 계좌는 소수). 순서는 서버(주계좌 먼저 → 등록순) */ export function useBankAccountsQuery() { return useSuspenseQuery({ diff --git a/apps/wholesale/src/features/settlement/components/PrepaidSummaryPanel.tsx b/apps/wholesale/src/features/settlement/components/PrepaidSummaryPanel.tsx new file mode 100644 index 0000000..ae6a119 --- /dev/null +++ b/apps/wholesale/src/features/settlement/components/PrepaidSummaryPanel.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Button, cn, Panel } from "@ondo/ui"; +import { usePrepaidQuery } from "../api/queries"; +import { retailerLabel } from "../derive"; +import type { RetailerView } from "../types"; +import { QueryBoundary } from "@/shared/api/QueryBoundary"; +import { formatNumber } from "@/shared/lib/format"; + +/** + * 우측 상단 — 펼친 거래처의 선수금 잔액 3카드(`GET /receivables/retailers/{id}/prepaid`). + * + * 총 입금액 · 배분 완료액 · **남은 선수금**(강조). 셋은 서버값이고 화면은 더하거나 빼지 않는다 — + * 취소된 입금·취소된 배분을 빼는 규칙이 서버에 있어서 여기서 다시 세면 그 순간 갈린다. + * 보조 문구(입금 회수·완료율·미정산 건수)는 Figma에 있지만 **서버가 주지 않아 안 그린다** — 더미를 두지 않는다. + * + * 입금 등록 패널 위에 놓인다. 입금 폼의 `총 사용 가능`이 이 값(남은 선수금)에 기대므로 같은 열에 붙여 둔다. + * 경계는 카드 자리에만 — 제목은 서버와 무관하게 늘 있어야 한다. + */ +export function PrepaidSummaryPanel({ + retailer, + onRefresh, +}: { + retailer: RetailerView; + /** 재조회 실패 시 `다시 불러오기`. 부모의 것 하나를 쓴다 — 우측 패널의 잠금도 같이 풀려야 한다 */ + onRefresh: () => void; +}) { + return ( + +
+

선수금 잔액

+ + {retailerLabel(retailer.name, retailer.code)} + +
+ + + +
+ ); +} + +/** 3카드. 안에서만 `useSuspenseQuery`를 부른다 */ +function PrepaidCards({ + retailerId, + onRefresh, +}: { + retailerId: number; + onRefresh: () => void; +}) { + const { data, isRefetchError } = usePrepaidQuery(retailerId); + + return ( + <> + {/* 캐시엔 값이 있는데 재조회만 실패한 상태 — 경계가 못 잡는 유일한 실패라 여기서 한 줄 */} + {isRefetchError ? ( +

+ 최신 선수금을 못 불러왔어요 + +

+ ) : null} +
+ + + {/* 세 번째만 강조 — 입금 폼이 실제로 쓰는 숫자는 이것뿐이다 */} + +
+ + ); +} + +function PrepaidCard({ + label, + value, + emphasized = false, +}: { + label: string; + value: number; + emphasized?: boolean; +}) { + return ( +
+
{label}
+
+ {formatNumber(value)}원 +
+
+ ); +} diff --git a/apps/wholesale/src/features/settlement/components/SettlementListView.tsx b/apps/wholesale/src/features/settlement/components/SettlementListView.tsx index 05c7f47..5976cb1 100644 --- a/apps/wholesale/src/features/settlement/components/SettlementListView.tsx +++ b/apps/wholesale/src/features/settlement/components/SettlementListView.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { BankAccountPanel } from "./BankAccountPanel"; import { DepositFormPanel } from "./DepositFormPanel"; +import { PrepaidSummaryPanel } from "./PrepaidSummaryPanel"; import { SettlementRelationTable } from "./SettlementRelationTable"; import { SettlementSegmentView } from "./SettlementSegmentView"; import { settlementKeys } from "../api/keys"; @@ -164,20 +165,29 @@ export function SettlementListView() { ) : openRetailer && draft ? ( - - updateDraft(openRetailer.id, patch, options) - } - inList={visibleIds?.has(openRetailer.id) ?? true} - stale={stale} - notice={notice} - onRefresh={retryRefresh} - onDone={finishPayment} - /> + /* 선수금 3카드가 위, 입금 폼이 아래 — 폼의 `총 사용 가능`이 카드의 남은 선수금에 기댄다(#138). + 둘은 경계가 따로다: 카드가 실패해도 입금 폼은 그대로 남는다 */ + <> + + + updateDraft(openRetailer.id, patch, options) + } + inList={visibleIds?.has(openRetailer.id) ?? true} + stale={stale} + notice={notice} + onRefresh={retryRefresh} + onDone={finishPayment} + /> + ) : undefined; return ( diff --git a/apps/wholesale/src/features/settlement/derive.ts b/apps/wholesale/src/features/settlement/derive.ts index 245bb9d..00cdb9d 100644 --- a/apps/wholesale/src/features/settlement/derive.ts +++ b/apps/wholesale/src/features/settlement/derive.ts @@ -17,9 +17,10 @@ import type { LedgerRowView, LedgerView, OrderRowView, - OrderStatus, PaymentAllocationRequest, PaymentCreateRequest, + PrepaidSummary, + PrepaidView, ReceivableLedgerPage, ReceivableRetailer, RetailerRowView, @@ -171,9 +172,12 @@ export function retailerLabel(name: string, code: string): string { return code === "" ? name : `${name} · ${code}`; } -/** 출고분이 있는 주문인가. 응답에 출고 금액이 없어 이행 상태로 근사한다 — 부분이라도 나갔으면 미수가 있다 */ -export function hasShipped(status: OrderStatus): boolean { - return status === "PARTIALLY_SHIPPED" || status === "SHIPPED"; +/** + * 출고분이 있는 주문인가 = `shippedAmount > 0`. 이행 상태(`SHIPPED`)로 근사하지 않는다 — + * dev에 `SHIPPED`인데 `shippedAmount` 0인 옛 주문이 있고(2026-09-18), 미수는 출고 **금액**이 만든다. + */ +export function hasShipped(shippedAmount: number): boolean { + return shippedAmount > 0; } /** @@ -181,12 +185,12 @@ export function hasShipped(status: OrderStatus): boolean { * 표의 미수 합이 행의 미수와 같아야 한다(선수금이 없을 때). * * 출고 전 주문은 미수 0 · `UNSHIPPED`(미출고)로 눕히고 배분 표(`allocationTargets`)에도 안 올린다. - * 서버가 그 주문에 `outstandingAmount`(주문 금액 기준)를 내려도 쓰지 않는다 — 원장에 없는 돈이라 배분하면 - * 행 `0원`·표 `부분 정산`·원장 `+선수금`이 동시에 서는 화면이 된다(F1). + * 서버도 이제 출고 전 주문의 `outstandingAmount`를 0으로 내린다(출고 미수 − 이미 붙은 배분) — 그래도 한 번 더 + * 0으로 눕히는 건 서버가 옛 정의(주문 금액 기준)로 돌아갔을 때 원장에 없는 돈을 배분하지 않기 위해서다(F1). * 이미 배정이 붙은 출고 전 주문(서버가 허용했을 때)은 상태만 서버값을 남기고 미수는 역시 0이다. */ export function toOrderView(order: SettlementOrder): OrderRowView { - const shipped = hasShipped(order.status.key); + const shipped = hasShipped(order.shippedAmount); return { id: order.id, orderNumber: String(order.orderNumber), @@ -208,6 +212,15 @@ export function outstandingTotal(orders: readonly OrderRowView[]): number { return orders.reduce((sum, o) => sum + o.outstanding, 0); } +/** 선수금 3카드. 서버값을 옮기기만 한다 — `prepaid`를 두 값의 차로 다시 세면 취소분 처리가 서버와 갈릴 수 있다 */ +export function toPrepaidView(summary: PrepaidSummary): PrepaidView { + return { + totalPaid: summary.totalPaid, + totalAllocated: summary.totalAllocated, + prepaid: summary.prepaid, + }; +} + /** * 원장 페이지 → 표. 서버는 최신순으로 내리지만(스텁 example) 화면은 **오래된 순**(위에서 아래로 잔액이 흐르는 * 사양)이라 페이지 안에서 뒤집는다. 잔액은 줄마다 서버가 확정한 값이라 순서를 바꿔도 틀리지 않는다. diff --git a/apps/wholesale/src/features/settlement/types.ts b/apps/wholesale/src/features/settlement/types.ts index 7301539..c9f8896 100644 --- a/apps/wholesale/src/features/settlement/types.ts +++ b/apps/wholesale/src/features/settlement/types.ts @@ -26,6 +26,11 @@ export type PaymentCreateRequest = WholesaleSchema<"PaymentCreateRequest">; export type PaymentAllocationRequest = WholesaleSchema<"PaymentAllocationRequest">; export type PaymentCreated = WholesaleSchema<"PaymentCreatedResponse">; +/** + * 선수금 요약(정산 탭 3카드). 취소된 입금·취소된 배분은 뺀 값이고 `prepaid = totalPaid − totalAllocated`다(스펙). + * 입금 폼의 사용 가능액 = 이번 입금액 + `prepaid`. + */ +export type PrepaidSummary = WholesaleSchema<"PrepaidSummaryResponse">; export type BankAccount = WholesaleSchema<"BankAccountResponse">; export type BankAccountCreateRequest = WholesaleSchema<"BankAccountCreateRequest">; @@ -92,13 +97,20 @@ export interface OrderRowView { /** 출고분이 있는 주문은 서버값, 없으면 `UNSHIPPED` */ settlementStatus: SettlementBadgeStatus; /** - * 미수 잔액 = **출고된 금액 − 배정액**(한 정의, 거래처 행의 원장 잔액과 같은 기준). - * 출고분이 있는 주문(`PARTIALLY_SHIPPED`·`SHIPPED`)은 서버 `outstandingAmount` 그대로, 출고 전 주문은 0 — - * 응답에 출고 금액 필드가 없어 `status.key`로 근사한다(04-wire §3-6) + * 남은 미수 = **출고된 금액 − 이미 붙은 배분**(한 정의, 거래처 행의 원장 잔액과 같은 기준). + * 서버 `outstandingAmount`가 이 정의다(2026-09-18 dev 확인: 출고 전 주문은 `shippedAmount` 0 · `outstandingAmount` 0). + * 출고분이 있는지는 `shippedAmount`로 본다 — 예전엔 이 필드가 없어 `status.key`로 근사했다 */ outstanding: number; } +/** 선수금 3카드. 셋 다 서버값 그대로 — `남은 선수금 = 총 입금액 − 배분 완료액`을 화면에서 다시 세지 않는다 */ +export interface PrepaidView { + totalPaid: number; + totalAllocated: number; + prepaid: number; +} + /** 원장 표 한 줄 */ export interface LedgerRowView { id: number; From 33b3c07f925421733e207e89c37731d0d9def5fa Mon Sep 17 00:00:00 2001 From: OhChangEun <131477920+OhChangEun@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:03:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(wholesale):=20=EC=9E=85=EA=B8=88=20?= =?UTF-8?q?=EB=B0=B0=EB=B6=84=20=EC=83=81=ED=95=9C=EC=9D=84=20=EC=9E=85?= =?UTF-8?q?=EA=B8=88=EC=95=A1+=EC=84=A0=EC=88=98=EA=B8=88=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C,=20=EB=B0=B0=EB=B6=84=20=ED=91=9C=20=EC=97=B4=C2=B7?= =?UTF-8?q?=ED=95=A9=EA=B3=84=ED=96=89=20=EA=B0=9C=EC=A0=95=20#138?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settlement/components/AllocationTable.tsx | 31 +++++--- .../components/DepositFormPanel.tsx | 54 ++++++++++---- .../components/PrepaidSummaryPanel.tsx | 19 ++++- .../components/SettlementListView.tsx | 12 +++- .../src/features/settlement/constants.ts | 5 +- .../src/features/settlement/derive.ts | 71 ++++++++++++------- .../src/features/settlement/types.ts | 7 +- 7 files changed, 146 insertions(+), 53 deletions(-) diff --git a/apps/wholesale/src/features/settlement/components/AllocationTable.tsx b/apps/wholesale/src/features/settlement/components/AllocationTable.tsx index 9203c1f..9919a4c 100644 --- a/apps/wholesale/src/features/settlement/components/AllocationTable.tsx +++ b/apps/wholesale/src/features/settlement/components/AllocationTable.tsx @@ -2,18 +2,23 @@ import { Table } from "@ondo/ui"; import { OrderStatusBadge, SettlementBadge } from "./StatusBadge"; +import { allocationTotal, outstandingTotal } from "../derive"; import type { OrderRowView } from "../types"; import { NumericInput } from "@/shared/components/NumericInput"; import { formatNumber } from "@/shared/lib/format"; /** - * 입금 1건을 여러 주문에 나눠 붙이는 표(`allocations[]`, 입금 1 : 주문 N). + * 돈 한 뭉치를 여러 주문에 나눠 붙이는 표(`allocations[]`, 1 : N). 입금 등록(`POST /payments`)과 + * 선수금 정산(`POST /allocations`)이 **같은 표**를 쓴다 — 상한이 무엇이든 행마다 붙이는 규칙은 같다. * - * `배분` 열만 입력이고 나머지는 읽기 전용이다 — 나머지 4열은 서버값이라 - * 여기서 고치면 화면끼리 숫자가 갈린다. + * `이번 배분` 열만 입력이고 나머지는 읽기 전용이다 — 나머지 4열은 서버값이라 + * 여기서 고치면 화면끼리 숫자가 갈린다. `남은 미수`는 서버 정의(출고 미수 − 이미 붙은 배분) 그대로다. * - * 값은 이 컴포넌트가 들고 있지 않는다. 입금액이 바뀌면 자동 배분이 다시 계산돼야 하고 - * 그 계산은 폼 전체(입금액)를 아는 쪽에서만 할 수 있기 때문이다. + * 값은 이 컴포넌트가 들고 있지 않는다. 사용 가능액이 바뀌면 자동 배분이 다시 계산돼야 하고 + * 그 계산은 폼 전체(입금액·선수금)를 아는 쪽에서만 할 수 있기 때문이다. + * + * 합계행은 Figma 개정(#138)에서 왔다 — `남은 미수` 합이 거래처 행의 미수 잔액과 같아야 하고, `이번 배분` 합이 + * 요약 줄의 합계와 같아야 한다. 둘 다 derive의 같은 함수로 센다. */ export function AllocationTable({ targets, @@ -27,7 +32,7 @@ export function AllocationTable({ values: Readonly>; /** 상한을 넘긴 행의 이유 한 줄(`derive.allocationIssues`). 있는 행만 빨갛게 + 칸 아래 문구 */ issues: Readonly>; - /** 입금액을 아직 안 적었으면 배분할 돈이 없다 → 입력칸을 전부 잠근다 */ + /** 배분할 돈이 아직 없으면(입금액 빈칸) 입력칸을 전부 잠근다 */ disabled: boolean; onChange: (orderId: number, raw: string) => void; }) { @@ -46,8 +51,8 @@ export function AllocationTable({ 주문번호 주문 상태 정산 상태 - 미수 - 배분 + 남은 미수 + 이번 배분 @@ -92,6 +97,16 @@ export function AllocationTable({ ); })} + {/* 합계행. hover 면이 생기면 안 되는 줄이라 `Table.Row` 대신 생짜 tr — 데이터 행이 아니다 */} + + + + 합계 + + {formatNumber(outstandingTotal(targets))} + {formatNumber(allocationTotal(values))} + + ); } diff --git a/apps/wholesale/src/features/settlement/components/DepositFormPanel.tsx b/apps/wholesale/src/features/settlement/components/DepositFormPanel.tsx index 23dc886..a1690a5 100644 --- a/apps/wholesale/src/features/settlement/components/DepositFormPanel.tsx +++ b/apps/wholesale/src/features/settlement/components/DepositFormPanel.tsx @@ -10,6 +10,7 @@ import { allocationIssues, allocationTargets, allocationTotal, + availableTotal, depositErrorText, formatAmountInput, formatInputDateTime, @@ -53,10 +54,14 @@ type DepositField = (typeof DEPOSIT_FIELDS)[number]; * * 배분 표의 주문은 **좌측 펼침 본문이 받은 것**을 그대로 쓴다(`orders`). 이 패널이 같은 쿼리를 따로 들면 * 경계가 둘이 된다(wire-order F6). 못 받았으면(`null`) 배분은 잠그고 `입금만 진행`만 열어 둔다. + * + * 배분 상한은 **총 사용 가능 = 이번 입금액 + 남은 선수금**이다(#138, 스펙: 이번 입금을 먼저 쓰고 모자라면 오래된 + * 입금부터 끌어 쓴다). 남은 선수금은 위 3카드 패널이 받아 부모가 넘긴다 — 같은 쿼리를 여기서 또 들지 않는다. */ export function DepositFormPanel({ retailer, orders, + prepaid, draft, onDraftChange, inList, @@ -68,6 +73,8 @@ export function DepositFormPanel({ retailer: RetailerView; /** 이 거래처의 확정 주문. 좌측 펼침 본문이 넘긴다. 아직 못 받았으면 null */ orders: readonly OrderRowView[] | null; + /** 남은 선수금(3카드의 세 번째 값). 아직 못 받았으면 0 — 그러면 상한이 입금액뿐이라 더 보수적일 뿐 틀리진 않는다 */ + prepaid: number; draft: DepositDraft; /** 부모가 병합하고 키를 새로 만든다. `keepKey`면 키를 유지한다(제출 시각 굳히기) */ onDraftChange: ( @@ -94,19 +101,21 @@ export function DepositFormPanel({ const amount = parseNumberInput(draft.amountRaw); /* 상한을 넘긴 입금액. 칸은 빨갛게, 라벨 아래 한 줄, 두 버튼 다 잠근다(#199) */ const amountOverMax = exceedsNumericMax(draft.amountRaw); + /* 총 사용 가능 = 입금액 + 남은 선수금. 입금액이 빈칸이면 null — 표가 잠긴다 */ + const available = availableTotal(amount, prepaid); const targets = allocationTargets(orders ?? []); const allocations = resolveAllocations( targets, draft.editedAllocations, - amount, + available, ); const total = allocationTotal(allocations); - /* 상한(미수·남은 입금액)을 넘긴 행마다 이유 한 줄. 값을 자르지 않고 말한다(#207 F4) */ - const issues = allocationIssues(targets, allocations, amount); + /* 상한(남은 미수·남은 사용 가능액)을 넘긴 행마다 이유 한 줄. 값을 자르지 않고 말한다(#207 F4) */ + const issues = allocationIssues(targets, allocations, available); const hasIssue = Object.keys(issues).length > 0; - const gapText = allocationGapText(amount, total); - /* 합계가 입금액을 넘긴 상태. 요약 숫자와 아래 한 줄을 빨갛게 — 미달은 허용이라 회색이다 */ - const overAllocated = amount !== null && total > amount; + const gapText = allocationGapText(available, total); + /* 합계가 사용 가능액을 넘긴 상태. 요약 숫자와 아래 한 줄을 빨갛게 — 미달은 허용이라 회색이다 */ + const overAllocated = available !== null && total > available; /* 서버 오류: `VALIDATION_FAILED`는 칸으로, 정책·상태 오류(400 코드·409·404·5xx)는 버튼 위 한 줄 */ const serverErrors = create.error @@ -149,13 +158,17 @@ export function DepositFormPanel({ /** 입금액을 안 적었거나 0이면 기록할 사실이 없다 — 두 버튼 모두 잠근다. 옛 숫자(`stale`)로도 안 보낸다 */ const canSubmit = amount !== null && amount > 0 && !amountOverMax && !busy && !stale; - /** 배분이 상한 안이고 입금액과 딱 맞을 때만 정산까지 간다. 미달·초과는 `입금만 진행`으로 남긴다 */ + /** + * 배분이 한 건이라도 있고 상한(남은 미수·사용 가능액) 안일 때 정산까지 간다. 예전엔 합계가 입금액과 **딱 맞아야** + * 했는데, 선수금 축이 생기며 그 규칙이 사라졌다(#138) — 덜 붙인 돈은 선수금으로 남아 3카드에 보이고, 옛 선수금을 + * 끌어 쓰면 합계가 입금액을 넘는 게 정상이다. 합계 0은 `입금만 진행`과 같은 뜻이라 그쪽 버튼만 연다 + */ const canSettle = canSubmit && orders !== null && targets.length > 0 && !hasIssue && - total === amount; + total > 0; const submit = (mode: DepositMode) => { if (amount === null || !canSubmit) return; @@ -297,7 +310,20 @@ export function DepositFormPanel({
- + + {/* 제목 오른쪽에 총 사용 가능(Figma 개정, #138). `Panel.Section`의 `title`은 문자열만 받아 제목 줄을 + 직접 그린다(같은 `mb-1 text-sm`). 입금액을 안 적었으면 아직 셀 수 없어 숫자를 안 보인다 */} +
+

주문별 배분

+ {available !== null ? ( + + 총 사용 가능{" "} + + {formatNumber(available)} + + + ) : null} +
{orders === null ? (

좌측에서 거래처를 펼치면 배분할 주문이 보여요 @@ -312,8 +338,8 @@ export function DepositFormPanel({ /> )} - {/* 요약 줄. 합계가 입금액과 어긋나면 그 아래 한 줄로 방향과 크기를 말한다 — - 미달은 허용(`입금만 진행`), 초과·상한 위반은 `입금 및 정산`이 잠긴다 */} + {/* 요약 줄 — 사용 가능액이 어디서 왔는지(입금액 + 선수금)와 이번 배분 합계. 합계가 사용 가능액과 어긋나면 + 그 아래 한 줄로 방향과 크기를 말한다 — 미달은 허용(선수금으로 남는다), 초과·상한 위반은 `입금 및 정산`이 잠긴다 */} {targets.length > 0 ? ( <>

@@ -321,8 +347,12 @@ export function DepositFormPanel({ {formatNumber(amount ?? 0)} + + 선수금 + + {formatNumber(prepaid)} + | - 배분 합계 + 이번 배분 void; /** 재조회 실패 시 `다시 불러오기`. 부모의 것 하나를 쓴다 — 우측 패널의 잠금도 같이 풀려야 한다 */ onRefresh: () => void; }) { @@ -34,7 +40,11 @@ export function PrepaidSummaryPanel({
- + ); @@ -43,13 +53,20 @@ export function PrepaidSummaryPanel({ /** 3카드. 안에서만 `useSuspenseQuery`를 부른다 */ function PrepaidCards({ retailerId, + onPrepaidChange, onRefresh, }: { retailerId: number; + onPrepaidChange: (prepaid: number | null) => void; onRefresh: () => void; }) { const { data, isRefetchError } = usePrepaidQuery(retailerId); + useEffect(() => { + onPrepaidChange(data.prepaid); + return () => onPrepaidChange(null); + }, [data.prepaid, onPrepaidChange]); + return ( <> {/* 캐시엔 값이 있는데 재조회만 실패한 상태 — 경계가 못 잡는 유일한 실패라 여기서 한 줄 */} diff --git a/apps/wholesale/src/features/settlement/components/SettlementListView.tsx b/apps/wholesale/src/features/settlement/components/SettlementListView.tsx index 5976cb1..bb160cc 100644 --- a/apps/wholesale/src/features/settlement/components/SettlementListView.tsx +++ b/apps/wholesale/src/features/settlement/components/SettlementListView.tsx @@ -59,6 +59,8 @@ export function SettlementListView() { ); /** 펼침 본문이 받은 확정 주문. 우측 배분 표가 쓴다. 본문이 내려가면 null */ const [orders, setOrders] = useState(null); + /** 3카드 패널이 받은 남은 선수금. 입금 폼의 `총 사용 가능`이 쓴다. 카드가 내려가거나 실패하면 null */ + const [prepaid, setPrepaid] = useState(null); /** * 입금 폼 입력, **소매처별.** 행을 접었다 펴도, 다른 거래처를 봤다 와도 적던 값이 남는다(⑥). * 입금이 등록되면 그 거래처 것만 지운다. @@ -109,6 +111,11 @@ export function SettlementListView() { [], ); + const handlePrepaidChange = useCallback( + (value: number | null) => setPrepaid(value), + [], + ); + /** 폼 입력 병합 + 새 멱등키. `keepKey`는 제출 시각을 칸에 굳힐 때만(같은 본문의 재전송이어야 하니 키를 지킨다) */ const updateDraft = ( retailerId: number, @@ -140,7 +147,7 @@ export function SettlementListView() { setNotice({ retailerName: created.retailerName, amount: created.amount, - unallocated: created.unallocatedAmount, + prepaidRemaining: created.prepaidRemaining, refreshed, }); }; @@ -171,12 +178,15 @@ export function SettlementListView() { updateDraft(openRetailer.id, patch, options) diff --git a/apps/wholesale/src/features/settlement/constants.ts b/apps/wholesale/src/features/settlement/constants.ts index 5dfc0c3..ad5d22f 100644 --- a/apps/wholesale/src/features/settlement/constants.ts +++ b/apps/wholesale/src/features/settlement/constants.ts @@ -155,9 +155,10 @@ export const DEPOSIT_ERROR_TEXT: Readonly> = { "지금 상태에서는 등록할 수 없어요. 목록을 새로 불러왔으니 확인한 뒤 다시 눌러 주세요.", ORDER_NOT_CONFIRMED: "확정되지 않은 주문에는 배분할 수 없어요. 목록을 새로 불러왔으니 확인한 뒤 다시 눌러 주세요.", - ALLOCATION_EXCEEDS_PAYMENT: "배분 합계가 입금액을 넘었어요.", + ALLOCATION_EXCEEDS_PAYMENT: + "배분 합계가 사용 가능액(입금액 + 남은 선수금)을 넘었어요. 선수금을 새로 불러왔으니 확인한 뒤 다시 눌러 주세요.", ALLOCATION_EXCEEDS_OUTSTANDING: - "미수보다 많이 배분한 주문이 있어요. 목록을 새로 불러왔으니 배분을 확인하고 다시 눌러 주세요.", + "남은 미수보다 많이 배분한 주문이 있어요. 목록을 새로 불러왔으니 배분을 확인하고 다시 눌러 주세요.", }; /** 계좌 요청의 칸 */ diff --git a/apps/wholesale/src/features/settlement/derive.ts b/apps/wholesale/src/features/settlement/derive.ts index 00cdb9d..b812861 100644 --- a/apps/wholesale/src/features/settlement/derive.ts +++ b/apps/wholesale/src/features/settlement/derive.ts @@ -376,7 +376,7 @@ export function allocationTargets( .sort((a, b) => a.orderedAtIso.localeCompare(b.orderedAtIso)); } -/** 배분 합계. 이 값이 입금액과 같아야 `입금 및 정산`을 누를 수 있다 */ +/** 배분 합계. 이 값이 0보다 크고 사용 가능액 안이어야 `입금 및 정산`을 누를 수 있다 */ export function allocationTotal( values: Readonly>, ): number { @@ -384,13 +384,26 @@ export function allocationTotal( } /** - * 표에 보일 배분값 = 사람이 고친 행은 그 값 그대로, 나머지 행은 **남은 입금액**으로 자동 배분(FIFO). + * 총 사용 가능 = **이번 입금액 + 남은 선수금**(스펙: 배분 합계 상한. 이번 입금을 먼저 쓰고 모자라면 오래된 입금부터 + * 끌어 쓴다). 입금액이 빈칸이면 `null` — 표가 잠긴다. 선수금만으로 정산하는 길은 따로 있다(`POST /allocations`). + */ +export function availableTotal( + amount: number | null, + prepaid: number, +): number | null { + return amount === null ? null : amount + prepaid; +} + +/** + * 표에 보일 배분값 = 사람이 고친 행은 그 값 그대로, 나머지 행은 **남은 사용 가능액**으로 자동 배분(FIFO). * **같은 규칙으로 요청을 만든다** — 표와 요청이 다른 값을 보면 안 된다. * - * 자동 배분은 입금액 전체가 아니라 `입금액 − 손댄 행의 합`을 위 행부터 미수만큼 채운다. 첫 행을 37,500→10,000으로 - * 줄이면 남은 27,500이 다음 행으로 흐른다(wire-settlement F4). 입금액이 바뀌어도 손댄 행은 그대로 두고 - * 자동 행만 다시 계산되므로 사람이 맞춘 배분이 한 글자 수정에 날아가지 않는다(F5, #207). - * 입금액이 미수 총합보다 크면 남는 돈은 어디에도 붙지 않는다(미배정 = 선수금). + * 자동 배분은 사용 가능액 전체가 아니라 `사용 가능액 − 손댄 행의 합`을 위 행부터 미수만큼 채운다. 첫 행을 + * 37,500→10,000으로 줄이면 남은 27,500이 다음 행으로 흐른다(wire-settlement F4). 입금액이 바뀌어도 손댄 행은 그대로 + * 두고 자동 행만 다시 계산되므로 사람이 맞춘 배분이 한 글자 수정에 날아가지 않는다(F5, #207). + * 사용 가능액이 미수 총합보다 크면 남는 돈은 어디에도 붙지 않는다(선수금으로 남는다). + * + * `available`은 입금 폼이면 `입금액 + 남은 선수금`, 선수금 정산이면 `남은 선수금`이다 — 상한이 무엇이든 채우는 규칙은 같다. * * 손댄 값은 여기서 **자르지 않는다.** 상한을 넘겼는지는 `allocationIssues`가 행마다 말한다 — 조용히 바꾸면 * 어느 행을 얼마나 고쳐야 하는지 화면이 말하지 않게 된다. @@ -398,13 +411,13 @@ export function allocationTotal( export function resolveAllocations( targets: readonly OrderRowView[], edited: Readonly>, - amount: number | null, + available: number | null, ): Record { const editedTotal = targets.reduce( (sum, order) => sum + (edited[order.id] ?? 0), 0, ); - let left = Math.max(0, (amount ?? 0) - editedTotal); + let left = Math.max(0, (available ?? 0) - editedTotal); const result: Record = {}; for (const order of targets) { const manual = edited[order.id]; @@ -422,48 +435,49 @@ export function resolveAllocations( /** * 행마다 상한을 넘긴 이유 한 줄. 넘긴 행만 담긴다 — 비어 있으면 배분 표가 서버 규칙 안이다. * - * 두 상한은 서버가 409로 거절하는 것 그대로다: 미수보다 많으면 `ALLOCATION_EXCEEDS_OUTSTANDING`, - * 합계가 입금액을 넘으면 `ALLOCATION_EXCEEDS_PAYMENT`. "남은 입금액"은 **이 행을 뺀 나머지 합**이 남긴 몫이라 - * 그 숫자로 줄이면 합계가 입금액에 딱 맞는다. `amount`가 없으면(빈칸) 표가 잠겨 있어 아무 말도 안 한다. + * 두 상한은 서버가 409로 거절하는 것 그대로다: 남은 미수보다 많으면 `ALLOCATION_EXCEEDS_OUTSTANDING`, + * 합계가 사용 가능액(입금액 + 남은 선수금)을 넘으면 `ALLOCATION_EXCEEDS_PAYMENT`(선수금 정산이면 + * `ALLOCATION_EXCEEDS_PREPAID`). "남은 사용 가능액"은 **이 행을 뺀 나머지 합**이 남긴 몫이라 그 숫자로 줄이면 + * 합계가 상한에 딱 맞는다. `available`이 없으면(입금액 빈칸) 표가 잠겨 있어 아무 말도 안 한다. */ export function allocationIssues( targets: readonly OrderRowView[], values: Readonly>, - amount: number | null, + available: number | null, ): Record { - if (amount === null) return {}; + if (available === null) return {}; const total = allocationTotal(values); const issues: Record = {}; for (const order of targets) { const value = values[order.id] ?? 0; if (value > order.outstanding) { - issues[order.id] = `미수 ${formatNumber(order.outstanding)}원까지`; + issues[order.id] = `남은 미수 ${formatNumber(order.outstanding)}원까지`; continue; } - const budget = Math.max(0, amount - (total - value)); + const budget = Math.max(0, available - (total - value)); if (value > budget) { - issues[order.id] = `남은 입금액 ${formatNumber(budget)}원까지`; + issues[order.id] = `남은 사용 가능액 ${formatNumber(budget)}원까지`; } } return issues; } /** - * 요약 줄 아래 한 줄 — 합계가 입금액과 어긋난 방향과 크기. 딱 맞으면 `null`. - * 모자란 건 허용이다(`입금만 진행`으로 남는다). 그래도 얼마가 어디에도 안 붙는지는 말해야 - * 사장이 채울지 남길지 정한다(wire-settlement F4). + * 요약 줄 아래 한 줄 — 합계가 사용 가능액과 어긋난 방향과 크기. 딱 맞으면 `null`. + * 모자란 건 허용이다 — 안 붙은 돈은 선수금으로 남고(스펙: `allocations`가 비면·모자라면 선수금) 3카드에 보인다. + * 그래도 얼마가 남는지는 말해야 사장이 채울지 남길지 정한다(wire-settlement F4). */ export function allocationGapText( - amount: number | null, + available: number | null, total: number, ): string | null { - if (amount === null) return null; - const gap = amount - total; + if (available === null) return null; + const gap = available - total; if (gap > 0) { - return `${formatNumber(gap)}원이 어느 주문에도 안 붙어요 — 배분을 채우거나 입금만 진행하세요`; + return `${formatNumber(gap)}원은 어느 주문에도 안 붙고 선수금으로 남아요`; } if (gap < 0) { - return `배분 합계가 입금액을 ${formatNumber(-gap)}원 넘었어요`; + return `배분 합계가 사용 가능액을 ${formatNumber(-gap)}원 넘었어요`; } return null; } @@ -596,12 +610,15 @@ export function isStaleRejection(error: unknown): boolean { return isApiError(error) && (error.status === 409 || error.status === 404); } -/** 입금 직후 문구. 재조회 실패면 옛 숫자임을 먼저 말한다(⑦) */ +/** + * 입금 직후 문구. 재조회 실패면 옛 숫자임을 먼저 말한다(⑦). + * 꼬리의 선수금은 응답 `prepaidRemaining`(등록 후 거래처 선수금 전체) — 3카드가 아직 옛 숫자여도 이 문구는 맞다. + */ export function noticeText(notice: SettlementNotice): string { const amount = `${formatNumber(notice.amount)}원`; const tail = - notice.unallocated > 0 - ? ` (미배정 ${formatNumber(notice.unallocated)}원)` + notice.prepaidRemaining > 0 + ? ` (선수금 ${formatNumber(notice.prepaidRemaining)}원 남음)` : ""; return notice.refreshed ? `${notice.retailerName}에 입금 ${amount} 등록했어요${tail} — 미수원장에서 확인하세요` diff --git a/apps/wholesale/src/features/settlement/types.ts b/apps/wholesale/src/features/settlement/types.ts index c9f8896..c43b534 100644 --- a/apps/wholesale/src/features/settlement/types.ts +++ b/apps/wholesale/src/features/settlement/types.ts @@ -170,8 +170,11 @@ export type DepositField = keyof PaymentCreateRequest; export interface SettlementNotice { retailerName: string; amount: number; - /** 어느 주문에도 안 붙은 금액(서버 `unallocatedAmount`). 선수금 칸이 화면에 없어 문구로만 */ - unallocated: number; + /** + * 등록 뒤 거래처 선수금 전체(서버 `prepaidRemaining`). `unallocatedAmount`(이번 입금에서 안 쓴 돈)가 아니다 — + * 이번 배분이 옛 선수금을 끌어 썼으면 둘이 다르고, 사장이 알아야 할 건 지금 남은 돈이다 + */ + prepaidRemaining: number; refreshed: boolean; } From 0c59e422db470561ab66f389196098e8e32fec22 Mon Sep 17 00:00:00 2001 From: OhChangEun <131477920+OhChangEun@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:09:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(wholesale):=20=EC=84=A0=EC=88=98?= =?UTF-8?q?=EA=B8=88=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EC=82=B0=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EA=B3=BC=20=EC=9B=90=EC=9E=A5=20=EC=9E=85=EA=B8=88=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=B6=94=EA=B0=80=20#138?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/settlement/api/mutations.ts | 84 +++++++ .../src/features/settlement/api/queries.ts | 3 + .../components/PaymentVoidDialog.tsx | 113 ++++++++++ .../components/PrepaidAllocationPanel.tsx | 210 ++++++++++++++++++ .../components/PrepaidSummaryPanel.tsx | 27 ++- .../components/ReceivableLedgerTable.tsx | 30 ++- .../components/SettlementListView.tsx | 130 +++++++++-- .../components/SettlementSegmentView.tsx | 48 +++- .../src/features/settlement/constants.ts | 15 ++ .../src/features/settlement/derive.ts | 107 +++++++-- .../src/features/settlement/types.ts | 34 ++- apps/wholesale/src/shared/api/errorCodes.ts | 2 + 12 files changed, 759 insertions(+), 44 deletions(-) create mode 100644 apps/wholesale/src/features/settlement/components/PaymentVoidDialog.tsx create mode 100644 apps/wholesale/src/features/settlement/components/PrepaidAllocationPanel.tsx diff --git a/apps/wholesale/src/features/settlement/api/mutations.ts b/apps/wholesale/src/features/settlement/api/mutations.ts index 1a43510..bc9a1eb 100644 --- a/apps/wholesale/src/features/settlement/api/mutations.ts +++ b/apps/wholesale/src/features/settlement/api/mutations.ts @@ -6,11 +6,15 @@ import { settlementKeys } from "./keys"; import { SETTLEMENT_PATH } from "./queries"; import { isStaleRejection } from "../derive"; import type { + AllocationCreated, + AllocationCreateRequest, BankAccount, BankAccountCreateRequest, BankAccountUpdateRequest, PaymentCreated, PaymentCreateRequest, + PaymentVoided, + PaymentVoidRequest, } from "../types"; /** @@ -96,6 +100,86 @@ export function useCreatePaymentMutation({ onDone }: PaymentDone = {}) { }); } +/** + * 선수금 정산 뒤 낡는 것 — 그 소매처의 주문 미수·정산 상태와 선수금 3카드. 원장은 안 바뀐다(스펙: 돈은 그대로 + * 받은 상태) — 소매처 행의 미수 잔액도 원장 잔액이라 그대로다. 그래도 표와 카드는 같은 돈을 보므로 둘을 같이 받는다. + */ +function refetchAfterAllocation(queryClient: QueryClient, retailerId: number) { + return refetch(queryClient, [ + settlementKeys.orders(retailerId), + settlementKeys.prepaid(retailerId), + ]); +} + +export interface AllocationDone { + onDone?: (created: AllocationCreated, refreshed: boolean) => void; +} + +export interface AllocationVariables { + body: AllocationCreateRequest; + /** 입금 등록과 같은 규칙 — 입력이 바뀌면 새 키, 같은 입력의 재전송은 같은 키(`AllocationDraft.idempotencyKey`) */ + idempotencyKey: string; +} + +/** 선수금으로 정산(`POST /allocations`, Idempotency-Key 필수). 새 입금 없이 남은 선수금을 출고된 주문에 붙인다 */ +export function useCreateAllocationMutation({ onDone }: AllocationDone = {}) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ body, idempotencyKey }: AllocationVariables) => + apiFetch(SETTLEMENT_PATH.allocations, { + method: "POST", + body, + idempotencyKey, + }), + onSuccess: async (created, { body }) => { + const refreshed = await refetchAfterAllocation( + queryClient, + body.retailerId, + ); + onDone?.(created, refreshed); + }, + onError: (error, { body }) => + isStaleRejection(error) + ? refetchAfterAllocation(queryClient, body.retailerId) + : undefined, + }); +} + +export interface PaymentVoidDone { + onDone?: (voided: PaymentVoided, refreshed: boolean) => void; +} + +export interface PaymentVoidVariables { + paymentId: number; + /** 무효화할 키를 고르려고 받는다 — 응답에 소매처가 없다 */ + retailerId: number; + body: PaymentVoidRequest; +} + +/** + * 입금 취소(`POST /payments/{id}/void`). 그 입금의 배분이 전부 풀리고 원장에 취소 줄이 쌓이므로 + * 입금 등록과 같은 넷(행·주문·원장·선수금)을 다시 받는다. 되돌릴 수 없다(스펙) — 다이얼로그가 한 번 막는다. + */ +export function useVoidPaymentMutation({ onDone }: PaymentVoidDone = {}) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ paymentId, body }: PaymentVoidVariables) => + apiFetch(SETTLEMENT_PATH.paymentVoid(paymentId), { + method: "POST", + body, + }), + onSuccess: async (voided, { retailerId }) => { + const refreshed = await refetchAfterPayment(queryClient, retailerId); + onDone?.(voided, refreshed); + }, + // 404(이미 없는 입금)·409(이미 취소된 입금)면 원장이 낡은 것 — 다시 받아 그 줄의 `취소`가 사라지게 한다 + onError: (error, { retailerId }) => + isStaleRejection(error) + ? refetchAfterPayment(queryClient, retailerId) + : undefined, + }); +} + /** * 재조회가 실패했을 때 `다시 불러오기`가 부른다 — 이 탭 키 전부. 활성 관찰자만 다시 부른다. */ diff --git a/apps/wholesale/src/features/settlement/api/queries.ts b/apps/wholesale/src/features/settlement/api/queries.ts index 29b84bd..03ccfc2 100644 --- a/apps/wholesale/src/features/settlement/api/queries.ts +++ b/apps/wholesale/src/features/settlement/api/queries.ts @@ -41,6 +41,9 @@ export const SETTLEMENT_PATH = { prepaid: (retailerId: number) => `/api/wholesale/receivables/retailers/${retailerId}/prepaid`, payments: "/api/wholesale/payments", + paymentVoid: (paymentId: number) => + `/api/wholesale/payments/${paymentId}/void`, + allocations: "/api/wholesale/allocations", orders: "/api/wholesale/orders", bankAccounts: "/api/wholesale/bank-accounts", bankAccount: (bankAccountId: number) => diff --git a/apps/wholesale/src/features/settlement/components/PaymentVoidDialog.tsx b/apps/wholesale/src/features/settlement/components/PaymentVoidDialog.tsx new file mode 100644 index 0000000..875dd04 --- /dev/null +++ b/apps/wholesale/src/features/settlement/components/PaymentVoidDialog.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { Button, Dialog, FormField, Textarea } from "@ondo/ui"; +import { useId, useState } from "react"; +import { useVoidPaymentMutation } from "../api/mutations"; +import { PAYMENT_VOID_REASON_MAX } from "../constants"; +import { canVoid, paymentVoidErrorText, toPaymentVoidRequest } from "../derive"; +import type { LedgerRowView, PaymentVoided } from "../types"; +import { formatNumber } from "@/shared/lib/format"; + +/** + * 입금 취소 확인 + 사유(`POST /payments/{id}/void`, 사유 필수). + * + * 취소는 **되돌릴 수 없다**(스펙: 필요하면 입금을 새로 등록한다) — 그 입금에서 나간 배분이 전부 풀려 주문 미수가 + * 다시 생기고, 남았던 선수금도 사라진다. 그래서 계좌 삭제처럼 한 번 막고, 빨강은 이 마지막 확인에만 쓴다. + * + * 열릴 때마다 새로 만들어진다(부모가 대상이 있을 때만 그린다) — 사유가 이전 입금 것으로 남지 않는다. + * `onDone`은 훅 옵션이다: 재조회로 원장이 다시 그려지며 이 다이얼로그가 내려가도 부모에게 결과가 간다. + */ +export function PaymentVoidDialog({ + row, + retailerId, + onClose, + onDone, +}: { + /** 취소할 입금 줄. `paymentId`가 있는 `PAYMENT` 줄만 온다 */ + row: LedgerRowView; + retailerId: number; + onClose: () => void; + onDone: (voided: PaymentVoided, refreshed: boolean) => void; +}) { + const [reason, setReason] = useState(""); + const reasonId = useId(); + const cancel = useVoidPaymentMutation({ + onDone: (voided, refreshed) => { + onClose(); + onDone(voided, refreshed); + }, + }); + const error = cancel.error ? paymentVoidErrorText(cancel.error) : null; + const overMax = reason.trim().length > PAYMENT_VOID_REASON_MAX; + + return ( + (open ? undefined : onClose())}> + + 입금 취소 + +
+ {row.date} 입금{" "} + {formatNumber(row.amount)}원을 + 취소합니다. 이 입금으로 붙인 배분이 전부 풀리고 미수가 다시 + 생깁니다. +
+ 되돌릴 수 없습니다. +
+
+ + +