diff --git a/apps/pyconkr-admin/src/components/elements/admin_filter_fieldset.tsx b/apps/pyconkr-admin/src/components/elements/admin_filter_fieldset.tsx new file mode 100644 index 0000000..9477fc3 --- /dev/null +++ b/apps/pyconkr-admin/src/components/elements/admin_filter_fieldset.tsx @@ -0,0 +1,32 @@ +import { Stack, styled } from "@mui/material"; +import * as React from "react"; + +const StyledFieldset = styled("fieldset")(({ theme }) => ({ + margin: 0, + padding: theme.spacing(1, 2, 2), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + minWidth: 0, + width: "fit-content", + maxWidth: "100%", +})); + +const StyledLegend = styled("legend")(({ theme }) => ({ + padding: theme.spacing(0, 1), + color: theme.palette.text.secondary, + ...theme.typography.caption, +})); + +type Props = { + label: string; + children: React.ReactNode; +}; + +export const AdminFilterFieldset: React.FC = ({ label, children }) => ( + + {label} + + {children} + + +); diff --git a/apps/pyconkr-admin/src/components/elements/admin_pagination.tsx b/apps/pyconkr-admin/src/components/elements/admin_pagination.tsx new file mode 100644 index 0000000..4c50d7d --- /dev/null +++ b/apps/pyconkr-admin/src/components/elements/admin_pagination.tsx @@ -0,0 +1,68 @@ +import { FormControl, InputLabel, MenuItem, Pagination, Select, Stack, Typography } from "@mui/material"; +import * as React from "react"; + +type Props = { + count: number; + page: number; + pageSize: number; + onPageChange: (page: number) => void; + onPageSizeChange: (size: number) => void; + pageSizeOptions?: number[]; + scrollToTopOnChange?: boolean; +}; + +const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100, 200]; + +const scrollToTop = () => window.scrollTo({ top: 0, behavior: "smooth" }); + +export const AdminPagination: React.FC = ({ + count, + page, + pageSize, + onPageChange, + onPageSizeChange, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + scrollToTopOnChange = true, +}) => { + const pageCount = Math.max(1, Math.ceil(count / pageSize)); + const startIdx = count === 0 ? 0 : (page - 1) * pageSize + 1; + const endIdx = Math.min(page * pageSize, count); + + const handlePageChange = (newPage: number) => { + onPageChange(newPage); + if (scrollToTopOnChange) scrollToTop(); + }; + + const handlePageSizeChange = (newSize: number) => { + onPageSizeChange(newSize); + if (scrollToTopOnChange) scrollToTop(); + }; + + return ( + + + {count.toLocaleString()}건 중 {startIdx.toLocaleString()}–{endIdx.toLocaleString()} + + + handlePageChange(p)} + showFirstButton + showLastButton + size="small" + /> + + 페이지당 + + + + + ); +}; diff --git a/apps/pyconkr-admin/src/components/elements/public_file_picker.tsx b/apps/pyconkr-admin/src/components/elements/public_file_picker.tsx new file mode 100644 index 0000000..626c154 --- /dev/null +++ b/apps/pyconkr-admin/src/components/elements/public_file_picker.tsx @@ -0,0 +1,119 @@ +import { useBackendAdminClient, useChoicesQuery, usePublicFileQuery } from "@frontend/common/src/hooks/useAdminAPI"; +import { OpenInNew } from "@mui/icons-material"; +import { Autocomplete, Box, Button, CircularProgress, Stack, TextField } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import * as React from "react"; +import { Link as RouterLink } from "react-router-dom"; + +type Option = { value: string; label: string }; + +type Props = { + label?: string; + value: string; + onChange: (value: string) => void; + // choices를 가져올 위치 (RJSF choices endpoint 활용) + choicesApp: string; + choicesResource: string; + choicesField: string; + // 확장자 화이트리스트 (대소문자 무시, 점 prefix 없이). 미지정 시 모든 파일 노출. + acceptExtensions?: string[]; +}; + +const PREVIEW_SIZE = 56; + +const previewBoxSx = { + width: PREVIEW_SIZE, + height: PREVIEW_SIZE, + flexShrink: 0, + border: 1, + borderColor: "divider", + borderRadius: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + bgcolor: "action.hover", + overflow: "hidden", +}; + +const ImagePreview: React.FC<{ id: string }> = ErrorBoundary.with( + { fallback: () => }, + Suspense.with( + { + fallback: ( + + + + ), + }, + ({ id }) => { + const client = useBackendAdminClient(); + const { data } = usePublicFileQuery(client, id); + if (!data) return ; + const isImage = data.mimetype?.startsWith("image/"); + if (!isImage) { + return ( + + 파일 + + ); + } + return ( + + + + ); + } + ) +); + +export const PublicFilePicker: React.FC = ({ + label = "이미지", + value, + onChange, + choicesApp, + choicesResource, + choicesField, + acceptExtensions, +}) => { + const client = useBackendAdminClient(); + const choicesQuery = useChoicesQuery(client, choicesApp, choicesResource); + const options: Option[] = React.useMemo(() => { + const all = (choicesQuery.data?.[choicesField] ?? []).map((item) => ({ value: item.const ?? "", label: item.title })); + if (!acceptExtensions || acceptExtensions.length === 0) return all; + const allowed = acceptExtensions.map((e) => e.toLowerCase()); + return all.filter((opt) => allowed.some((ext) => opt.label.toLowerCase().endsWith(`.${ext}`))); + }, [choicesQuery.data, choicesField, acceptExtensions]); + const selected = options.find((o) => o.value === value) ?? null; + + return ( + + {value ? : } + onChange(newValue?.value ?? "")} + getOptionLabel={(o) => o.label} + isOptionEqualToValue={(a, b) => a.value === b.value} + sx={{ flexGrow: 1, minWidth: 240 }} + renderInput={(params) => } + /> + + + ); +}; diff --git a/apps/pyconkr-admin/src/components/layouts/admin_list.tsx b/apps/pyconkr-admin/src/components/layouts/admin_list.tsx index ee1f208..ad8325d 100644 --- a/apps/pyconkr-admin/src/components/layouts/admin_list.tsx +++ b/apps/pyconkr-admin/src/components/layouts/admin_list.tsx @@ -1,92 +1,181 @@ -import { useBackendAdminClient, useChoicesQuery, useListQuery, useOpenApiSchemaQuery } from "@frontend/common/src/hooks/useAdminAPI"; +import { + useBackendAdminClient, + useChoicesQuery, + useListQuery, + useOpenApiSchemaQuery, + useRemovePreparedMutation, +} from "@frontend/common/src/hooks/useAdminAPI"; import { extractQueryParameters } from "@frontend/common/src/utils"; -import { Add } from "@mui/icons-material"; -import { Box, Button, CircularProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; +import { Add, Delete, Edit } from "@mui/icons-material"; +import { Box, Button, CircularProgress, IconButton, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; import { ErrorBoundary, Suspense } from "@suspensive/react"; import * as React from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { addErrorSnackbar, addSnackbar } from "../../utils/snackbar"; import { AdminListFilter } from "../elements/admin_list_filter"; import { BackendAdminSignInGuard } from "../elements/admin_signin_guard"; import { ErrorFallback } from "../elements/error_fallback"; +type ListRowType = { + id: string; + str_repr: string; + created_at: string; + updated_at: string; +}; + +export type AdminListColumn = { + field: string; + header: string; + width?: string | number; + align?: "left" | "right" | "center"; + render?: (row: Record) => React.ReactNode; +}; + type AdminListProps = { app: string; resource: string; + title?: string; hideCreatedAt?: boolean; hideUpdatedAt?: boolean; hideCreateNew?: boolean; -}; - -type ListRowType = { - id: string; - str_repr: string; - created_at: string; - updated_at: string; + columns?: AdminListColumn[]; + enableRowActions?: boolean; }; const InnerAdminList: React.FC = ErrorBoundary.with( { fallback: ErrorFallback }, - Suspense.with({ fallback: }, ({ app, resource, hideCreatedAt, hideUpdatedAt, hideCreateNew }) => { - const navigate = useNavigate(); - - const [searchParams, setSearchParams] = useSearchParams(); - const backendAdminClient = useBackendAdminClient(); - - const filterParams: Record = Object.fromEntries(searchParams.entries()); - const listQuery = useListQuery(backendAdminClient, app, resource, filterParams); - - const openApiSchemaQuery = useOpenApiSchemaQuery(backendAdminClient); - const queryParameters = React.useMemo( - () => extractQueryParameters(openApiSchemaQuery.data, app, resource), - [openApiSchemaQuery.data, app, resource] - ); - - const choicesQuery = useChoicesQuery(backendAdminClient, app, resource); - - const handleFilterApply = (newParams: Record) => setSearchParams(newParams, { replace: true }); - - return ( - - - {app.toUpperCase()} > {resource.toUpperCase()} > 목록 - -
- - - {!hideCreateNew && ( - - )} - - - - - ID - 이름 - {hideCreatedAt === true && 생성 시간} - {hideUpdatedAt === true && 수정 시간} - - - - {listQuery.data?.map((item) => ( - - - {item.id} - - - {item.str_repr} - - {!hideCreatedAt && {new Date(item.created_at).toLocaleString()}} - {!hideUpdatedAt && {new Date(item.updated_at).toLocaleString()}} + Suspense.with( + { fallback: }, + ({ app, resource, title, hideCreatedAt, hideUpdatedAt, hideCreateNew, columns, enableRowActions }) => { + const navigate = useNavigate(); + + const [searchParams, setSearchParams] = useSearchParams(); + const backendAdminClient = useBackendAdminClient(); + + const filterParams: Record = Object.fromEntries(searchParams.entries()); + const listQuery = useListQuery>(backendAdminClient, app, resource, filterParams); + + const openApiSchemaQuery = useOpenApiSchemaQuery(backendAdminClient); + const queryParameters = React.useMemo( + () => extractQueryParameters(openApiSchemaQuery.data, app, resource), + [openApiSchemaQuery.data, app, resource] + ); + + const choicesQuery = useChoicesQuery(backendAdminClient, app, resource); + + const removeMutation = useRemovePreparedMutation(backendAdminClient, app, resource); + + const handleFilterApply = (newParams: Record) => setSearchParams(newParams, { replace: true }); + + const detailPath = (id: string) => `/${app}/${resource}/${id}`; + const hasCustomColumns = !!(columns && columns.length > 0); + + const labelForRow = (item: ListRowType & Record): string => { + if (hasCustomColumns) { + const firstField = columns![0].field; + const value = item[firstField]; + if (typeof value === "string" && value.length > 0) return value; + } + return item.str_repr || item.id; + }; + + const handleDelete = (id: string, label: string) => { + if (!window.confirm(`'${label}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) return; + removeMutation.mutate(id, { + onSuccess: () => addSnackbar("삭제했습니다.", "success"), + onError: addErrorSnackbar, + }); + }; + + return ( + + {title ?? `${app.toUpperCase()} > ${resource.toUpperCase()} > 목록`} +
+ + + {!hideCreateNew && ( + + )} + +
+ + + {hasCustomColumns ? ( + columns!.map((col) => ( + + {col.header} + + )) + ) : ( + <> + ID + 이름 + + )} + {!hideCreatedAt && 생성 시간} + {!hideUpdatedAt && 수정 시간} + {enableRowActions && 작업} - ))} - -
-
- ); - }) + + + {listQuery.data?.map((item) => ( + + {hasCustomColumns ? ( + columns!.map((col, idx) => { + if (col.render) { + return ( + + {col.render(item)} + + ); + } + const value = (item as Record)[col.field]; + const displayValue = value === null || value === undefined ? "" : String(value); + return ( + + {idx === 0 ? {displayValue} : displayValue} + + ); + }) + ) : ( + <> + + {item.id} + + + {item.str_repr} + + + )} + {!hideCreatedAt && {new Date(item.created_at).toLocaleString()}} + {!hideUpdatedAt && {new Date(item.updated_at).toLocaleString()}} + {enableRowActions && ( + + navigate(detailPath(item.id))} aria-label="수정"> + + + handleDelete(item.id, labelForRow(item))} + disabled={removeMutation.isPending} + aria-label="삭제" + > + + + + )} + + ))} + + + + ); + } + ) ); export const AdminList: React.FC = (props) => ( diff --git a/apps/pyconkr-admin/src/components/pages/shop/_common/status_labels.ts b/apps/pyconkr-admin/src/components/pages/shop/_common/status_labels.ts new file mode 100644 index 0000000..dc1e038 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/_common/status_labels.ts @@ -0,0 +1,25 @@ +import { OrderProductStatus, PaymentStatus } from "../order/types"; +import { ProductCurrentStatus } from "../product/types"; + +type ChipColor = "default" | "primary" | "success" | "warning" | "error" | "info"; + +export const PAYMENT_STATUS_LABEL: Record = { + pending: { label: "대기", color: "default" }, + completed: { label: "완료", color: "success" }, + partial_refunded: { label: "부분환불", color: "warning" }, + refunded: { label: "환불", color: "error" }, +}; + +export const ORDER_PRODUCT_STATUS_LABEL: Record = { + pending: { label: "대기", color: "default" }, + paid: { label: "결제완료", color: "success" }, + used: { label: "사용", color: "info" }, + refunded: { label: "환불", color: "error" }, +}; + +export const PRODUCT_STATUS_LABEL: Record = { + hidden: { label: "비공개", color: "default" }, + out_of_visible_period: { label: "노출 기간 아님", color: "default" }, + out_of_orderable_period: { label: "판매 기간 아님", color: "warning" }, + active: { label: "노출 중", color: "success" }, +}; diff --git a/apps/pyconkr-admin/src/components/pages/shop/category_group/editor.tsx b/apps/pyconkr-admin/src/components/pages/shop/category_group/editor.tsx new file mode 100644 index 0000000..bed4e01 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/category_group/editor.tsx @@ -0,0 +1,232 @@ +import { useBackendAdminClient, useRetrieveQuery } from "@frontend/common/src/hooks/useAdminAPI"; +import { Add, Delete, Edit } from "@mui/icons-material"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { useMutation } from "@tanstack/react-query"; +import * as React from "react"; +import { useParams } from "react-router-dom"; + +import { addErrorSnackbar, addSnackbar } from "../../../../utils/snackbar"; +import { AdminEditor } from "../../../layouts/admin_editor"; + +type Category = { + id?: string; + group?: string; + name: string; + priority: number; + created_at?: string; + updated_at?: string; +}; + +type CategoryGroup = { + id: string; + name: string; + priority: number; + categories: Category[]; +}; + +type CategoryFormValues = { + name: string; + priority: string; +}; + +type CategoryDialogProps = { + open: boolean; + onClose: () => void; + group: CategoryGroup; + category?: Category; +}; + +const CategoryDialog: React.FC = ({ open, onClose, group, category }) => { + const client = useBackendAdminClient(); + + const [values, setValues] = React.useState({ + name: category?.name ?? "", + priority: category ? String(category.priority) : "0", + }); + + React.useEffect(() => { + if (open) { + setValues({ + name: category?.name ?? "", + priority: category ? String(category.priority) : String((group.categories ?? []).length * 10), + }); + } + }, [open, category, group.categories]); + + const mutation = useMutation({ + mutationFn: async () => { + const payload: Category = { + ...(category ?? {}), + group: group.id, + name: values.name, + priority: Number(values.priority) || 0, + }; + const newCategories = category + ? group.categories.map((c) => (c.id === category.id ? { ...c, ...payload } : c)) + : [...group.categories, payload]; + return client.patch(`v1/admin-api/shop/category-groups/${group.id}/`, { + ...group, + categories: newCategories, + }); + }, + onSuccess: () => { + addSnackbar(`카테고리 '${values.name}'을(를) ${category ? "수정" : "생성"}했습니다.`, "success"); + onClose(); + }, + onError: addErrorSnackbar, + }); + + const onSubmit = () => { + if (!values.name.trim()) { + addSnackbar("이름은 필수입니다.", "error"); + return; + } + mutation.mutate(); + }; + + return ( + + {category ? `카테고리 수정: ${category.name}` : "새 카테고리 추가"} + + + setValues((p) => ({ ...p, name: e.target.value }))} + fullWidth + autoFocus + /> + setValues((p) => ({ ...p, priority: e.target.value }))} + fullWidth + helperText="낮은 값이 먼저 표시됩니다." + /> + + + + + + + + ); +}; + +const InnerChildCategoryList: React.FC<{ groupId: string }> = ErrorBoundary.with( + { fallback: () => null }, + Suspense.with({ fallback: }, ({ groupId }) => { + const client = useBackendAdminClient(); + const groupQuery = useRetrieveQuery(client, "shop", "category-groups", groupId); + const group = groupQuery.data; + const [dialogState, setDialogState] = React.useState<{ open: boolean; category?: Category }>({ open: false }); + + const deleteMutation = useMutation({ + mutationFn: async (categoryId: string) => { + if (!group) return; + const newCategories = group.categories.filter((c) => c.id !== categoryId); + return client.patch(`v1/admin-api/shop/category-groups/${group.id}/`, { ...group, categories: newCategories }); + }, + onSuccess: () => addSnackbar("카테고리를 삭제했습니다.", "success"), + onError: addErrorSnackbar, + }); + + if (!group) return null; + + const categories = [...(group.categories ?? [])].sort((a, b) => a.priority - b.priority); + + const handleDelete = (category: Category) => { + if (!category.id) return; + if (window.confirm(`'${category.name}' 카테고리를 삭제하시겠습니까?`)) { + deleteMutation.mutate(category.id); + } + }; + + return ( + + + + 하위 카테고리 ({categories.length}) + + + + + + 이름 + 우선순위 + 생성일 + 작업 + + + + {categories.length === 0 && ( + + + 하위 카테고리가 없습니다. + + + )} + {categories.map((category) => ( + + {category.name} + {category.priority} + {category.created_at ? new Date(category.created_at).toLocaleString() : "—"} + + setDialogState({ open: true, category })} aria-label="수정"> + + + handleDelete(category)} aria-label="삭제" disabled={deleteMutation.isPending}> + + + + + ))} + +
+ setDialogState({ open: false })} group={group} category={dialogState.category} /> +
+ ); + }) +); + +export const ShopCategoryGroupEditorPage: React.FC = () => { + const { id } = useParams<{ id?: string }>(); + + return ( + )} + > + {id && } + + ); +}; diff --git a/apps/pyconkr-admin/src/components/pages/shop/category_group/list.tsx b/apps/pyconkr-admin/src/components/pages/shop/category_group/list.tsx new file mode 100644 index 0000000..81d44a7 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/category_group/list.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { Link } from "react-router-dom"; + +import { AdminList, AdminListColumn } from "../../../layouts/admin_list"; + +const columns: AdminListColumn[] = [ + { + field: "name", + header: "이름", + width: "40%", + render: (row) => { + const id = row.id as string; + const name = String(row.name ?? ""); + return {name}; + }, + }, + { + field: "priority", + header: "우선순위", + align: "right", + render: (row) => String(row.priority ?? 0), + }, + { + field: "categories", + header: "하위 카테고리 수", + align: "right", + render: (row) => { + const cats = row.categories; + return Array.isArray(cats) ? cats.length.toLocaleString() : "0"; + }, + }, +]; + +export const ShopCategoryGroupListPage: React.FC = () => ; diff --git a/apps/pyconkr-admin/src/components/pages/shop/order/editor.tsx b/apps/pyconkr-admin/src/components/pages/shop/order/editor.tsx new file mode 100644 index 0000000..c0ae856 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/order/editor.tsx @@ -0,0 +1,352 @@ +import { useBackendAdminClient, useRetrieveQuery, useUpdateMutation } from "@frontend/common/src/hooks/useAdminAPI"; +import { CurrencyExchange, NotificationsActive, Save } from "@mui/icons-material"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Divider, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import * as React from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { RefundDialog } from "./refund_dialog"; +import { OrderAdmin, SimpleCustomerInfo, SimpleOrderProductRelation } from "./types"; +import { addErrorSnackbar, addSnackbar } from "../../../../utils/snackbar"; +import { BackendAdminSignInGuard } from "../../../elements/admin_signin_guard"; +import { ErrorFallback } from "../../../elements/error_fallback"; +import { ORDER_PRODUCT_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "../_common/status_labels"; + +const formatPrice = (price: number) => `₩${price.toLocaleString()}`; + +// ----------------- Customer Info Tab (editable) ----------------- +const CustomerInfoTab: React.FC<{ order: OrderAdmin }> = ({ order }) => { + const client = useBackendAdminClient(); + const updateMutation = useUpdateMutation<{ customer_info: SimpleCustomerInfo }>(client, "shop", "orders", order.id); + + const [name, setName] = React.useState(order.customer_info?.name ?? ""); + const [phone, setPhone] = React.useState(order.customer_info?.phone ?? ""); + const [email, setEmail] = React.useState(order.customer_info?.email ?? ""); + const [organization, setOrganization] = React.useState(order.customer_info?.organization ?? ""); + + React.useEffect(() => { + setName(order.customer_info?.name ?? ""); + setPhone(order.customer_info?.phone ?? ""); + setEmail(order.customer_info?.email ?? ""); + setOrganization(order.customer_info?.organization ?? ""); + }, [order.customer_info]); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !phone.trim() || !email.trim()) { + addSnackbar("이름, 연락처, 이메일은 필수입니다.", "error"); + return; + } + updateMutation.mutate({ customer_info: { name, phone, email, organization: organization || null } } as { customer_info: SimpleCustomerInfo }, { + onSuccess: () => addSnackbar("고객 정보를 수정했습니다.", "success"), + onError: addErrorSnackbar, + }); + }; + + return ( +
+ + {!order.customer_info && ( + + 아직 고객 정보가 입력되지 않았습니다. 저장하면 신규 생성됩니다. + + )} + setName(e.target.value)} fullWidth /> + setPhone(e.target.value)} + fullWidth + helperText="예: 010-1234-5678 또는 +821012345678" + /> + setEmail(e.target.value)} fullWidth /> + setOrganization(e.target.value)} fullWidth /> + + + + +
+ ); +}; + +const OrderProductRow: React.FC<{ relation: SimpleOrderProductRelation }> = ({ relation }) => { + const status = ORDER_PRODUCT_STATUS_LABEL[relation.status]; + return ( + <> + + + {relation.id.slice(0, 8)} + + {relation.product.name_ko || relation.product.name_en} + + + + {formatPrice(relation.price)} + {relation.donation_price > 0 ? formatPrice(relation.donation_price) : "—"} + + + + {relation.options.length === 0 ? ( + + 옵션 없음 + + ) : ( + + + + 옵션 그룹 + 옵션 / 입력 + + + + {relation.options.map((opt) => ( + + {opt.option_group_name_ko || opt.option_group_name_en} + + {opt.option_name_ko || opt.option_name_en ? ( + opt.option_name_ko || opt.option_name_en + ) : ( + + 사용자 입력: {opt.custom_response || "(없음)"} + + )} + + + ))} + +
+ )} +
+
+ + ); +}; + +const OrderProductsTab: React.FC<{ order: OrderAdmin }> = ({ order }) => ( + + + + 주문 상품 ID + 상품명 + 상태 + 가격 + 기부 + + + + {order.products.length === 0 && ( + + + 주문 상품이 없습니다. + + + )} + {order.products.map((p) => ( + + ))} + +
+); + +const PaymentHistoryTab: React.FC<{ order: OrderAdmin; onRefund: () => void }> = ({ order, onRefund }) => { + const histories = [...order.payment_histories].sort((a, b) => (a.created_at < b.created_at ? -1 : 1)); + const canRefund = order.current_paid_price > 0 && (order.current_status === "completed" || order.current_status === "partial_refunded"); + + return ( + + + + PortOne imp_id 기준 결제 이력입니다. + + + + + + + 일시 + imp_id + 상태 + 금액 + + + + {histories.length === 0 && ( + + + 결제 이력이 없습니다. + + + )} + {histories.map((h, idx) => { + const status = PAYMENT_STATUS_LABEL[h.status] ?? { label: h.status, color: "default" as const }; + const isLatest = idx === histories.length - 1; + return ( + + {new Date(h.created_at).toLocaleString()} + + {h.imp_id} + + + + + {formatPrice(h.price)} + + ); + })} + +
+
+ ); +}; + +const InnerOrderEditor: React.FC = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, () => { + const { id } = useParams<{ id?: string }>(); + const navigate = useNavigate(); + const client = useBackendAdminClient(); + const [tab, setTab] = React.useState(0); + const [refundOpen, setRefundOpen] = React.useState(false); + + const orderQuery = useRetrieveQuery(client, "shop", "orders", id ?? ""); + const order = orderQuery.data; + + if (!order) { + return ( + + SHOP > ORDERS + 해당 ID의 주문을 찾을 수 없습니다. + + + ); + } + + const status = PAYMENT_STATUS_LABEL[order.current_status] ?? { label: order.current_status, color: "default" as const }; + const canRefund = order.current_paid_price > 0 && (order.current_status === "completed" || order.current_status === "partial_refunded"); + + const onNotify = () => { + navigate({ + pathname: "/notification/notification/create", + search: `?order_id=${order.id}`, + }); + }; + + return ( + + + + + SHOP > ORDERS > 상세: {order.id.slice(0, 8)} + + + + + {formatPrice(order.current_paid_price)} + {order.first_paid_price !== order.current_paid_price && ( + + (최초 결제: {formatPrice(order.first_paid_price)}) + + )} + + {order.user && ( + + · {order.user.email} + + )} + + + + + + + + + + + + + 필드 + + + + + + ID + + {order.id} + + + + 주문일 + {new Date(order.created_at).toLocaleString()} + + + 최초 결제일 + {order.first_paid_at ? new Date(order.first_paid_at).toLocaleString() : "—"} + + + 최근 imp_id + + {order.latest_imp_id || "—"} + + + + 마지막 수정 + {new Date(order.updated_at).toLocaleString()} + + +
+ + + + setTab(v)}> + + + + + + + {tab === 0 && } + {tab === 1 && } + {tab === 2 && setRefundOpen(true)} />} + + + setRefundOpen(false)} order={order} /> +
+ ); + }) +); + +export const ShopOrderEditorPage: React.FC = () => ( + + + +); diff --git a/apps/pyconkr-admin/src/components/pages/shop/order/list.tsx b/apps/pyconkr-admin/src/components/pages/shop/order/list.tsx new file mode 100644 index 0000000..56af830 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/order/list.tsx @@ -0,0 +1,268 @@ +import { useBackendAdminClient, useListPaginatedQuery, useListQuery } from "@frontend/common/src/hooks/useAdminAPI"; +import { + Chip, + CircularProgress, + MenuItem, + Select, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import * as React from "react"; +import { Link, useSearchParams } from "react-router-dom"; + +import { OrderAdmin, PaymentStatus } from "./types"; +import { AdminFilterFieldset } from "../../../elements/admin_filter_fieldset"; +import { AdminPagination } from "../../../elements/admin_pagination"; +import { BackendAdminSignInGuard } from "../../../elements/admin_signin_guard"; +import { ErrorFallback } from "../../../elements/error_fallback"; +import { PAYMENT_STATUS_LABEL } from "../_common/status_labels"; +import { CategoryGroupAdminWithCategories } from "../product/types"; + +const formatPrice = (price: number) => `₩${price.toLocaleString()}`; + +const DEFAULT_PAGE_SIZE = 50; + +type StatusFilter = "all" | PaymentStatus; + +const InnerOrderList: React.FC = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, () => { + const client = useBackendAdminClient(); + const [searchParams, setSearchParams] = useSearchParams(); + + const nameQuery = searchParams.get("name") ?? ""; + const emailQuery = searchParams.get("email") ?? ""; + const impIdQuery = searchParams.get("imp_id") ?? ""; + const statusQuery = (searchParams.get("status") ?? "all") as StatusFilter; + const categoryGroupQuery = searchParams.get("category_group_id") ?? ""; + const categoryQuery = searchParams.get("category_id") ?? ""; + const paidAfter = searchParams.get("first_paid_at_after") ?? ""; + const paidBefore = searchParams.get("first_paid_at_before") ?? ""; + const statusChangedAfter = searchParams.get("status_changed_at_after") ?? ""; + const statusChangedBefore = searchParams.get("status_changed_at_before") ?? ""; + const page = Number(searchParams.get("page") ?? 1); + const pageSize = Number(searchParams.get("page_size") ?? DEFAULT_PAGE_SIZE); + + const apiParams: Record = { page: String(page), page_size: String(pageSize) }; + if (nameQuery.trim()) apiParams.name = nameQuery.trim(); + if (emailQuery.trim()) apiParams.email = emailQuery.trim(); + if (impIdQuery.trim()) apiParams.imp_id = impIdQuery.trim(); + if (statusQuery !== "all") apiParams.status = statusQuery; + if (categoryGroupQuery) apiParams.category_group_id = categoryGroupQuery; + if (categoryQuery) apiParams.category_id = categoryQuery; + if (paidAfter) apiParams.first_paid_at_after = paidAfter; + if (paidBefore) apiParams.first_paid_at_before = paidBefore; + if (statusChangedAfter) apiParams.status_changed_at_after = statusChangedAfter; + if (statusChangedBefore) apiParams.status_changed_at_before = statusChangedBefore; + + const ordersQuery = useListPaginatedQuery(client, "shop", "orders", apiParams); + const groupsQuery = useListQuery(client, "shop", "category-groups", {}); + const { count = 0, results: orders = [] } = ordersQuery.data ?? {}; + const groups = React.useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]); + + const updateFilterParam = (key: string, value: string) => { + const next = new URLSearchParams(searchParams); + if (value) next.set(key, value); + else next.delete(key); + next.delete("page"); + setSearchParams(next, { replace: true }); + }; + + const setCategoryGroup = (value: string) => { + const next = new URLSearchParams(searchParams); + if (value) next.set("category_group_id", value); + else next.delete("category_group_id"); + next.delete("category_id"); // 그룹 바꾸면 카테고리 선택 초기화 + next.delete("page"); + setSearchParams(next, { replace: true }); + }; + + const setPage = (p: number) => { + const next = new URLSearchParams(searchParams); + if (p <= 1) next.delete("page"); + else next.set("page", String(p)); + setSearchParams(next, { replace: true }); + }; + + const setPageSize = (size: number) => { + const next = new URLSearchParams(searchParams); + next.set("page_size", String(size)); + next.delete("page"); + setSearchParams(next, { replace: true }); + }; + + return ( + + SHOP > ORDERS > 목록 + + + + updateFilterParam("first_paid_at_after", e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ minWidth: 220 }} + /> + updateFilterParam("first_paid_at_before", e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ minWidth: 220 }} + /> + + + + updateFilterParam("status_changed_at_after", e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ minWidth: 220 }} + /> + updateFilterParam("status_changed_at_before", e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ minWidth: 220 }} + /> + + + + + + + + + + updateFilterParam("name", e.target.value)} + sx={{ minWidth: 220 }} + /> + updateFilterParam("email", e.target.value)} + sx={{ minWidth: 220 }} + /> + updateFilterParam("imp_id", e.target.value)} + sx={{ minWidth: 200 }} + /> + + + + + + + 주문 ID + 사용자 + 이름 + 상태 + 결제액 + 결제일 + 생성일 + + + + {orders.length === 0 && ( + + + 조건에 맞는 주문이 없습니다. + + + )} + {orders.map((order) => { + const status = PAYMENT_STATUS_LABEL[order.current_status] ?? { label: order.current_status, color: "default" as const }; + return ( + + + + {order.id.slice(0, 8)} + + + {order.user?.email ?? "—"} + {order.name_ko || order.str_repr} + + + + {formatPrice(order.current_paid_price)} + {order.first_paid_at ? new Date(order.first_paid_at).toLocaleString() : "—"} + {new Date(order.created_at).toLocaleString()} + + ); + })} + +
+ + +
+ ); + }) +); + +export const ShopOrderListPage: React.FC = () => ( + + + +); diff --git a/apps/pyconkr-admin/src/components/pages/shop/order/refund_dialog.tsx b/apps/pyconkr-admin/src/components/pages/shop/order/refund_dialog.tsx new file mode 100644 index 0000000..6822646 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/order/refund_dialog.tsx @@ -0,0 +1,91 @@ +import { useBackendAdminClient } from "@frontend/common/src/hooks/useAdminAPI"; +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from "@mui/material"; +import { useMutation } from "@tanstack/react-query"; +import * as React from "react"; + +import { OrderAdmin } from "./types"; +import { addErrorSnackbar, addSnackbar } from "../../../../utils/snackbar"; + +type RefundDialogProps = { + open: boolean; + onClose: () => void; + order: OrderAdmin; +}; + +const formatPrice = (price: number) => `₩${price.toLocaleString()}`; + +export const RefundDialog: React.FC = ({ open, onClose, order }) => { + const client = useBackendAdminClient(); + const [totp, setTotp] = React.useState(""); + const [touched, setTouched] = React.useState(false); + + React.useEffect(() => { + if (open) { + setTotp(""); + setTouched(false); + } + }, [open]); + + const refundMutation = useMutation({ + mutationFn: async (totpCode: string) => { + return client.post>(`v1/admin-api/shop/orders/${order.id}/refund/?totp=${encodeURIComponent(totpCode)}`, { + totp: totpCode, + }); + }, + onSuccess: () => { + addSnackbar(`주문 '${order.name_ko || order.str_repr}'의 전액 환불(${formatPrice(order.current_paid_price)})이 처리되었습니다.`, "success"); + onClose(); + }, + onError: addErrorSnackbar, + }); + + const totpInvalid = touched && totp.trim().length === 0; + const disabled = refundMutation.isPending; + + const onSubmit = () => { + setTouched(true); + if (totp.trim().length === 0) return; + refundMutation.mutate(totp.trim()); + }; + + return ( + + 환불 처리 + + + + 주문: {order.name_ko || order.str_repr} + + + 현재 결제액: {formatPrice(order.current_paid_price)} + + + + 전액 환불만 지원됩니다. 환불 가능한 상태의 모든 상품이 환불 처리되며, 이 작업은 되돌릴 수 없습니다. + + + setTotp(e.target.value)} + error={totpInvalid} + helperText={totpInvalid ? "TOTP 코드는 필수입니다." : "환불 권한자의 인증 앱에서 6자리 코드를 입력하세요."} + fullWidth + disabled={disabled} + slotProps={{ htmlInput: { autoComplete: "one-time-code", inputMode: "numeric" } }} + /> + + + + + + + + ); +}; diff --git a/apps/pyconkr-admin/src/components/pages/shop/order/types.ts b/apps/pyconkr-admin/src/components/pages/shop/order/types.ts new file mode 100644 index 0000000..ecd4e90 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/order/types.ts @@ -0,0 +1,67 @@ +export type PaymentStatus = "pending" | "completed" | "partial_refunded" | "refunded"; +export type OrderProductStatus = "pending" | "paid" | "used" | "refunded"; + +export type SimpleUser = { + id: string; + username: string; + email: string; + unique_id: string; +}; + +export type SimpleCustomerInfo = { + name: string; + phone: string; + email: string; + organization: string | null; +}; + +export type SimpleProduct = { + id: string; + name_ko: string; + name_en: string; + price: number; +}; + +export type SimpleOrderProductOptionRelation = { + id: string; + option_group_name_ko: string; + option_group_name_en: string; + option_name_ko: string | null; + option_name_en: string | null; + custom_response: string | null; +}; + +export type SimpleOrderProductRelation = { + id: string; + product: SimpleProduct; + status: OrderProductStatus; + price: number; + donation_price: number; + options: SimpleOrderProductOptionRelation[]; +}; + +export type SimplePaymentHistory = { + id: string; + imp_id: string; + status: PaymentStatus; + price: number; + created_at: string; +}; + +export type OrderAdmin = { + id: string; + str_repr: string; + created_at: string; + updated_at: string; + name_ko: string; + name_en: string; + user: SimpleUser | null; + customer_info: SimpleCustomerInfo | null; + products: SimpleOrderProductRelation[]; + payment_histories: SimplePaymentHistory[]; + first_paid_price: number; + current_paid_price: number; + current_status: PaymentStatus; + first_paid_at: string | null; + latest_imp_id: string | null; +}; diff --git a/apps/pyconkr-admin/src/components/pages/shop/product/editor.tsx b/apps/pyconkr-admin/src/components/pages/shop/product/editor.tsx new file mode 100644 index 0000000..d537752 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/product/editor.tsx @@ -0,0 +1,256 @@ +import { + useBackendAdminClient, + useCreateMutation, + useListQuery, + useRemoveMutation, + useRetrieveQuery, + useUpdateMutation, +} from "@frontend/common/src/hooks/useAdminAPI"; +import { Add, Delete, Edit } from "@mui/icons-material"; +import { + Box, + Button, + CircularProgress, + Divider, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tabs, + Typography, +} from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import * as React from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { buildDefaultFormValues, ProductFormValues, toPayload } from "./form"; +import { BasicInfoTab } from "./tabs/basic_info_tab"; +import { OptionGroupsTab } from "./tabs/option_groups_tab"; +import { PriceOptionsTab } from "./tabs/price_options_tab"; +import { TimeSettingsTab } from "./tabs/time_settings_tab"; +import { CategoryGroupAdminWithCategories, ProductAdmin, TagAdmin } from "./types"; +import { addErrorSnackbar, addSnackbar } from "../../../../utils/snackbar"; +import { BackendAdminSignInGuard } from "../../../elements/admin_signin_guard"; +import { ErrorFallback } from "../../../elements/error_fallback"; + +const formatLeftover = (v: number | null | undefined): React.ReactNode => + v === undefined ? ( + + — + + ) : v === null ? ( + "무한대" + ) : ( + v.toLocaleString() + ); + +const InnerProductEditor: React.FC = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, () => { + const { id } = useParams<{ id?: string }>(); + const navigate = useNavigate(); + const client = useBackendAdminClient(); + + const isCreate = !id; + const productQuery = useRetrieveQuery(client, "shop", "products", id ?? ""); + const groupsQuery = useListQuery(client, "shop", "category-groups", {}); + const tagsQuery = useListQuery(client, "shop", "tags", {}); + + const existing = isCreate ? undefined : (productQuery.data ?? undefined); + const groups = groupsQuery.data ?? []; + const tags = tagsQuery.data ?? []; + + const [tab, setTab] = React.useState(0); + const [values, setValues] = React.useState(() => buildDefaultFormValues(existing)); + + React.useEffect(() => { + if (existing) setValues(buildDefaultFormValues(existing)); + }, [existing]); + + const createMutation = useCreateMutation(client, "shop", "products"); + const updateMutation = useUpdateMutation(client, "shop", "products", id ?? ""); + const deleteMutation = useRemoveMutation(client, "shop", "products", id ?? ""); + + const setField = (key: K, value: ProductFormValues[K]) => { + setValues((prev) => ({ ...prev, [key]: value })); + }; + + const onSubmit = () => { + if (!values.name_ko.trim()) { + addSnackbar("한국어 이름은 필수입니다.", "error"); + return; + } + if (!values.category) { + addSnackbar("카테고리는 필수입니다.", "error"); + return; + } + const payload = toPayload(values); + if (existing) { + updateMutation.mutate(payload as unknown as ProductAdmin, { + onSuccess: () => addSnackbar("상품을 수정했습니다.", "success"), + onError: addErrorSnackbar, + }); + } else { + createMutation.mutate(payload as unknown as ProductAdmin, { + onSuccess: (data) => { + addSnackbar("상품을 생성했습니다.", "success"); + navigate(`/shop/products/${(data as unknown as ProductAdmin).id}`); + }, + onError: addErrorSnackbar, + }); + } + }; + + const onDelete = () => { + if (!existing) return; + if (window.confirm(`'${existing.name_ko}' 상품을 삭제하시겠습니까?`)) { + deleteMutation.mutate(undefined, { + onSuccess: () => { + addSnackbar("상품을 삭제했습니다.", "success"); + navigate("/shop/products"); + }, + onError: addErrorSnackbar, + }); + } + }; + + const title = `SHOP > PRODUCTS > ${existing ? `편집: ${existing.name_ko}` : "새 객체 추가"}`; + const disabled = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending; + + return ( + + {title} + + {existing && ( + + + + + 필드 + + + + + + ID + + {existing.id} + + + + 생성일 + {new Date(existing.created_at).toLocaleString()} + + + 수정일 + {new Date(existing.updated_at).toLocaleString()} + + +
+ + + + 재고 제약 + + + + + 제약 + 한도 + 남은 재고 + + + + + 상품 자체 + {existing.stock === 0 ? "무한대" : existing.stock.toLocaleString()} + {formatLeftover(existing.leftover_stock)} + + {existing.tag_set.map((tagId) => { + const tag = tags.find((t) => t.id === tagId); + if (!tag) return null; + return ( + + 태그: {tag.name_ko || tag.name_en} + {tag.stock === 0 ? "무한대" : tag.stock.toLocaleString()} + {formatLeftover(tag.leftover_stock)} + + ); + })} + {existing.option_groups.flatMap((og) => + og.options.map((opt) => ( + + + 옵션: {og.name_ko} > {opt.name_ko} + + {opt.stock === 0 ? "무한대" : opt.stock.toLocaleString()} + {formatLeftover(opt.leftover_stock)} + + )) + )} + + 현재 판매 가능 재고 + + {existing.leftover_stock === undefined ? ( + + 백엔드 leftover_stock 노출 후 표시 + + ) : existing.leftover_stock === null ? ( + "무한대" + ) : ( + existing.leftover_stock.toLocaleString() + )} + + + +
+
+ + +
+ )} + + setTab(v)}> + + + + {existing && } + + + {tab === 0 && } + {tab === 1 && } + {tab === 2 && } + {tab === 3 && existing && } + + + {existing ? ( + <> + + + + + ) : ( + + )} + +
+ ); + }) +); + +export const ShopProductEditorPage: React.FC = () => ( + + + +); diff --git a/apps/pyconkr-admin/src/components/pages/shop/product/form.ts b/apps/pyconkr-admin/src/components/pages/shop/product/form.ts new file mode 100644 index 0000000..f6eb4c4 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/product/form.ts @@ -0,0 +1,72 @@ +import { ProductAdmin } from "./types"; + +export type ProductFormValues = { + name_ko: string; + name_en: string; + description_ko: string; + description_en: string; + image: string; + price: string; + stock: string; + hidden: boolean; + max_quantity_per_user: string; + category: string; + priority: string; + visible_starts_at: string; + visible_ends_at: string; + orderable_starts_at: string; + orderable_ends_at: string; + refundable_ends_at: string; + donation_allowed: boolean; + donation_min_price: string; + donation_max_price: string; + tag_set: string[]; +}; + +export type SetField = (key: K, value: ProductFormValues[K]) => void; + +export const buildDefaultFormValues = (existing?: ProductAdmin): ProductFormValues => ({ + name_ko: existing?.name_ko ?? "", + name_en: existing?.name_en ?? "", + description_ko: existing?.description_ko ?? "", + description_en: existing?.description_en ?? "", + image: existing?.image ?? "", + price: existing ? String(existing.price) : "0", + stock: existing ? String(existing.stock) : "0", + hidden: existing?.hidden ?? false, + max_quantity_per_user: existing ? String(existing.max_quantity_per_user) : "0", + category: existing?.category ?? "", + priority: existing ? String(existing.priority) : "0", + visible_starts_at: existing?.visible_starts_at ?? "", + visible_ends_at: existing?.visible_ends_at ?? "", + orderable_starts_at: existing?.orderable_starts_at ?? "", + orderable_ends_at: existing?.orderable_ends_at ?? "", + refundable_ends_at: existing?.refundable_ends_at ?? "", + donation_allowed: existing?.donation_allowed ?? false, + donation_min_price: existing ? String(existing.donation_min_price) : "0", + donation_max_price: existing ? String(existing.donation_max_price) : "0", + tag_set: existing?.tag_set ?? [], +}); + +export const toPayload = (v: ProductFormValues) => ({ + name_ko: v.name_ko, + name_en: v.name_en, + description_ko: v.description_ko, + description_en: v.description_en, + image: v.image, + price: Number(v.price) || 0, + stock: Number(v.stock) || 0, + hidden: v.hidden, + max_quantity_per_user: Number(v.max_quantity_per_user) || 0, + category: v.category, + priority: Number(v.priority) || 0, + visible_starts_at: v.visible_starts_at || null, + visible_ends_at: v.visible_ends_at || null, + orderable_starts_at: v.orderable_starts_at || null, + orderable_ends_at: v.orderable_ends_at || null, + refundable_ends_at: v.refundable_ends_at || null, + donation_allowed: v.donation_allowed, + donation_min_price: Number(v.donation_min_price) || 0, + donation_max_price: Number(v.donation_max_price) || 0, + tag_set: v.tag_set, +}); diff --git a/apps/pyconkr-admin/src/components/pages/shop/product/list.tsx b/apps/pyconkr-admin/src/components/pages/shop/product/list.tsx new file mode 100644 index 0000000..ac137d3 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/product/list.tsx @@ -0,0 +1,213 @@ +import { useBackendAdminClient, useListQuery } from "@frontend/common/src/hooks/useAdminAPI"; +import { Add, Delete, Edit } from "@mui/icons-material"; +import { + Button, + Chip, + CircularProgress, + IconButton, + MenuItem, + Select, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { useMutation } from "@tanstack/react-query"; +import * as React from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; + +import { CategoryGroupAdminWithCategories, ProductAdmin, ProductCurrentStatus } from "./types"; +import { addErrorSnackbar, addSnackbar } from "../../../../utils/snackbar"; +import { BackendAdminSignInGuard } from "../../../elements/admin_signin_guard"; +import { ErrorFallback } from "../../../elements/error_fallback"; +import { PRODUCT_STATUS_LABEL } from "../_common/status_labels"; + +const formatPrice = (price: number) => `₩${price.toLocaleString()}`; +const formatLeftoverStock = (leftover: number | null | undefined) => { + if (leftover === null || leftover === undefined) return "무한대"; + return leftover.toLocaleString(); +}; + +type StatusFilter = "all" | ProductCurrentStatus; + +const InnerProductList: React.FC = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, () => { + const client = useBackendAdminClient(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + const nameQuery = searchParams.get("name") ?? ""; + const categoryGroupQuery = searchParams.get("category_group") ?? ""; + const categoryQuery = searchParams.get("category") ?? ""; + const statusQuery = (searchParams.get("status") ?? "all") as StatusFilter; + + const apiParams: Record = {}; + if (nameQuery.trim()) apiParams.name = nameQuery.trim(); + if (categoryGroupQuery) apiParams.category_group = categoryGroupQuery; + if (categoryQuery) apiParams.category = categoryQuery; + if (statusQuery !== "all") apiParams.status = statusQuery; + + const productsQuery = useListQuery(client, "shop", "products", apiParams); + const groupsQuery = useListQuery(client, "shop", "category-groups", {}); + + const products = productsQuery.data ?? []; + const groups = React.useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]); + + const categoryToGroup: Record = React.useMemo(() => { + const map: Record = {}; + for (const g of groups) { + for (const c of g.categories ?? []) { + map[c.id] = { groupId: g.id, groupName: g.name, categoryName: c.name }; + } + } + return map; + }, [groups]); + + const updateParam = (key: string, value: string) => { + const next = new URLSearchParams(searchParams); + if (value) next.set(key, value); + else next.delete(key); + setSearchParams(next, { replace: true }); + }; + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => client.delete(`v1/admin-api/shop/products/${id}/`), + onSuccess: () => addSnackbar("상품을 삭제했습니다.", "success"), + onError: addErrorSnackbar, + }); + + const handleDelete = (id: string, name: string) => { + if (window.confirm(`'${name}' 상품을 삭제하시겠습니까?`)) { + deleteMutation.mutate(id); + } + }; + + return ( + + + SHOP > PRODUCTS > 목록 + + + + + updateParam("name", e.target.value)} sx={{ minWidth: 240 }} /> + + + + + {products.length} 건 + + + + + + + 이름 + 카테고리 + 가격 + 판매 가능 재고 + 상태 + 작업 + + + + {products.length === 0 && ( + + + 조건에 맞는 상품이 없습니다. + + + )} + {products.map((product) => { + const cat = categoryToGroup[product.category]; + const status = PRODUCT_STATUS_LABEL[product.current_status] ?? { label: product.current_status, color: "default" as const }; + return ( + + + {product.name_ko || product.str_repr} + + {cat ? `${cat.groupName} > ${cat.categoryName}` : "—"} + {formatPrice(product.price)} + {formatLeftoverStock(product.leftover_stock)} + + + + + navigate(`/shop/products/${product.id}`)} aria-label="수정"> + + + handleDelete(product.id, product.name_ko || product.str_repr)} + aria-label="삭제" + disabled={deleteMutation.isPending} + > + + + + + ); + })} + +
+
+ ); + }) +); + +export const ShopProductListPage: React.FC = () => ( + + + +); diff --git a/apps/pyconkr-admin/src/components/pages/shop/product/tabs/basic_info_tab.tsx b/apps/pyconkr-admin/src/components/pages/shop/product/tabs/basic_info_tab.tsx new file mode 100644 index 0000000..40e0799 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/product/tabs/basic_info_tab.tsx @@ -0,0 +1,147 @@ +import { Components } from "@frontend/common"; +import { useCommonContext } from "@frontend/common/src/hooks/useCommonContext"; +import { + Autocomplete, + Box, + Checkbox, + Chip, + Divider, + FormControlLabel, + MenuItem, + Stack, + styled, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import * as React from "react"; + +import { IMAGE_FILE_EXTENSIONS } from "../../../../../consts/file_extensions"; +import { PublicFilePicker } from "../../../../elements/public_file_picker"; +import { ProductFormValues, SetField } from "../form"; +import { CategoryGroupAdminWithCategories, TagAdmin } from "../types"; + +const MUIStyledFieldset = styled("fieldset")(({ theme }) => ({ + color: theme.palette.text.secondary, + margin: 0, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, +})); + +const MDXRendererContainer = styled(Box)(({ theme }) => ({ + width: "50%", + maxWidth: "50%", + "& .markdown-body": { + width: "100%", + p: { margin: theme.spacing(2, 0) }, + a: { color: theme.palette.primary.main }, + }, +})); + +type Props = { + values: ProductFormValues; + setField: SetField; + disabled?: boolean; + groups: CategoryGroupAdminWithCategories[]; + tags: TagAdmin[]; +}; + +export const BasicInfoTab: React.FC = ({ values, setField, disabled, groups, tags }) => { + const [langTab, setLangTab] = React.useState<"ko" | "en">("ko"); + const { baseUrl, mdxComponents } = useCommonContext(); + const selectedTags = tags.filter((t) => values.tag_set.includes(t.id)); + const isKo = langTab === "ko"; + const nameKey = isKo ? "name_ko" : "name_en"; + const descKey = isKo ? "description_ko" : "description_en"; + + return ( + + setField("image", v)} + choicesApp="shop" + choicesResource="products" + choicesField="image" + acceptExtensions={IMAGE_FILE_EXTENSIONS} + /> + + setField("category", e.target.value)} fullWidth> + {groups.flatMap((group) => [ + + {group.name} + , + ...(group.categories ?? []).map((c) => ( + + {c.name} + + )), + ])} + + + t.name_ko || t.name_en || t.id} + value={selectedTags} + onChange={(_, newValue) => + setField( + "tag_set", + newValue.map((t) => t.id) + ) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...rest } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + setField("hidden", e.target.checked)} />} + label="비공개 (사용자에게 노출되지 않음)" + /> + + + + + setLangTab(v)} scrollButtons={false}> + + + + + setField(nameKey, e.target.value)} + fullWidth + /> + + + 상품 설명 {isKo ? "(한국어)" : "(영어)"} + + + + setField(descKey, value ?? "")} + /> + + + + + + + + + + ); +}; diff --git a/apps/pyconkr-admin/src/components/pages/shop/product/tabs/option_groups_tab.tsx b/apps/pyconkr-admin/src/components/pages/shop/product/tabs/option_groups_tab.tsx new file mode 100644 index 0000000..40f05e1 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/product/tabs/option_groups_tab.tsx @@ -0,0 +1,497 @@ +import { useBackendAdminClient, useCreateMutation, useUpdateMutation } from "@frontend/common/src/hooks/useAdminAPI"; +import { Add, Delete, Edit, ExpandMore } from "@mui/icons-material"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useMutation } from "@tanstack/react-query"; +import * as React from "react"; + +import { addErrorSnackbar, addSnackbar } from "../../../../../utils/snackbar"; +import { OptionAdmin, OptionGroupAdmin } from "../types"; + +// ----------------- Option dialog ----------------- +type OptionFormValues = { + name_ko: string; + name_en: string; + additional_price: string; + stock: string; + max_quantity_per_user: string; + priority: string; +}; + +type OptionDialogProps = { + open: boolean; + onClose: () => void; + optionGroup: OptionGroupAdmin; + option?: OptionAdmin; +}; + +const OptionDialog: React.FC = ({ open, onClose, optionGroup, option }) => { + const client = useBackendAdminClient(); + const updateMutation = useUpdateMutation(client, "shop", "option-groups", optionGroup.id); + const [values, setValues] = React.useState({ + name_ko: "", + name_en: "", + additional_price: "0", + stock: "0", + max_quantity_per_user: "0", + priority: "0", + }); + + React.useEffect(() => { + if (open) { + setValues({ + name_ko: option?.name_ko ?? "", + name_en: option?.name_en ?? "", + additional_price: option ? String(option.additional_price) : "0", + stock: option ? String(option.stock) : "0", + max_quantity_per_user: option ? String(option.max_quantity_per_user) : "0", + priority: option ? String(option.priority) : String(optionGroup.options.length * 10), + }); + } + }, [open, option, optionGroup.options.length]); + + const onSubmit = () => { + if (!values.name_ko.trim()) { + addSnackbar("한국어 이름은 필수입니다.", "error"); + return; + } + if (!values.name_en.trim()) { + addSnackbar("영어 이름은 필수입니다.", "error"); + return; + } + const optionPayload = { + name_ko: values.name_ko, + name_en: values.name_en, + additional_price: Number(values.additional_price) || 0, + stock: Number(values.stock) || 0, + max_quantity_per_user: Number(values.max_quantity_per_user) || 0, + priority: Number(values.priority) || 0, + group: optionGroup.id, + }; + const newOptions = option + ? optionGroup.options.map((o) => (o.id === option.id ? { ...o, ...optionPayload } : o)) + : [...optionGroup.options, optionPayload]; + updateMutation.mutate({ ...optionGroup, options: newOptions } as unknown as OptionGroupAdmin, { + onSuccess: () => { + addSnackbar(`옵션 '${values.name_ko}'을(를) ${option ? "수정" : "생성"}했습니다.`, "success"); + onClose(); + }, + onError: addErrorSnackbar, + }); + }; + + return ( + + {option ? `옵션 수정: ${option.name_ko}` : "새 옵션 추가"} + + + + setValues((p) => ({ ...p, name_ko: e.target.value }))} + fullWidth + autoFocus + /> + setValues((p) => ({ ...p, name_en: e.target.value }))} + fullWidth + /> + + + setValues((p) => ({ ...p, additional_price: e.target.value }))} + fullWidth + /> + setValues((p) => ({ ...p, priority: e.target.value }))} + fullWidth + /> + + + setValues((p) => ({ ...p, stock: e.target.value }))} + fullWidth + /> + setValues((p) => ({ ...p, max_quantity_per_user: e.target.value }))} + fullWidth + /> + + + + + + + + + ); +}; + +// ----------------- OptionGroup dialog ----------------- +type OptionGroupFormValues = { + name_ko: string; + name_en: string; + min_quantity_per_product: string; + max_quantity_per_product: string; + is_custom_response: boolean; + custom_response_pattern: string; + priority: string; + response_modifiable_ends_at: string; +}; + +type OptionGroupDialogProps = { + open: boolean; + onClose: () => void; + productId: string; + group?: OptionGroupAdmin; + existingGroupCount: number; +}; + +const OptionGroupDialog: React.FC = ({ open, onClose, productId, group, existingGroupCount }) => { + const client = useBackendAdminClient(); + const createMutation = useCreateMutation(client, "shop", "option-groups"); + const updateMutation = useUpdateMutation(client, "shop", "option-groups", group?.id ?? ""); + + const [values, setValues] = React.useState({ + name_ko: "", + name_en: "", + min_quantity_per_product: "0", + max_quantity_per_product: "1", + is_custom_response: false, + custom_response_pattern: "", + priority: "0", + response_modifiable_ends_at: "", + }); + + React.useEffect(() => { + if (open) { + setValues({ + name_ko: group?.name_ko ?? "", + name_en: group?.name_en ?? "", + min_quantity_per_product: group ? String(group.min_quantity_per_product) : "0", + max_quantity_per_product: group ? String(group.max_quantity_per_product) : "1", + is_custom_response: group?.is_custom_response ?? false, + custom_response_pattern: group?.custom_response_pattern ?? "", + priority: group ? String(group.priority) : String(existingGroupCount * 10), + response_modifiable_ends_at: group?.response_modifiable_ends_at ?? "", + }); + } + }, [open, group, existingGroupCount]); + + const onSubmit = () => { + if (!values.name_ko.trim()) { + addSnackbar("한국어 이름은 필수입니다.", "error"); + return; + } + if (!values.name_en.trim()) { + addSnackbar("영어 이름은 필수입니다.", "error"); + return; + } + + const payload = { + product: productId, + priority: Number(values.priority) || 0, + name_ko: values.name_ko, + name_en: values.name_en, + min_quantity_per_product: Number(values.min_quantity_per_product) || 0, + max_quantity_per_product: Number(values.max_quantity_per_product) || 1, + is_custom_response: values.is_custom_response, + custom_response_pattern: values.is_custom_response ? values.custom_response_pattern : "", + response_modifiable_ends_at: values.response_modifiable_ends_at || null, + options: group?.options ?? [], + }; + + const handlers = { + onSuccess: () => { + addSnackbar(`옵션 그룹 '${values.name_ko}'을(를) ${group ? "수정" : "생성"}했습니다.`, "success"); + onClose(); + }, + onError: addErrorSnackbar, + }; + + if (group) updateMutation.mutate(payload as unknown as OptionGroupAdmin, handlers); + else createMutation.mutate(payload as unknown as OptionGroupAdmin, handlers); + }; + + const pending = createMutation.isPending || updateMutation.isPending; + + return ( + + {group ? `옵션 그룹 수정: ${group.name_ko}` : "새 옵션 그룹 추가"} + + + + setValues((p) => ({ ...p, name_ko: e.target.value }))} + fullWidth + autoFocus + /> + setValues((p) => ({ ...p, name_en: e.target.value }))} + fullWidth + /> + + + setValues((p) => ({ ...p, min_quantity_per_product: e.target.value }))} + fullWidth + /> + setValues((p) => ({ ...p, max_quantity_per_product: e.target.value }))} + fullWidth + /> + setValues((p) => ({ ...p, priority: e.target.value }))} + fullWidth + /> + + setValues((p) => ({ ...p, is_custom_response: e.target.checked }))} /> + } + label="사용자 입력 옵션 (custom response)" + /> + {values.is_custom_response && ( + setValues((p) => ({ ...p, custom_response_pattern: e.target.value }))} + fullWidth + helperText="예: ^.{1,20}$" + /> + )} + setValues((p) => ({ ...p, response_modifiable_ends_at: e.target.value }))} + fullWidth + slotProps={{ inputLabel: { shrink: true } }} + helperText="비워두면 제한 없음" + /> + + + + + + + + ); +}; + +// ----------------- Tab component ----------------- +type Props = { + productId: string; + optionGroups: OptionGroupAdmin[]; +}; + +export const OptionGroupsTab: React.FC = ({ productId, optionGroups }) => { + const client = useBackendAdminClient(); + const [groupDialog, setGroupDialog] = React.useState<{ open: boolean; group?: OptionGroupAdmin }>({ open: false }); + const [optionDialog, setOptionDialog] = React.useState<{ open: boolean; optionGroup?: OptionGroupAdmin; option?: OptionAdmin }>({ open: false }); + + const deleteGroupMutation = useMutation({ + mutationFn: async (groupId: string) => client.delete(`v1/admin-api/shop/option-groups/${groupId}/`), + onSuccess: () => addSnackbar("옵션 그룹을 삭제했습니다.", "success"), + onError: addErrorSnackbar, + }); + + const deleteOptionMutation = useMutation({ + mutationFn: async (params: { group: OptionGroupAdmin; optionId: string }) => { + const newOptions = params.group.options.filter((o) => o.id !== params.optionId); + return client.patch(`v1/admin-api/shop/option-groups/${params.group.id}/`, { ...params.group, options: newOptions }); + }, + onSuccess: () => addSnackbar("옵션을 삭제했습니다.", "success"), + onError: addErrorSnackbar, + }); + + const handleDeleteGroup = (group: OptionGroupAdmin) => { + if (window.confirm(`'${group.name_ko}' 옵션 그룹을 삭제하시겠습니까? 하위 옵션도 함께 삭제됩니다.`)) { + deleteGroupMutation.mutate(group.id); + } + }; + + const handleDeleteOption = (group: OptionGroupAdmin, option: OptionAdmin) => { + if (window.confirm(`'${option.name_ko}' 옵션을 삭제하시겠습니까?`)) { + deleteOptionMutation.mutate({ group, optionId: option.id }); + } + }; + + const sortedGroups = [...optionGroups].sort((a, b) => a.priority - b.priority); + + return ( + + + 옵션 그룹 ({sortedGroups.length}) + + + + {sortedGroups.length === 0 && ( + + 옵션 그룹이 없습니다. + + )} + + {sortedGroups.map((group) => { + const options = [...group.options].sort((a, b) => a.priority - b.priority); + return ( + + }> + + {group.name_ko} + {group.is_custom_response && } + + + 최소 {group.min_quantity_per_product} / 최대 {group.max_quantity_per_product} · 옵션 {options.length}개 + + + + + + + {!group.is_custom_response && ( + + )} + + + + {group.is_custom_response ? ( + + 이 그룹은 사용자 입력형이며, 옵션이 없습니다. 검증 정규식: {group.custom_response_pattern || "(없음)"} + + ) : ( + + + + 이름 + 추가 금액 + 재고 + 사용자당 최대 + 우선순위 + 작업 + + + + {options.length === 0 && ( + + + 옵션이 없습니다. + + + )} + {options.map((option) => ( + + {option.name_ko} + ₩{option.additional_price.toLocaleString()} + {option.stock === 0 ? "무한대" : option.stock.toLocaleString()} + + {option.max_quantity_per_user === 0 ? "제한 없음" : option.max_quantity_per_user.toLocaleString()} + + {option.priority} + + setOptionDialog({ open: true, optionGroup: group, option })} aria-label="수정"> + + + handleDeleteOption(group, option)} aria-label="삭제"> + + + + + ))} + +
+ )} +
+
+
+ ); + })} + + setGroupDialog({ open: false })} + productId={productId} + group={groupDialog.group} + existingGroupCount={sortedGroups.length} + /> + {optionDialog.optionGroup && ( + setOptionDialog({ open: false })} + optionGroup={optionDialog.optionGroup} + option={optionDialog.option} + /> + )} +
+ ); +}; diff --git a/apps/pyconkr-admin/src/components/pages/shop/product/tabs/price_options_tab.tsx b/apps/pyconkr-admin/src/components/pages/shop/product/tabs/price_options_tab.tsx new file mode 100644 index 0000000..bb0c29f --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/product/tabs/price_options_tab.tsx @@ -0,0 +1,56 @@ +import { Checkbox, Divider, FormControlLabel, Stack, TextField } from "@mui/material"; +import * as React from "react"; + +import { ProductFormValues, SetField } from "../form"; + +type Props = { + values: ProductFormValues; + setField: SetField; +}; + +export const PriceOptionsTab: React.FC = ({ values, setField }) => ( + + + setField("price", e.target.value)} fullWidth /> + setField("stock", e.target.value)} + fullWidth + /> + setField("max_quantity_per_user", e.target.value)} + fullWidth + /> + setField("priority", e.target.value)} fullWidth /> + + + setField("donation_allowed", e.target.checked)} />} + label="기부 허용" + /> + {values.donation_allowed && ( + + setField("donation_min_price", e.target.value)} + fullWidth + /> + setField("donation_max_price", e.target.value)} + fullWidth + /> + + )} + +); diff --git a/apps/pyconkr-admin/src/components/pages/shop/product/tabs/time_settings_tab.tsx b/apps/pyconkr-admin/src/components/pages/shop/product/tabs/time_settings_tab.tsx new file mode 100644 index 0000000..fe7900c --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/product/tabs/time_settings_tab.tsx @@ -0,0 +1,37 @@ +import { Stack, TextField, Typography } from "@mui/material"; +import * as React from "react"; + +import { ProductFormValues, SetField } from "../form"; + +type Props = { + values: ProductFormValues; + setField: SetField; +}; + +type DateTimeFieldKey = "visible_starts_at" | "visible_ends_at" | "orderable_starts_at" | "orderable_ends_at" | "refundable_ends_at"; + +const dateTimeFieldProps = (values: ProductFormValues, setField: SetField, key: DateTimeFieldKey, label: string) => ({ + label, + type: "datetime-local" as const, + value: values[key]?.slice(0, 16) ?? "", + onChange: (e: React.ChangeEvent) => setField(key, e.target.value), + fullWidth: true, + slotProps: { inputLabel: { shrink: true } }, +}); + +export const TimeSettingsTab: React.FC = ({ values, setField }) => ( + + + 비워두면 항상 활성으로 처리됩니다. + + + + + + + + + + + +); diff --git a/apps/pyconkr-admin/src/components/pages/shop/product/types.ts b/apps/pyconkr-admin/src/components/pages/shop/product/types.ts new file mode 100644 index 0000000..11e5c2d --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/product/types.ts @@ -0,0 +1,85 @@ +export type OptionAdmin = { + id: string; + created_at: string; + updated_at: string; + group: string; + priority: number; + name_ko: string; + name_en: string; + max_quantity_per_user: number; + additional_price: number; + stock: number; + leftover_stock?: number | null; +}; + +export type OptionGroupAdmin = { + id: string; + created_at: string; + updated_at: string; + str_repr: string; + product: string; + priority: number; + name_ko: string; + name_en: string; + min_quantity_per_product: number; + max_quantity_per_product: number; + is_custom_response: boolean; + custom_response_pattern: string; + response_modifiable_ends_at: string | null; + options: OptionAdmin[]; +}; + +export type ProductAdmin = { + id: string; + created_at: string; + updated_at: string; + str_repr: string; + name_ko: string; + name_en: string; + description_ko: string; + description_en: string; + image: string; + price: number; + stock: number; + hidden: boolean; + max_quantity_per_user: number; + visible_starts_at: string; + visible_ends_at: string; + orderable_starts_at: string; + orderable_ends_at: string; + refundable_ends_at: string; + category: string; + priority: number; + donation_allowed: boolean; + donation_min_price: number; + donation_max_price: number; + option_groups: OptionGroupAdmin[]; + tag_set: string[]; + leftover_stock?: number | null; + current_status: ProductCurrentStatus; +}; + +export type ProductCurrentStatus = "hidden" | "out_of_visible_period" | "out_of_orderable_period" | "active"; + +export type TagAdmin = { + id: string; + name_ko: string; + name_en: string; + stock: number; + max_quantity_per_user: number; + leftover_stock?: number | null; +}; + +export type CategoryAdminFromGroup = { + id: string; + name: string; + priority: number; + group: string; +}; + +export type CategoryGroupAdminWithCategories = { + id: string; + name: string; + priority: number; + categories: CategoryAdminFromGroup[]; +}; diff --git a/apps/pyconkr-admin/src/components/pages/shop/tag/list.tsx b/apps/pyconkr-admin/src/components/pages/shop/tag/list.tsx new file mode 100644 index 0000000..655146b --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/shop/tag/list.tsx @@ -0,0 +1,51 @@ +import { Chip } from "@mui/material"; +import * as React from "react"; +import { Link } from "react-router-dom"; + +import { AdminList, AdminListColumn } from "../../../layouts/admin_list"; + +const formatStock = (stock: number) => (stock === 0 ? "무한대" : stock.toLocaleString()); +const formatMaxPerUser = (qty: number) => (qty === 0 ? "제한 없음" : qty.toLocaleString()); + +const columns: AdminListColumn[] = [ + { + field: "name_ko", + header: "이름", + width: "30%", + render: (row) => { + const id = row.id as string; + const name = String(row.name_ko ?? row.name_en ?? ""); + const leftover = row.leftover_stock; + const soldOut = typeof leftover === "number" && leftover <= 0; + return ( + <> + {name} + {soldOut && } + + ); + }, + }, + { + field: "stock", + header: "재고", + align: "right", + render: (row) => formatStock(Number(row.stock ?? 0)), + }, + { + field: "max_quantity_per_user", + header: "사용자당 최대 수량", + align: "right", + render: (row) => formatMaxPerUser(Number(row.max_quantity_per_user ?? 0)), + }, + { + field: "leftover_stock", + header: "남은 재고", + align: "right", + render: (row) => { + const v = row.leftover_stock; + return v === null || v === undefined ? "—" : Number(v).toLocaleString(); + }, + }, +]; + +export const ShopTagListPage: React.FC = () => ; diff --git a/apps/pyconkr-admin/src/components/pages/user/editor.tsx b/apps/pyconkr-admin/src/components/pages/user/editor.tsx index 614eea4..b718036 100644 --- a/apps/pyconkr-admin/src/components/pages/user/editor.tsx +++ b/apps/pyconkr-admin/src/components/pages/user/editor.tsx @@ -6,6 +6,7 @@ import * as React from "react"; import { useNavigate, useParams } from "react-router-dom"; import { PasswordResultDialog } from "./password_result_dialog"; +import { ShopOrderSection } from "./shop_order_section"; import { addErrorSnackbar } from "../../../utils/snackbar"; import { ErrorFallback } from "../../elements/error_fallback"; import { AdminEditor } from "../../layouts/admin_editor"; @@ -90,7 +91,10 @@ export const AdminUserExtEditor: React.FC = ErrorBoundary.with( - + + {id && } +
+
); }) diff --git a/apps/pyconkr-admin/src/components/pages/user/shop_order_section.tsx b/apps/pyconkr-admin/src/components/pages/user/shop_order_section.tsx new file mode 100644 index 0000000..cf0f9c9 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/user/shop_order_section.tsx @@ -0,0 +1,84 @@ +import { useBackendAdminClient, useListQuery } from "@frontend/common/src/hooks/useAdminAPI"; +import { Alert, Chip, CircularProgress, Divider, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import * as React from "react"; +import { Link } from "react-router-dom"; + +import { ErrorFallback } from "../../elements/error_fallback"; +import { PAYMENT_STATUS_LABEL } from "../shop/_common/status_labels"; +import { PaymentStatus } from "../shop/order/types"; + +type OrderListRow = { + id: string; + str_repr: string; + name_ko: string; + current_status: PaymentStatus; + current_paid_price: number; + first_paid_at: string | null; + created_at: string; +}; + +const formatPrice = (price: number) => `₩${price.toLocaleString()}`; + +const InnerShopOrderSection: React.FC<{ userId: string }> = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, ({ userId }) => { + const client = useBackendAdminClient(); + const ordersQuery = useListQuery(client, "shop", "orders", { user_id: userId }); + const orders = ordersQuery.data ?? []; + + return ( + + + + 스토어 주문 내역 ({orders.length}) + + + 이 사용자의 모든 스토어 주문입니다. 환불·알림 등 액션은 주문 상세 페이지에서 수행할 수 있습니다. + + + + + 주문 ID + 이름 + 상태 + 결제액 + 결제일 + 생성일 + + + + {orders.length === 0 && ( + + + 주문 내역이 없습니다. + + + )} + {orders.map((order) => { + const status = PAYMENT_STATUS_LABEL[order.current_status] ?? { label: order.current_status, color: "default" as const }; + return ( + + + + {order.id.slice(0, 8)} + + + {order.name_ko || order.str_repr} + + + + {formatPrice(order.current_paid_price)} + {order.first_paid_at ? new Date(order.first_paid_at).toLocaleString() : "—"} + {new Date(order.created_at).toLocaleString()} + + ); + })} + +
+
+ ); + }) +); + +export const ShopOrderSection: React.FC<{ userId: string }> = (props) => ; diff --git a/apps/pyconkr-admin/src/consts/file_extensions.ts b/apps/pyconkr-admin/src/consts/file_extensions.ts new file mode 100644 index 0000000..0d1688e --- /dev/null +++ b/apps/pyconkr-admin/src/consts/file_extensions.ts @@ -0,0 +1 @@ +export const IMAGE_FILE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"]; diff --git a/apps/pyconkr-admin/src/routes.tsx b/apps/pyconkr-admin/src/routes.tsx index b0204af..9a6bbb7 100644 --- a/apps/pyconkr-admin/src/routes.tsx +++ b/apps/pyconkr-admin/src/routes.tsx @@ -10,14 +10,18 @@ import { Email, Event, FilePresent, + FolderSpecial, Forum, Handshake, + LocalOffer, ManageAccounts, MarkEmailRead, MeetingRoom, NoteAlt, Public, + ReceiptLong, Send, + ShoppingBag, Sms, StickyNote2, Tag, @@ -41,6 +45,13 @@ import { AdminNotificationHistoryEditor } from "./components/pages/notification/ import { AdminSMSTemplateEditor } from "./components/pages/notification/sms_template_editor"; import { AdminCMSPageEditor } from "./components/pages/page/editor"; import { AdminPresentationEditor } from "./components/pages/presentation/editor"; +import { ShopCategoryGroupEditorPage } from "./components/pages/shop/category_group/editor"; +import { ShopCategoryGroupListPage } from "./components/pages/shop/category_group/list"; +import { ShopOrderEditorPage } from "./components/pages/shop/order/editor"; +import { ShopOrderListPage } from "./components/pages/shop/order/list"; +import { ShopProductEditorPage } from "./components/pages/shop/product/editor"; +import { ShopProductListPage } from "./components/pages/shop/product/list"; +import { ShopTagListPage } from "./components/pages/shop/tag/list"; import { SiteMapList } from "./components/pages/sitemap/list"; import { AdminUserExtEditor } from "./components/pages/user/editor"; @@ -178,6 +189,41 @@ export const RouteDefinitions: RouteDef[] = [ app: "event", resource: "presentation", }, + { + type: "separator", + key: "shop-separator", + title: "스토어", + }, + { + type: "autoAdminRouteDefinition", + key: "shop-category-groups", + icon: FolderSpecial, + title: "카테고리 그룹", + app: "shop", + resource: "category-groups", + }, + { + type: "autoAdminRouteDefinition", + key: "shop-tags", + icon: LocalOffer, + title: "태그", + app: "shop", + resource: "tags", + }, + { + type: "routeDefinition", + key: "shop-product", + icon: ShoppingBag, + title: "상품", + route: "/shop/products", + }, + { + type: "routeDefinition", + key: "shop-order", + icon: ReceiptLong, + title: "주문", + route: "/shop/orders", + }, { type: "separator", key: "user-separator", @@ -328,4 +374,13 @@ export const RegisteredRoutes = { "/event/presentation/:id": , "/modification-audit": , "/modification-audit/modification-audit/:id": , + "/shop/category-groups": , + "/shop/category-groups/create": , + "/shop/category-groups/:id": , + "/shop/tags": , + "/shop/products": , + "/shop/products/create": , + "/shop/products/:id": , + "/shop/orders": , + "/shop/orders/:id": , }; diff --git a/packages/common/src/apis/admin_api.ts b/packages/common/src/apis/admin_api.ts index b43a9b7..cf887e1 100644 --- a/packages/common/src/apis/admin_api.ts +++ b/packages/common/src/apis/admin_api.ts @@ -8,6 +8,7 @@ import { OpenAPISchema, PageSectionBulkUpdateSchema, PageSectionSchema, + PaginatedListResponse, PublicFileSchema, UserChangePasswordSchema, UserResetPasswordResponseSchema, @@ -40,6 +41,11 @@ export const list = () => client.get(`v1/admin-api/${app}/${resource}/`, { params }); +export const listPaginated = + (client: BackendAPIClient, app: string, resource: string, params?: Record) => + () => + client.get>(`v1/admin-api/${app}/${resource}/`, { params }); + export const retrieve = (client: BackendAPIClient, app: string, resource: string, id: string) => () => { diff --git a/packages/common/src/components/md_editor.tsx b/packages/common/src/components/md_editor.tsx index fdc6665..e4d42c0 100644 --- a/packages/common/src/components/md_editor.tsx +++ b/packages/common/src/components/md_editor.tsx @@ -59,7 +59,7 @@ export const MarkdownEditor: React.FC = ({ disabled, name, defaul commands.divider, commands.help, ]} - extraCommands={extraCommands} + extraCommands={extraCommands ?? []} style={TextEditorStyle} /> diff --git a/packages/common/src/hooks/useAdminAPI.ts b/packages/common/src/hooks/useAdminAPI.ts index 0ce891e..1f7a6c2 100644 --- a/packages/common/src/hooks/useAdminAPI.ts +++ b/packages/common/src/hooks/useAdminAPI.ts @@ -92,6 +92,12 @@ export const useListQuery = (client: BackendAPIClient, app: string, resource: queryFn: BackendAdminAPIs.list(client, app, resource, params), }); +export const useListPaginatedQuery = (client: BackendAPIClient, app: string, resource: string, params?: Record) => + useSuspenseQuery({ + queryKey: [...QUERY_KEYS.ADMIN_LIST, app, resource, "paginated", JSON.stringify(params)], + queryFn: BackendAdminAPIs.listPaginated(client, app, resource, params), + }); + export const useRetrieveQuery = (client: BackendAPIClient, app: string, resource: string, id: string) => useSuspenseQuery({ queryKey: [...QUERY_KEYS.ADMIN_RETRIEVE, app, resource, id], diff --git a/packages/common/src/schemas/backendAdminAPI.ts b/packages/common/src/schemas/backendAdminAPI.ts index 76f127e..7f3ad4c 100644 --- a/packages/common/src/schemas/backendAdminAPI.ts +++ b/packages/common/src/schemas/backendAdminAPI.ts @@ -19,6 +19,13 @@ export type AdminSchemaDefinition = { export type ChoicesResponse = Record; +export type PaginatedListResponse = { + count: number; + next: string | null; + previous: string | null; + results: T[]; +}; + export type UserSchema = { id: number; username: string;