@@ -16,7 +16,6 @@ import {
1616 Alert ,
1717 Box ,
1818 Button ,
19- Checkbox ,
2019 Chip ,
2120 CircularProgress ,
2221 Dialog ,
@@ -25,11 +24,8 @@ import {
2524 DialogTitle ,
2625 Divider ,
2726 FormControl ,
28- FormControlLabel ,
2927 InputLabel ,
30- ListItemText ,
3128 MenuItem ,
32- OutlinedInput ,
3329 Select ,
3430 Stack ,
3531 Tab ,
@@ -48,11 +44,15 @@ import { ErrorBoundary, Suspense } from "@suspensive/react";
4844import { FC , useMemo , useState } from "react" ;
4945import { useNavigate } from "react-router-dom" ;
5046
51- import { ChoicePicker } from "@apps/pyconkr-admin/components/elements/choice_picker" ;
5247import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback" ;
5348import { CHANNEL_BY_VALUE , NOTIFICATION_CHANNELS , NotificationChannel } from "@apps/pyconkr-admin/components/pages/notification/channels" ;
54- import { ORDER_PRODUCT_STATUS_LABEL } from "@apps/pyconkr-admin/components/pages/shop/_common/status_labels" ;
55- import { OrderProductStatus } from "@apps/pyconkr-admin/components/pages/shop/order/types" ;
49+ import {
50+ EMPTY_PRODUCT_FILTER ,
51+ ProductFilterState ,
52+ productFilterParams ,
53+ toOrderProductParams ,
54+ } from "@apps/pyconkr-admin/components/pages/shop/order/order_product_filters" ;
55+ import { ProductFilterFields } from "@apps/pyconkr-admin/components/pages/shop/order/product_filter_fields" ;
5656import { addErrorSnackbar , addSnackbar } from "@apps/pyconkr-admin/utils/snackbar" ;
5757
5858const PREVIEW_ROW_LIMIT = 20 ;
@@ -76,38 +76,6 @@ const SEND_MODES: SendMode[] = [
7676 } ,
7777] ;
7878
79- // 백엔드 queryset 이 PURCHASED_OR_REFUNDED_STATUS 로 미리 좁히므로 pending 은 애초에 대상이 아니다.
80- const SELECTABLE_OPR_STATUSES : OrderProductStatus [ ] = [ "paid" , "used" , "refunded" ] ;
81-
82- // 상품 단위 filterset(OrderProductRelationAdminFilterSet)은 주문 filterset 과 키가 다르다.
83- // `status` 는 양쪽에 다 있지만 의미가 다르다 — 주문에선 결제 상태, 상품에선 상품 상태(pending/paid/used/refunded).
84- // django-filter 는 모르는 키를 조용히 무시하므로, 넘길 키를 명시한 allowlist 로 둔다.
85- // 주문 목록에 필터가 새로 생겨도 여기 없으면 자동으로 dropped 에 잡혀 경고로 노출된다 (대상이 몰래 넓어지지 않음).
86- const ORDER_PRODUCT_PARAM_BY_ORDER_PARAM : Record < string , string > = {
87- id : "order_id" ,
88- user_id : "user_id" ,
89- user_unique_id : "user_unique_id" ,
90- name : "name" ,
91- email : "email" ,
92- imp_id : "imp_id" ,
93- status : "order_status" ,
94- first_paid_at_after : "first_paid_at_after" ,
95- first_paid_at_before : "first_paid_at_before" ,
96- product_id : "product_id" ,
97- category_id : "category_id" ,
98- category_group_id : "category_group_id" ,
99- event_id : "event_id" ,
100- // price_min/max 는 의도적으로 제외 — 주문에선 주문 총액, 상품에선 상품 단가라 뜻이 달라 그대로 넘기면 안 된다.
101- } ;
102-
103- type ProductFilterState = {
104- productIds : ( string | number ) [ ] ;
105- statuses : OrderProductStatus [ ] ;
106- ticketOnly : boolean ;
107- } ;
108-
109- const EMPTY_PRODUCT_FILTER : ProductFilterState = { productIds : [ ] , statuses : [ ] , ticketOnly : false } ;
110-
11179/** 발송 대상 범위. 호출 위치(목록 / 주문 상세 / 주문 상품 행)마다 지정할 수 있는 범위가 다르다. */
11280export type OrderNotificationScope =
11381 | { kind : "orderFilter" ; params : Record < string , string > }
@@ -123,12 +91,6 @@ const SCOPE_TITLE: Record<OrderNotificationScope["kind"], string> = {
12391// 특정 상품 1건을 지목한 경우엔 주문 단위 발송이 의미가 없다 (수신자가 주문자로 바뀌어 버린다).
12492const modesForScope = ( kind : OrderNotificationScope [ "kind" ] ) : SendMode [ ] => ( kind === "orderProduct" ? [ SEND_MODES [ 1 ] ] : SEND_MODES ) ;
12593
126- const productFilterParams = ( productFilter : ProductFilterState ) : Record < string , string > => ( {
127- ...( productFilter . productIds . length ? { product_id : productFilter . productIds . join ( "," ) } : { } ) ,
128- ...( productFilter . statuses . length ? { status : productFilter . statuses . join ( "," ) } : { } ) ,
129- ...( productFilter . ticketOnly ? { is_ticket : "true" } : { } ) ,
130- } ) ;
131-
13294/** scope + 발송 단위 → 실제로 보낼 query params. `dropped` 는 상품 filterset 이 지원하지 않아 무시된 주문 필터 키. */
13395const buildRequestParams = (
13496 scope : OrderNotificationScope ,
@@ -147,13 +109,7 @@ const buildRequestParams = (
147109 } ;
148110 case "orderFilter" : {
149111 if ( ! isProductTarget ) return { params : scope . params , dropped : [ ] } ;
150- const params : Record < string , string > = { } ;
151- const dropped : string [ ] = [ ] ;
152- for ( const [ key , value ] of Object . entries ( scope . params ) ) {
153- const mappedKey = ORDER_PRODUCT_PARAM_BY_ORDER_PARAM [ key ] ;
154- if ( mappedKey ) params [ mappedKey ] = value ;
155- else dropped . push ( key ) ;
156- }
112+ const { params, dropped } = toOrderProductParams ( scope . params ) ;
157113 return { params : { ...params , ...productFilterParams ( productFilter ) } , dropped } ;
158114 }
159115 }
@@ -187,68 +143,6 @@ const TargetSummary: FC<{ params: Record<string, string>; dropped: string[]; des
187143 ) ;
188144} ;
189145
190- type ProductFilterFieldsProps = {
191- value : ProductFilterState ;
192- onChange : ( next : ProductFilterState ) => void ;
193- } ;
194-
195- // ChoicePicker 는 caption 라벨을 컨트롤 위에 그리고 Select 는 라벨을 테두리에 얹기 때문에 두 컨트롤의 높이가 다르다.
196- // 아래쪽 기준(flex-end)으로 맞추고 체크박스도 컨트롤 높이(small = 40px)에 고정해 세 필드의 밑선을 일치시킨다.
197- const FILTER_CONTROL_HEIGHT = 40 ;
198-
199- const ProductFilterFields : FC < ProductFilterFieldsProps > = ( { value, onChange } ) => (
200- < Stack spacing = { 1 } >
201- < Typography variant = "caption" color = "text.secondary" >
202- 주문 목록에서 적용한 필터에 더해 상품 조건으로 좁힙니다.
203- </ Typography >
204- < Stack direction = "row" spacing = { 2 } alignItems = "flex-end" flexWrap = "wrap" useFlexGap >
205- < Box sx = { { flex : 1 , minWidth : 280 } } >
206- { /* selectables 를 suspense 로 조회하므로 자체 경계가 필요 — 없으면 다이얼로그 전체가 fallback 으로 교체된다.
207- fallback 도 같은 높이를 차지해야 로딩 완료 시 옆 필드들이 밀리지 않는다. */ }
208- < Suspense
209- fallback = {
210- < Box sx = { { height : FILTER_CONTROL_HEIGHT , display : "flex" , alignItems : "center" } } >
211- < CircularProgress size = { 20 } />
212- </ Box >
213- }
214- >
215- < ChoicePicker
216- multiple
217- label = "상품"
218- source = { { app : "shop" , resource : "product" } }
219- value = { value . productIds }
220- onChange = { ( productIds ) => onChange ( { ...value , productIds } ) }
221- />
222- </ Suspense >
223- </ Box >
224- < FormControl size = "small" sx = { { minWidth : 200 } } >
225- < InputLabel id = "order-noti-opr-status" > 상품 상태</ InputLabel >
226- < Select
227- labelId = "order-noti-opr-status"
228- multiple
229- input = { < OutlinedInput label = "상품 상태" /> }
230- value = { value . statuses }
231- onChange = { ( e ) => onChange ( { ...value , statuses : e . target . value as OrderProductStatus [ ] } ) }
232- renderValue = { ( selected ) => selected . map ( ( s ) => ORDER_PRODUCT_STATUS_LABEL [ s ] . label ) . join ( ", " ) || "전체" }
233- >
234- { SELECTABLE_OPR_STATUSES . map ( ( s ) => (
235- < MenuItem key = { s } value = { s } >
236- < Checkbox size = "small" checked = { value . statuses . includes ( s ) } />
237- < ListItemText primary = { ORDER_PRODUCT_STATUS_LABEL [ s ] . label } />
238- </ MenuItem >
239- ) ) }
240- </ Select >
241- </ FormControl >
242- < FormControlLabel
243- sx = { { height : FILTER_CONTROL_HEIGHT , mr : 0 } }
244- control = { < Checkbox size = "small" checked = { value . ticketOnly } onChange = { ( e ) => onChange ( { ...value , ticketOnly : e . target . checked } ) } /> }
245- label = "티켓 상품만"
246- slotProps = { { typography : { variant : "body2" } } }
247- />
248- </ Stack >
249- </ Stack >
250- ) ;
251-
252146// ErrorBoundary.with() 대신 명명 컴포넌트 + 인라인 경계 — HMR 시 입력 필드가 detach 되는 것을 막는다.
253147const NotificationDialogBody : FC < NotificationDialogBodyProps > = ( { scope, onClose } ) => {
254148 const client = useBackendAdminClient ( ) ;
0 commit comments