Skip to content

Commit 58b0f2a

Browse files
committed
feat: 주문의 알림 전송 기능 관련 UI 구현 및 개선
1 parent 2bf4f49 commit 58b0f2a

8 files changed

Lines changed: 689 additions & 35 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,50 @@
1+
import { NotificationChannelValue } from "@frontend/common/schemas/backendAdminAPI";
2+
13
// 알림 채널별 admin 라우트 설정. 라우트 컨벤션(<app>/<model_name>)에 따라 app 은 모두 "notification",
24
// resource 는 채널·종류별 모델명을 사용한다.
35
export type NotificationChannelKind = "email" | "kakao" | "sms";
46

57
export type NotificationChannel = {
68
app: string;
79
kind: NotificationChannelKind;
10+
label: string;
11+
// 백엔드 API 가 body 로 받는 채널 식별자 (notification.channels.NotificationChannel).
12+
value: NotificationChannelValue;
813
templateResource: string;
914
historyResource: string;
1015
};
1116

1217
export const EMAIL_CHANNEL: NotificationChannel = {
1318
app: "notification",
1419
kind: "email",
20+
label: "이메일",
21+
value: "email",
1522
templateResource: "emailnotificationtemplate",
1623
historyResource: "emailnotificationhistory",
1724
};
1825

1926
export const KAKAO_CHANNEL: NotificationChannel = {
2027
app: "notification",
2128
kind: "kakao",
29+
label: "카카오 알림톡",
30+
value: "nhn_cloud_kakao_alimtalk",
2231
templateResource: "nhncloudkakaoalimtalknotificationtemplate",
2332
historyResource: "nhncloudkakaoalimtalknotificationhistory",
2433
};
2534

2635
export const SMS_CHANNEL: NotificationChannel = {
2736
app: "notification",
2837
kind: "sms",
38+
label: "SMS",
39+
value: "nhn_cloud_sms",
2940
templateResource: "nhncloudsmsnotificationtemplate",
3041
historyResource: "nhncloudsmsnotificationhistory",
3142
};
43+
44+
export const NOTIFICATION_CHANNELS: NotificationChannel[] = [EMAIL_CHANNEL, SMS_CHANNEL, KAKAO_CHANNEL];
45+
46+
export const CHANNEL_BY_VALUE: Record<NotificationChannelValue, NotificationChannel> = {
47+
email: EMAIL_CHANNEL,
48+
nhn_cloud_sms: SMS_CHANNEL,
49+
nhn_cloud_kakao_alimtalk: KAKAO_CHANNEL,
50+
};

apps/pyconkr-admin/src/components/pages/notification/send_history_create.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Fieldset } from "@frontend/common/components";
22
import { useBackendAdminClient, useCreateMutation, useListPaginatedQuery, useRenderTemplateMutation } from "@frontend/common/hooks/useAdminAPI";
3+
import { NotificationTemplateSchema } from "@frontend/common/schemas/backendAdminAPI";
34
import { Add, Close, Delete, Send, Visibility } from "@mui/icons-material";
45
import {
56
Box,
@@ -46,17 +47,6 @@ type NotificationHistoryCreateRequest = {
4647
sent_to_list: NotificationHistorySentToCreateRequest[];
4748
};
4849

49-
type NotificationTemplateSchema = {
50-
id: string;
51-
str_repr: string;
52-
code: string;
53-
title: string;
54-
description: string;
55-
data: string;
56-
sent_from: string;
57-
template_variables: string[];
58-
};
59-
6050
type OnMemorySentTo = {
6151
trackId: string;
6252
recipient: string;

apps/pyconkr-admin/src/components/pages/shop/order/editor.tsx

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fal
2828
import { ORDER_PRODUCT_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "@apps/pyconkr-admin/components/pages/shop/_common/status_labels";
2929
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
3030

31+
import { OrderNotificationDialog } from "./notification_dialog";
3132
import { RefundDialog } from "./refund_dialog";
3233
import { OrderAdmin, SimpleCustomerInfo, SimpleOrderProductRelation } from "./types";
3334

@@ -94,6 +95,7 @@ const CustomerInfoTab: FC<{ order: OrderAdmin }> = ({ order }) => {
9495
const OrderProductRow: FC<{ order: OrderAdmin; relation: SimpleOrderProductRelation }> = ({ order, relation }) => {
9596
const status = ORDER_PRODUCT_STATUS_LABEL[relation.status];
9697
const [refundOpen, setRefundOpen] = useState(false);
98+
const [notifyOpen, setNotifyOpen] = useState(false);
9799
const canRefund = canRefundProduct(relation);
98100
return (
99101
<>
@@ -108,16 +110,21 @@ const OrderProductRow: FC<{ order: OrderAdmin; relation: SimpleOrderProductRelat
108110
<TableCell align="right">{formatPrice(relation.price)}</TableCell>
109111
<TableCell align="right">{relation.donation_price > 0 ? formatPrice(relation.donation_price) : "—"}</TableCell>
110112
<TableCell align="right">
111-
<Button
112-
size="small"
113-
variant="outlined"
114-
color="error"
115-
startIcon={<CurrencyExchange />}
116-
onClick={() => setRefundOpen(true)}
117-
disabled={!canRefund}
118-
>
119-
환불
120-
</Button>
113+
<Stack direction="row" spacing={1} justifyContent="flex-end">
114+
<Button size="small" variant="outlined" startIcon={<NotificationsActive />} onClick={() => setNotifyOpen(true)}>
115+
알림
116+
</Button>
117+
<Button
118+
size="small"
119+
variant="outlined"
120+
color="error"
121+
startIcon={<CurrencyExchange />}
122+
onClick={() => setRefundOpen(true)}
123+
disabled={!canRefund}
124+
>
125+
환불
126+
</Button>
127+
</Stack>
121128
</TableCell>
122129
</TableRow>
123130
<TableRow>
@@ -197,6 +204,7 @@ const OrderProductRow: FC<{ order: OrderAdmin; relation: SimpleOrderProductRelat
197204
</TableCell>
198205
</TableRow>
199206
<RefundDialog open={refundOpen} onClose={() => setRefundOpen(false)} order={order} kind="product" relation={relation} />
207+
{notifyOpen && <OrderNotificationDialog scope={{ kind: "orderProduct", orderProductId: relation.id }} onClose={() => setNotifyOpen(false)} />}
200208
</>
201209
);
202210
};
@@ -289,6 +297,7 @@ const InnerOrderEditor: FC = ErrorBoundary.with(
289297
const client = useBackendAdminClient();
290298
const [tab, setTab] = useState(0);
291299
const [refundOpen, setRefundOpen] = useState(false);
300+
const [notifyOpen, setNotifyOpen] = useState(false);
292301

293302
const orderQuery = useRetrieveQuery<OrderAdmin>(client, "shop", "order", id ?? "");
294303
const order = orderQuery.data;
@@ -308,13 +317,6 @@ const InnerOrderEditor: FC = ErrorBoundary.with(
308317
const status = PAYMENT_STATUS_LABEL[order.current_status] ?? { label: order.current_status, color: "default" as const };
309318
const canRefund = canRefundOrder(order);
310319

311-
const onNotify = () => {
312-
navigate({
313-
pathname: "/notification/notification/create",
314-
search: `?order_id=${order.id}`,
315-
});
316-
};
317-
318320
return (
319321
<Stack sx={{ flexGrow: 1, width: "100%", minHeight: "100%" }} spacing={3}>
320322
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
@@ -341,7 +343,7 @@ const InnerOrderEditor: FC = ErrorBoundary.with(
341343
</Stack>
342344

343345
<Stack direction="row" spacing={1}>
344-
<Button variant="outlined" startIcon={<NotificationsActive />} onClick={onNotify}>
346+
<Button variant="outlined" startIcon={<NotificationsActive />} onClick={() => setNotifyOpen(true)}>
345347
알림 발송
346348
</Button>
347349
<Button variant="contained" color="error" startIcon={<CurrencyExchange />} onClick={() => setRefundOpen(true)} disabled={!canRefund}>
@@ -400,6 +402,7 @@ const InnerOrderEditor: FC = ErrorBoundary.with(
400402
</Box>
401403

402404
<RefundDialog open={refundOpen} onClose={() => setRefundOpen(false)} order={order} kind="order" />
405+
{notifyOpen && <OrderNotificationDialog scope={{ kind: "order", orderId: order.id }} onClose={() => setNotifyOpen(false)} />}
403406
</Stack>
404407
);
405408
})

apps/pyconkr-admin/src/components/pages/shop/order/list.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useBackendAdminClient, useListPaginatedQuery } from "@frontend/common/hooks/useAdminAPI";
2-
import { FileDownload, FileUpload, RestartAlt } from "@mui/icons-material";
2+
import { FileDownload, FileUpload, RestartAlt, Send } from "@mui/icons-material";
33
import {
44
Button,
55
Chip,
@@ -26,6 +26,7 @@ import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fal
2626
import { PAYMENT_STATUS_LABEL } from "@apps/pyconkr-admin/components/pages/shop/_common/status_labels";
2727
import { OrderExportDialog } from "@apps/pyconkr-admin/components/pages/shop/order/export_dialog";
2828
import { OrderImportDialog } from "@apps/pyconkr-admin/components/pages/shop/order/import_dialog";
29+
import { OrderNotificationDialog } from "@apps/pyconkr-admin/components/pages/shop/order/notification_dialog";
2930
import { CategoryGroupAdminWithCategories } from "@apps/pyconkr-admin/components/pages/shop/product/types";
3031

3132
import { OrderAdmin, PaymentStatus } from "./types";
@@ -84,23 +85,26 @@ const InnerOrderList: FC = ErrorBoundary.with(
8485
const page = Number(searchParams.get("page") ?? 1);
8586
const pageSize = Number(searchParams.get("page_size") ?? DEFAULT_PAGE_SIZE);
8687

87-
// apiParams derives from the URL (the "applied" state); local filter inputs only update the URL on Apply.
88-
const apiParams: Record<string, string> = { page: String(page), page_size: String(pageSize) };
88+
// filterParams derives from the URL (the "applied" state); local filter inputs only update the URL on Apply.
89+
// 알림 발송은 페이지네이션 없이 같은 필터 전체를 대상으로 하므로 page/page_size 와 분리해 둔다.
90+
const filterParams: Record<string, string> = {};
8991
for (const key of FILTER_KEYS) {
9092
const value = searchParams.get(key);
9193
if (!value) continue;
9294
if (key === "status" && value === "all") continue;
9395
if (key === "name" || key === "email" || key === "imp_id") {
9496
const trimmed = value.trim();
95-
if (trimmed) apiParams[key] = trimmed;
97+
if (trimmed) filterParams[key] = trimmed;
9698
} else {
97-
apiParams[key] = value;
99+
filterParams[key] = value;
98100
}
99101
}
102+
const apiParams: Record<string, string> = { page: String(page), page_size: String(pageSize), ...filterParams };
100103

101104
const [filters, setFilters] = useState<FilterState>(() => readFilters(searchParams));
102105
const [exportDialogOpen, setExportDialogOpen] = useState(false);
103106
const [importDialogOpen, setImportDialogOpen] = useState(false);
107+
const [notificationDialogOpen, setNotificationDialogOpen] = useState(false);
104108

105109
// Re-sync local form state when the URL changes externally (browser back/forward, pagination).
106110
useEffect(() => {
@@ -284,10 +288,17 @@ const InnerOrderList: FC = ErrorBoundary.with(
284288
<Button variant="outlined" size="small" startIcon={<FileDownload />} onClick={() => setExportDialogOpen(true)}>
285289
내보내기
286290
</Button>
291+
<Button variant="outlined" size="small" color="secondary" startIcon={<Send />} onClick={() => setNotificationDialogOpen(true)}>
292+
알림 발송
293+
</Button>
287294
</Stack>
288295

289296
<OrderImportDialog open={importDialogOpen} onClose={() => setImportDialogOpen(false)} />
290297
<OrderExportDialog open={exportDialogOpen} onClose={() => setExportDialogOpen(false)} />
298+
{/* 열릴 때만 마운트 — 템플릿 목록 조회와 폼 상태를 매번 새로 시작한다. */}
299+
{notificationDialogOpen && (
300+
<OrderNotificationDialog scope={{ kind: "orderFilter", params: filterParams }} onClose={() => setNotificationDialogOpen(false)} />
301+
)}
291302

292303
<Table>
293304
<TableHead>

0 commit comments

Comments
 (0)