Skip to content

Commit 7bb7641

Browse files
committed
feat: 등록 데스크 시스템 설정 대응 추가
1 parent 4f7622d commit 7bb7641

16 files changed

Lines changed: 546 additions & 122 deletions

File tree

apps/pyconkr-admin/src/components/layouts/admin_editor.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { ChoicePicker } from "@apps/pyconkr-admin/components/elements/choice_pic
5252
import { ChoicePickerWidget } from "@apps/pyconkr-admin/components/elements/choice_picker_widget";
5353
import { ColorPickerWidget } from "@apps/pyconkr-admin/components/elements/color_picker_widget";
5454
import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback";
55+
import { IMAGE_FILE_EXTENSIONS } from "@apps/pyconkr-admin/consts/file_extensions";
5556
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
5657

5758
type EditorFormDataEventType = IChangeEvent<Record<string, string>, RJSFSchema, { [k in string]: unknown }>;
@@ -167,6 +168,15 @@ const MDEditorField: Field = ErrorBoundary.with({ fallback: ErrorFallback }, ({
167168
);
168169
});
169170

171+
const isImageUrl = (url: string): boolean => {
172+
try {
173+
const path = new URL(url).pathname.toLowerCase();
174+
return IMAGE_FILE_EXTENSIONS.some((ext) => path.endsWith(`.${ext}`));
175+
} catch {
176+
return false;
177+
}
178+
};
179+
170180
type ReadOnlyValueFieldStateType = {
171181
loading: boolean;
172182
blob: Blob | null;
@@ -188,15 +198,18 @@ const ReadOnlyValueField: FC<{
188198

189199
useEffect(() => {
190200
(async () => {
191-
if (!(isString(value) && value.startsWith("http") && uiSchema?.[name]["ui:field"] === "file")) {
201+
// ui:field 가 없는 필드도 있다 (`uiSchema[name]` 자체가 undefined) — 옵셔널 체이닝을 빼면 여기서 던지고
202+
// loading 이 true 로 남아 스피너만 계속 돈다. fetch 실패도 마찬가지라 try/finally 로 반드시 내린다.
203+
try {
204+
if (!(isString(value) && value.startsWith("http") && uiSchema?.[name]?.["ui:field"] === "file")) return;
205+
206+
const blob = await (await fetch(value)).blob();
207+
const blobText = await blob.text();
208+
const objectUrl = URL.createObjectURL(blob);
209+
setFieldState((ps) => ({ ...ps, blob, blobText, objectUrl }));
210+
} finally {
192211
setFieldState((ps) => ({ ...ps, loading: false }));
193-
return;
194212
}
195-
196-
const blob = await (await fetch(value)).blob();
197-
const blobText = await blob.text();
198-
const objectUrl = URL.createObjectURL(blob);
199-
setFieldState((ps) => ({ ...ps, loading: false, blob, blobText, objectUrl }));
200213
})();
201214
}, [value, name, uiSchema]);
202215

@@ -219,6 +232,17 @@ const ReadOnlyValueField: FC<{
219232
}
220233

221234
if (value === null || value === undefined) return "";
235+
// ui:field 힌트가 없는 URL 값(예: logo_url). 이미지면 그대로 <img> 로 — blob fetch 와 달리 CORS 를 타지 않는다.
236+
if (isString(value) && value.startsWith("http")) {
237+
return (
238+
<Stack spacing={1} alignItems="flex-start">
239+
{isImageUrl(value) && <Box component="img" src={value} alt={name} sx={{ maxWidth: 300, maxHeight: 200, objectFit: "contain" }} />}
240+
<a href={value} target="_blank" rel="noopener noreferrer">
241+
{value}
242+
</a>
243+
</Stack>
244+
);
245+
}
222246
if (typeof value === "object") {
223247
return (
224248
<Box
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { FC } from "react";
2+
3+
import { AdminList, AdminListColumn } from "@apps/pyconkr-admin/components/layouts/admin_list";
4+
5+
// 백엔드 RegistrationDeskConfig 의 기본값(무제한) 센티널. 그대로 노출하면 오해를 부른다.
6+
const OPEN_START = "0001-01-01";
7+
const OPEN_END = "9999-12-31";
8+
9+
const formatPeriod = (row: Record<string, unknown>) => {
10+
const start = String(row.start_date ?? "");
11+
const end = String(row.end_date ?? "");
12+
return `${start === OPEN_START ? "제한 없음" : start} ~ ${end === OPEN_END ? "제한 없음" : end}`;
13+
};
14+
15+
const columns: AdminListColumn[] = [
16+
{ field: "name", header: "이름", width: "30%" },
17+
{ field: "start_date", header: "적용 기간", width: "25%", render: formatPeriod },
18+
{
19+
field: "categories",
20+
header: "대상 카테고리",
21+
width: "15%",
22+
align: "right",
23+
render: (row) => `${Array.isArray(row.categories) ? row.categories.length : 0}개`,
24+
},
25+
];
26+
27+
// 생성 시간까지 넣으면 컬럼이 좁아져 헤더가 줄바꿈된다 — 설정은 수정 시간만 있으면 충분하다.
28+
export const RegistrationDeskConfigListPage: FC = () => (
29+
<AdminList
30+
app="internal_api"
31+
resource="registrationdeskconfig"
32+
title="등록 데스크 > 설정 > 목록"
33+
columns={columns}
34+
hideCreatedAt
35+
enableRowActions
36+
/>
37+
);

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ORDER_PRODUCT_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "@apps/pyconkr-
2929
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
3030

3131
import { OrderNotificationDialog } from "./notification_dialog";
32+
import { OrderProductTags } from "./product_tags";
3233
import { RefundDialog } from "./refund_dialog";
3334
import { OrderAdmin, SimpleCustomerInfo, SimpleOrderProductRelation } from "./types";
3435

@@ -130,6 +131,8 @@ const OrderProductRow: FC<{ order: OrderAdmin; relation: SimpleOrderProductRelat
130131
<TableRow>
131132
<TableCell colSpan={6} sx={{ bgcolor: "action.hover", py: 1, pl: 4 }}>
132133
<Stack spacing={2}>
134+
<Typography variant="subtitle2">태그</Typography>
135+
<OrderProductTags relation={relation} />
133136
{relation.ticket_info && (
134137
<>
135138
<Typography variant="subtitle2">참가자 정보</Typography>

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

Lines changed: 7 additions & 1 deletion
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, Send } from "@mui/icons-material";
2+
import { FileDownload, FileUpload, LocalOffer, RestartAlt, Send } from "@mui/icons-material";
33
import {
44
Button,
55
Chip,
@@ -27,6 +27,7 @@ import { PAYMENT_STATUS_LABEL } from "@apps/pyconkr-admin/components/pages/shop/
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";
2929
import { OrderNotificationDialog } from "@apps/pyconkr-admin/components/pages/shop/order/notification_dialog";
30+
import { OrderProductTagDialog } from "@apps/pyconkr-admin/components/pages/shop/order/tag_dialog";
3031
import { CategoryGroupAdminWithCategories } from "@apps/pyconkr-admin/components/pages/shop/product/types";
3132

3233
import { OrderAdmin, PaymentStatus } from "./types";
@@ -105,6 +106,7 @@ const InnerOrderList: FC = ErrorBoundary.with(
105106
const [exportDialogOpen, setExportDialogOpen] = useState(false);
106107
const [importDialogOpen, setImportDialogOpen] = useState(false);
107108
const [notificationDialogOpen, setNotificationDialogOpen] = useState(false);
109+
const [tagDialogOpen, setTagDialogOpen] = useState(false);
108110

109111
// Re-sync local form state when the URL changes externally (browser back/forward, pagination).
110112
useEffect(() => {
@@ -291,6 +293,9 @@ const InnerOrderList: FC = ErrorBoundary.with(
291293
<Button variant="outlined" size="small" color="secondary" startIcon={<Send />} onClick={() => setNotificationDialogOpen(true)}>
292294
알림 발송
293295
</Button>
296+
<Button variant="outlined" size="small" color="secondary" startIcon={<LocalOffer />} onClick={() => setTagDialogOpen(true)}>
297+
태그 부착/해제
298+
</Button>
294299
</Stack>
295300

296301
<OrderImportDialog open={importDialogOpen} onClose={() => setImportDialogOpen(false)} />
@@ -299,6 +304,7 @@ const InnerOrderList: FC = ErrorBoundary.with(
299304
{notificationDialogOpen && (
300305
<OrderNotificationDialog scope={{ kind: "orderFilter", params: filterParams }} onClose={() => setNotificationDialogOpen(false)} />
301306
)}
307+
{tagDialogOpen && <OrderProductTagDialog orderParams={filterParams} onClose={() => setTagDialogOpen(false)} />}
302308

303309
<Table>
304310
<TableHead>

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

Lines changed: 8 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
4844
import { FC, useMemo, useState } from "react";
4945
import { useNavigate } from "react-router-dom";
5046

51-
import { ChoicePicker } from "@apps/pyconkr-admin/components/elements/choice_picker";
5247
import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback";
5348
import { 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";
5656
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
5757

5858
const 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
/** 발송 대상 범위. 호출 위치(목록 / 주문 상세 / 주문 상품 행)마다 지정할 수 있는 범위가 다르다. */
11280
export type OrderNotificationScope =
11381
| { kind: "orderFilter"; params: Record<string, string> }
@@ -123,12 +91,6 @@ const SCOPE_TITLE: Record<OrderNotificationScope["kind"], string> = {
12391
// 특정 상품 1건을 지목한 경우엔 주문 단위 발송이 의미가 없다 (수신자가 주문자로 바뀌어 버린다).
12492
const 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 이 지원하지 않아 무시된 주문 필터 키. */
13395
const 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 되는 것을 막는다.
253147
const NotificationDialogBody: FC<NotificationDialogBodyProps> = ({ scope, onClose }) => {
254148
const client = useBackendAdminClient();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { OrderProductStatus } from "@apps/pyconkr-admin/components/pages/shop/order/types";
2+
3+
// 주문 목록 필터(OrderAdminFilterSet)를 상품 단위 filterset(OrderProductRelationAdminFilterSet) 키로 옮기는 표.
4+
// `status` 는 양쪽에 다 있지만 의미가 다르다 — 주문에선 결제 상태, 상품에선 상품 상태(pending/paid/used/refunded).
5+
// django-filter 는 모르는 키를 조용히 무시하므로, 넘길 키를 명시한 allowlist 로 둔다.
6+
// 주문 목록에 필터가 새로 생겨도 여기 없으면 자동으로 dropped 에 잡혀 경고로 노출된다 (대상이 몰래 넓어지지 않음).
7+
const ORDER_PRODUCT_PARAM_BY_ORDER_PARAM: Record<string, string> = {
8+
id: "order_id",
9+
user_id: "user_id",
10+
user_unique_id: "user_unique_id",
11+
name: "name",
12+
email: "email",
13+
imp_id: "imp_id",
14+
status: "order_status",
15+
first_paid_at_after: "first_paid_at_after",
16+
first_paid_at_before: "first_paid_at_before",
17+
product_id: "product_id",
18+
category_id: "category_id",
19+
category_group_id: "category_group_id",
20+
event_id: "event_id",
21+
// price_min/max 는 의도적으로 제외 — 주문에선 주문 총액, 상품에선 상품 단가라 뜻이 달라 그대로 넘기면 안 된다.
22+
};
23+
24+
/** 주문 필터 → 상품 필터. `dropped` 는 상품 filterset 이 지원하지 않아 무시된 주문 필터 키. */
25+
export const toOrderProductParams = (orderParams: Record<string, string>): { params: Record<string, string>; dropped: string[] } => {
26+
const params: Record<string, string> = {};
27+
const dropped: string[] = [];
28+
for (const [key, value] of Object.entries(orderParams)) {
29+
const mappedKey = ORDER_PRODUCT_PARAM_BY_ORDER_PARAM[key];
30+
if (mappedKey) params[mappedKey] = value;
31+
else dropped.push(key);
32+
}
33+
return { params, dropped };
34+
};
35+
36+
// 알림 발송 대상 queryset 은 PURCHASED_OR_REFUNDED_STATUS 로 미리 좁혀져 pending 이 애초에 대상이 아니다.
37+
// 태그는 그런 제약이 없지만, 두 화면 모두 운영상 의미 있는 상태만 고르면 되므로 같은 목록을 쓴다.
38+
export const SELECTABLE_OPR_STATUSES: OrderProductStatus[] = ["paid", "used", "refunded"];
39+
40+
export type ProductFilterState = {
41+
productIds: (string | number)[];
42+
statuses: OrderProductStatus[];
43+
ticketOnly: boolean;
44+
};
45+
46+
export const EMPTY_PRODUCT_FILTER: ProductFilterState = { productIds: [], statuses: [], ticketOnly: false };
47+
48+
export const productFilterParams = (productFilter: ProductFilterState): Record<string, string> => ({
49+
...(productFilter.productIds.length ? { product_id: productFilter.productIds.join(",") } : {}),
50+
...(productFilter.statuses.length ? { status: productFilter.statuses.join(",") } : {}),
51+
...(productFilter.ticketOnly ? { is_ticket: "true" } : {}),
52+
});

0 commit comments

Comments
 (0)