Skip to content

Commit 1f0e0d9

Browse files
committed
feat: 주문 내보내기 기능 추가
1 parent 668e50b commit 1f0e0d9

6 files changed

Lines changed: 151 additions & 2 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useBackendAdminClient, useExportOrdersMutation } from "@frontend/common/hooks/useAdminAPI";
2+
import { timestampedFilename, triggerBlobDownload } from "@frontend/common/utils";
3+
import { FileDownload } from "@mui/icons-material";
4+
import {
5+
Button,
6+
Checkbox,
7+
CircularProgress,
8+
Dialog,
9+
DialogActions,
10+
DialogContent,
11+
DialogTitle,
12+
FormControl,
13+
FormControlLabel,
14+
InputLabel,
15+
MenuItem,
16+
Select,
17+
Stack,
18+
} from "@mui/material";
19+
import { ErrorBoundary, Suspense } from "@suspensive/react";
20+
import { FC, useState } from "react";
21+
22+
import { ChoicePicker } from "@apps/pyconkr-admin/components/elements/choice_picker";
23+
import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback";
24+
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
25+
26+
type Scope = "event" | "categorygroup" | "category" | "product";
27+
28+
const SCOPES: { value: Scope; label: string; paramKey: string; source: { app: string; resource: string } }[] = [
29+
{ value: "event", label: "이벤트별", paramKey: "event_id", source: { app: "event", resource: "event" } },
30+
{ value: "categorygroup", label: "카테고리 그룹별", paramKey: "category_group_id", source: { app: "shop", resource: "categorygroup" } },
31+
{ value: "category", label: "카테고리별", paramKey: "category_id", source: { app: "shop", resource: "category" } },
32+
{ value: "product", label: "상품별", paramKey: "product_id", source: { app: "shop", resource: "product" } },
33+
];
34+
35+
const ExportDialogBody: FC<{ onClose: () => void }> = ErrorBoundary.with({ fallback: ErrorFallback }, ({ onClose }) => {
36+
const client = useBackendAdminClient();
37+
const exportMutation = useExportOrdersMutation(client);
38+
39+
const [scope, setScope] = useState<Scope>("event");
40+
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
41+
const [includeRefunded, setIncludeRefunded] = useState(false);
42+
43+
const { paramKey, source } = SCOPES.find((s) => s.value === scope)!;
44+
45+
const changeScope = (next: Scope) => {
46+
setScope(next);
47+
setSelectedIds([]);
48+
};
49+
50+
const handleExport = () => {
51+
const params: Record<string, string> = { [paramKey]: selectedIds.join(",") };
52+
if (includeRefunded) params.include_refunded = "true";
53+
exportMutation.mutate(params, {
54+
onSuccess: (blob) => {
55+
triggerBlobDownload(blob, timestampedFilename("order_export", "xlsx"));
56+
addSnackbar("주문 내보내기를 완료했습니다.", "success");
57+
onClose();
58+
},
59+
onError: addErrorSnackbar,
60+
});
61+
};
62+
63+
return (
64+
<>
65+
<DialogContent dividers>
66+
<Stack spacing={2} sx={{ pt: 1 }}>
67+
<FormControl size="small" fullWidth>
68+
<InputLabel id="order-export-scope">범위</InputLabel>
69+
<Select labelId="order-export-scope" label="범위" value={scope} onChange={(e) => changeScope(e.target.value as Scope)}>
70+
{SCOPES.map((s) => (
71+
<MenuItem key={s.value} value={s.value}>
72+
{s.label}
73+
</MenuItem>
74+
))}
75+
</Select>
76+
</FormControl>
77+
78+
{/* key={scope} 로 범위 변경 시 ChoicePicker 를 remount — source(selectables) 와 내부 필터 상태를 새로 시작. */}
79+
<Suspense fallback={<CircularProgress size={20} />}>
80+
<ChoicePicker key={scope} multiple label="대상" source={source} value={selectedIds} onChange={setSelectedIds} />
81+
</Suspense>
82+
83+
<FormControlLabel
84+
control={<Checkbox size="small" checked={includeRefunded} onChange={(e) => setIncludeRefunded(e.target.checked)} />}
85+
label="환불 포함"
86+
slotProps={{ typography: { variant: "body2" } }}
87+
/>
88+
</Stack>
89+
</DialogContent>
90+
<DialogActions>
91+
<Button onClick={onClose} color="inherit">
92+
취소
93+
</Button>
94+
<Button
95+
variant="contained"
96+
startIcon={<FileDownload />}
97+
onClick={handleExport}
98+
disabled={selectedIds.length === 0 || exportMutation.isPending}
99+
>
100+
{exportMutation.isPending ? "내보내는 중…" : `내보내기${selectedIds.length ? ` (${selectedIds.length})` : ""}`}
101+
</Button>
102+
</DialogActions>
103+
</>
104+
);
105+
});
106+
107+
export const OrderExportDialog: FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => (
108+
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
109+
<DialogTitle>범위별 주문 내보내기</DialogTitle>
110+
<ExportDialogBody onClose={onClose} />
111+
</Dialog>
112+
);

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useBackendAdminClient, useListPaginatedQuery } from "@frontend/common/hooks/useAdminAPI";
2-
import { RestartAlt } from "@mui/icons-material";
2+
import { FileDownload, RestartAlt } from "@mui/icons-material";
33
import {
44
Button,
55
Chip,
@@ -24,6 +24,7 @@ import { AdminPagination } from "@apps/pyconkr-admin/components/elements/admin_p
2424
import { BackendAdminSignInGuard } from "@apps/pyconkr-admin/components/elements/admin_signin_guard";
2525
import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback";
2626
import { PAYMENT_STATUS_LABEL } from "@apps/pyconkr-admin/components/pages/shop/_common/status_labels";
27+
import { OrderExportDialog } from "@apps/pyconkr-admin/components/pages/shop/order/export_dialog";
2728
import { CategoryGroupAdminWithCategories } from "@apps/pyconkr-admin/components/pages/shop/product/types";
2829

2930
import { OrderAdmin, PaymentStatus } from "./types";
@@ -97,6 +98,7 @@ const InnerOrderList: FC = ErrorBoundary.with(
9798
}
9899

99100
const [filters, setFilters] = useState<FilterState>(() => readFilters(searchParams));
101+
const [exportDialogOpen, setExportDialogOpen] = useState(false);
100102

101103
// Re-sync local form state when the URL changes externally (browser back/forward, pagination).
102104
useEffect(() => {
@@ -267,15 +269,20 @@ const InnerOrderList: FC = ErrorBoundary.with(
267269
</AdminFilterFieldset>
268270
</Stack>
269271

270-
<Stack direction="row" spacing={1}>
272+
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" sx={{ rowGap: 1 }}>
271273
<Button variant="contained" onClick={handleApply} size="small">
272274
검색
273275
</Button>
274276
<Button variant="text" onClick={handleReset} size="small" startIcon={<RestartAlt />}>
275277
초기화
276278
</Button>
279+
<Button variant="outlined" size="small" startIcon={<FileDownload />} onClick={() => setExportDialogOpen(true)} sx={{ ml: "auto" }}>
280+
내보내기
281+
</Button>
277282
</Stack>
278283

284+
<OrderExportDialog open={exportDialogOpen} onClose={() => setExportDialogOpen(false)} />
285+
279286
<Table>
280287
<TableHead>
281288
<TableRow>

packages/common/src/apis/admin_api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ export const renderSentTo = (client: BackendAPIClient, app: string, resource: st
142142
export const issueGoogleOAuth2AccessToken = (client: BackendAPIClient, id: string) => () =>
143143
client.post<GoogleOAuth2AccessTokenResponseSchema, undefined>(`v1/admin-api/external_api/googleoauth2/${id}/access-token/`, undefined);
144144

145+
export const exportOrders = (client: BackendAPIClient) => (params: Record<string, string>) =>
146+
client.post<Blob, null>("v1/admin-api/shop/order/export/", null, { params, responseType: "blob" });
147+
145148
export const listDashboardCharts = (client: BackendAPIClient) => () => client.get<DashboardChartDefinition[]>("v1/admin-api/dashboard/charts/");
146149

147150
export const fetchDashboardChartData = (client: BackendAPIClient, endpoint: string) => (params: Record<string, unknown>) =>

packages/common/src/hooks/useAdminAPI.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
bulkUpdateSections,
66
changePassword,
77
create,
8+
exportOrders,
89
fetchDashboardChartData,
910
issueGoogleOAuth2AccessToken,
1011
listAll,
@@ -63,6 +64,7 @@ const MUTATION_KEYS = {
6364
ADMIN_RETRY_HISTORY: ["mutation", "admin", "retry-history"],
6465
ADMIN_RETRY_SENT_TO: ["mutation", "admin", "retry-sent-to"],
6566
ADMIN_ISSUE_GOOGLE_OAUTH2_ACCESS_TOKEN: ["mutation", "admin", "google-oauth2-access-token"],
67+
ADMIN_EXPORT_ORDERS: ["mutation", "admin", "export-orders"],
6668
};
6769

6870
export const useBackendAdminClient = () => {
@@ -267,6 +269,13 @@ export const useIssueGoogleOAuth2AccessTokenMutation = (client: BackendAPIClient
267269
meta: { invalidates: [] },
268270
});
269271

272+
export const useExportOrdersMutation = (client: BackendAPIClient) =>
273+
useMutation({
274+
mutationKey: [...MUTATION_KEYS.ADMIN_EXPORT_ORDERS],
275+
mutationFn: exportOrders(client),
276+
meta: { invalidates: [] },
277+
});
278+
270279
// 정의 자체는 정적이지만 응답의 옵션(티켓/이벤트)은 DB에서 동적 주입 → 기본 staleTime 으로 갱신되게 둔다.
271280
export const useDashboardChartsQuery = (client: BackendAPIClient) =>
272281
useSuspenseQuery({
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export const triggerBlobDownload = (blob: Blob, filename: string): void => {
2+
const url = URL.createObjectURL(blob);
3+
const anchor = document.createElement("a");
4+
anchor.href = url;
5+
anchor.download = filename;
6+
document.body.appendChild(anchor);
7+
anchor.click();
8+
anchor.remove();
9+
URL.revokeObjectURL(url);
10+
};
11+
12+
export const timestampedFilename = (prefix: string, ext: string): string => {
13+
const pad = (n: number) => String(n).padStart(2, "0");
14+
const now = new Date();
15+
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
16+
return `${prefix}_${stamp}.${ext}`;
17+
};

packages/common/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { buildFlatSiteMap, buildNestedSiteMap, parseCss } from "./api";
22
export { isChunkLoadError, registerChunkLoadErrorReloadHandler, reloadForChunkLoadError } from "./chunk_load_error";
33
export { captureSessionTokenFromURL, getCookie } from "./cookie";
4+
export { timestampedFilename, triggerBlobDownload } from "./download";
45
export { getFaro, initFaro } from "./faro";
56
export type { InitFaroOptions } from "./faro";
67
export { getFormValue, isFormValid } from "./form";

0 commit comments

Comments
 (0)