+>(({ className, ...props }, ref) => (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..85d83be
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,53 @@
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..e56b0af
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx
index 01ff749..4860a76 100644
--- a/src/context/AuthContext.tsx
+++ b/src/context/AuthContext.tsx
@@ -1,5 +1,6 @@
-import React, { createContext, useContext, useEffect, useState } from 'react';
+import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { Members } from '../api/auth/members';
+import { showError, showSuccess } from '@/utils/alert';
interface AuthContextType {
isAuthenticated: boolean | null; // null = loading, true = authenticated, false = not authenticated
@@ -11,19 +12,46 @@ const AuthContext = createContext(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(null);
- const checkAuth = async () => {
+ const maybeShowWelcomeMessage = (name: string) => {
+ const isLoginPage = window.location.pathname === '/login';
+ if (isLoginPage) {
+ return;
+ }
+
+ const hasShownWelcome = sessionStorage.getItem('hasShownWelcome');
+ if (hasShownWelcome) {
+ return;
+ }
+
+ showSuccess(`안녕하세요 ${name} 관리자님!`);
+ sessionStorage.setItem('hasShownWelcome', 'true');
+ };
+
+ const checkAuth = useCallback(async () => {
try {
- const authenticated = await Members();
- setIsAuthenticated(authenticated);
+ const response = await Members();
+ if (!response.ok || !response.data) {
+ setIsAuthenticated(false);
+ return;
+ }
+
+ if (response.data.role !== 'ADMIN') {
+ setIsAuthenticated(false);
+ showError('로그인 실패! 관리자만 접근 가능합니다.');
+ return;
+ }
+
+ setIsAuthenticated(true);
+ maybeShowWelcomeMessage(response.data.name);
} catch (error) {
console.error('인증 확인 실패:', error);
setIsAuthenticated(false);
}
- };
+ }, []);
useEffect(() => {
- checkAuth();
- }, []);
+ void checkAuth();
+ }, [checkAuth]);
return (
@@ -39,4 +67,4 @@ export const useAuth = () => {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
-};
\ No newline at end of file
+};
diff --git a/src/hooks/useSessionKeepAlive.ts b/src/hooks/useSessionKeepAlive.ts
index 228ac52..fe60d0f 100644
--- a/src/hooks/useSessionKeepAlive.ts
+++ b/src/hooks/useSessionKeepAlive.ts
@@ -28,8 +28,11 @@ export function useSessionKeepAlive({
if (inFlight || !active) return;
inFlight = true;
try {
- const ok = await Members();
- if (active && ok === false) {
+ const response = await Members();
+ const isAuthFailure = response.status === 401 || response.status === 403;
+ const isRoleMismatch = response.ok && (!response.data || response.data.role !== 'ADMIN');
+ const unauthorized = isAuthFailure || isRoleMismatch;
+ if (active && unauthorized) {
onUnauthorized?.();
}
} catch {
diff --git a/src/index.css b/src/index.css
index 6c9ec0c..672348d 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,205 +1,85 @@
-@import 'tailwindcss';
-
-@layer base {
- @font-face {
- font-family: "SUIT Variable";
- src: url("./SUIT-Variable.ttf");
- font-weight: 100 900;
- font-display: swap;
- }
-
- :root {
- --background: #F2F3F8;
- --white: #FFFFFF;
- --black: #000000;
-
- /* gray 컬러 팔레트 */
- --gray-90: #53575F;
- --gray-60: #767676;
- --gray-50: #7A7A7A;
- --gray-30: #D9D9D9;
- --gray-20: #E2E2E2;
-
- }
-
- body {
- background: var(--background);
- font-family: "SUIT Variable";
- }
+@import "tailwindcss";
+@plugin "tailwindcss-animate";
+@custom-variant dark (&:is(.dark *));
+
+@font-face {
+ font-family: "SUIT Variable";
+ src: url("/SUIT-Variable.ttf");
+ font-weight: 100 900;
+ font-display: swap;
}
-@layer utilities {
- /* Font Size Classes */
- .font-size-32px {
- font-size: 32px;
- }
-
- .font-size-24px {
- font-size: 24px;
- }
-
- .font-size-20px {
- font-size: 20px;
- }
-
- .font-size-18px {
- font-size: 18px;
- }
-
- .font-size-16px {
- font-size: 16px;
- }
-
- .font-size-12px {
- font-size: 12px;
- }
-
- /* Font Weight Classes */
- .font-weight-800 {
- font-weight: 800;
- }
-
- .font-weight-700 {
- font-weight: 700;
- }
-
- .font-weight-600 {
- font-weight: 600;
- }
-
- .font-weight-500 {
- font-weight: 500;
- }
-
- /* Color Classes */
- .color-black {
- color: var(--black);
- }
-
- .color-white {
- color: var(--white);
- }
-
- .color-gray-90 {
- color: var(--gray-90);
- }
-
- .color-gray-60 {
- color: var(--gray-60);
- }
-
- .color-gray-50 {
- color: var(--gray-50);
- }
-
- .color-gray-30 {
- color: var(--gray-30);
- }
-
- .color-gray-20 {
- color: var(--gray-20);
- }
-
- /* Background Color Classes */
- .bg-black {
- background-color: var(--black);
- }
-
- .bg-white {
- background-color: var(--white);
- }
-
- .bg-gray-90 {
- background-color: var(--gray-90);
- }
-
- .bg-gray-60 {
- background-color: var(--gray-60);
- }
-
- .bg-gray-50 {
- background-color: var(--gray-50);
- }
-
- .bg-gray-30 {
- background-color: var(--gray-30);
- }
-
- .bg-gray-20 {
- background-color: var(--gray-20);
- }
-
- /* Hover Background Color Classes */
- .hover\\:bg-gray-90:hover {
- background-color: var(--gray-90);
- }
-
- .hover\\:bg-gray-60:hover {
- background-color: var(--gray-60);
- }
-
- .hover\\:bg-gray-50:hover {
- background-color: var(--gray-50);
- }
-
- .hover\\:bg-gray-30:hover {
- background-color: var(--gray-30);
- }
-
- .hover\\:bg-gray-20:hover {
- background-color: var(--gray-20);
- }
-
- /* Border Color Classes */
- .border-gray-30 {
- border-color: var(--gray-30);
- }
-
- .border-gray-50 {
- border-color: var(--gray-50);
- }
-
- .border-gray-90 {
- border-color: var(--gray-90);
- }
-
- /* util Style */
- .login-input {
- font-size: 16px;
- font-weight: 500;
- color: black;
- padding-left: 20px;
- }
-
- .login-input-placeholder::placeholder {
- font-size: 16px;
- font-weight: 500;
- color: var(--gray-30);
- }
-
- .search-input {
- font-size: 20px;
- font-weight: 600;
- color: black;
- padding-left: 50px;
- }
-
- .search-input-placeholder::placeholder {
- font-size: 20px;
- font-weight: 600;
- color: var(--gray-30);
- }
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
- .table-header {
- font-size: 20px;
- font-weight: 600;
- color: black;
- }
+:root {
+ --radius: 0.75rem;
+ --background: oklch(0.97 0.004 264.54);
+ --foreground: oklch(0.21 0.006 285.885);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.21 0.006 285.885);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.21 0.006 285.885);
+ --primary: oklch(0.28 0.015 286);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.95 0.004 286.32);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.95 0.004 286.32);
+ --muted-foreground: oklch(0.5 0.015 286.067);
+ --accent: oklch(0.93 0.01 286.3);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.88 0.004 286.32);
+ --input: oklch(0.88 0.004 286.32);
+ --ring: oklch(0.64 0.016 286);
+ --sidebar: oklch(1 0 0);
+ --sidebar-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-primary: oklch(0.28 0.015 286);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.95 0.004 286.32);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.88 0.004 286.32);
+ --sidebar-ring: oklch(0.64 0.016 286);
+}
- .table-data {
- font-size: 18px;
- font-weight: 500;
- color: black;
- }
+* {
+ @apply border-border outline-ring/50;
}
+body {
+ @apply bg-background text-foreground antialiased;
+ font-family: "SUIT Variable", sans-serif;
+}
diff --git a/src/lib/errors/admin-error.ts b/src/lib/errors/admin-error.ts
new file mode 100644
index 0000000..31ee2b3
--- /dev/null
+++ b/src/lib/errors/admin-error.ts
@@ -0,0 +1,30 @@
+interface ResolveAdminErrorMessageOptions {
+ overrides?: Record
+ fallback?: string
+}
+
+const DEFAULT_ADMIN_ERROR_MESSAGES: Record = {
+ BAD_REQUEST: "요청 값이 올바르지 않습니다.",
+ INVALID_INPUT_VALUE: "입력값이 올바르지 않습니다.",
+ INVALID_ENUM: "요청 값이 올바르지 않습니다.",
+ MEMBER_NOT_FOUND: "회원을 찾을 수 없습니다.",
+ UNAUTHORIZED: "인증이 필요합니다.",
+ FORBIDDEN: "권한이 없습니다.",
+}
+
+export function resolveAdminErrorMessage(
+ errorName?: string,
+ options?: ResolveAdminErrorMessageOptions,
+): string {
+ const fallback = options?.fallback ?? "요청 처리에 실패했습니다."
+
+ if (!errorName) {
+ return fallback
+ }
+
+ if (options?.overrides?.[errorName]) {
+ return options.overrides[errorName]
+ }
+
+ return DEFAULT_ADMIN_ERROR_MESSAGES[errorName] ?? fallback
+}
diff --git a/src/lib/http/client.ts b/src/lib/http/client.ts
new file mode 100644
index 0000000..ee12598
--- /dev/null
+++ b/src/lib/http/client.ts
@@ -0,0 +1,60 @@
+import type { ApiResult, RequestApiOptions } from "@/lib/http/types"
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
+
+function resolveJsonHeaders(headers?: HeadersInit): Headers {
+ const merged = new Headers(headers)
+
+ if (!merged.has("accept")) {
+ merged.set("accept", "application/json")
+ }
+
+ if (!merged.has("Content-Type")) {
+ merged.set("Content-Type", "application/json")
+ }
+
+ return merged
+}
+
+export async function requestApi(
+ path: string,
+ init?: RequestInit,
+ options?: RequestApiOptions,
+): Promise> {
+ const logContext = options?.logContext ?? "Admin API Error"
+
+ if (!API_BASE_URL) {
+ console.error(`[${logContext}] Missing VITE_API_BASE_URL`)
+ return { ok: false, status: 0 }
+ }
+
+ try {
+ const response = await fetch(`${API_BASE_URL}${path}`, {
+ ...init,
+ credentials: "include",
+ headers: resolveJsonHeaders(init?.headers),
+ })
+
+ const contentType = response.headers.get("content-type") || ""
+ const canParseJson = contentType.includes("application/json")
+
+ if (response.ok) {
+ if (response.status === 204 || !canParseJson) {
+ return { ok: true, status: response.status }
+ }
+
+ const data = (await response.json()) as T
+ return { ok: true, status: response.status, data }
+ }
+
+ if (!canParseJson) {
+ return { ok: false, status: response.status }
+ }
+
+ const error = (await response.json()) as { name?: string }
+ return { ok: false, status: response.status, errorName: error.name }
+ } catch (error) {
+ console.error(`[${logContext}]`, error)
+ return { ok: false, status: 0 }
+ }
+}
diff --git a/src/lib/http/types.ts b/src/lib/http/types.ts
new file mode 100644
index 0000000..5549ab4
--- /dev/null
+++ b/src/lib/http/types.ts
@@ -0,0 +1,10 @@
+export interface ApiResult {
+ ok: boolean
+ status: number
+ data?: T
+ errorName?: string
+}
+
+export interface RequestApiOptions {
+ logContext?: string
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/page/coupon.tsx b/src/page/coupon.tsx
index 6864e34..c83ed58 100644
--- a/src/page/coupon.tsx
+++ b/src/page/coupon.tsx
@@ -1,814 +1,91 @@
-import { useEffect, useMemo, useState } from 'react';
-
-import Header from '../components/Header';
-import Sidebar from '../components/SideBar';
-import { useAuthGuard } from '../hooks/useAuthGuard';
-import { showError, showSuccess, showWarning } from '../utils/alert';
-
-import { getCoupons } from '../api/coupon/get-coupons';
-import { createCoupon } from '../api/coupon/post-coupon';
-import { updateCouponName } from '../api/coupon/patch-coupon-name';
-import { deleteCoupon } from '../api/coupon/delete-coupon';
-import { getCouponCodes } from '../api/coupon/get-coupon-codes';
-import { createCouponCode } from '../api/coupon/post-coupon-code';
-import { deleteCouponCode } from '../api/coupon/delete-coupon-code';
-import { getIssuedCoupons } from '../api/coupon/get-issued-coupons';
-import { createIssuedCoupons } from '../api/coupon/post-issued-coupons';
-import { deleteIssuedCoupon } from '../api/coupon/delete-issued-coupon';
-import { getAdminMembers } from '../api/coupon/get-admin-members';
-
-import type {
- AdminCoupon,
- AdminCouponCode,
- AdminIssuedCoupon,
- AdminMemberSummary,
-} from '../api/coupon/types';
-
-type CouponTab = 'coupon' | 'code' | 'issued';
-
-function mapErrorMessage(errorName?: string): string {
- switch (errorName) {
- case 'COUPON_ALREADY_EXISTS':
- return '동일한 이름과 할인 금액을 가진 쿠폰이 이미 존재합니다.';
- case 'COUPON_NOT_FOUND':
- return '쿠폰을 찾을 수 없습니다.';
- case 'COUPON_ISSUED_COUPON_EXISTS':
- return '발급된 쿠폰이 남아 있어 쿠폰을 삭제할 수 없습니다.';
- case 'COUPON_CODE_ALREADY_USED_CANNOT_DELETE':
- return '이미 사용된 쿠폰 코드는 삭제할 수 없습니다.';
- case 'ISSUED_COUPON_ALREADY_USED':
- return '이미 사용된 쿠폰 발급 내역은 삭제할 수 없습니다.';
- case 'COUPON_CODE_CANNOT_ISSUE_CODE':
- return '쿠폰 코드 생성에 실패했습니다. 잠시 후 다시 시도해주세요.';
- default:
- return '요청 처리에 실패했습니다.';
- }
-}
-
-function formatDateTime(value: string | null): string {
- if (!value) {
- return '-';
- }
-
- const parsed = new Date(value);
- if (Number.isNaN(parsed.getTime())) {
- return '-';
- }
-
- return parsed.toLocaleString('ko-KR', {
- hour12: false,
- });
-}
+import { Card, CardContent } from "@/components/ui/card"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { formatDateTime, useCouponPageState } from "@/page/coupon/hooks/useCouponPageState"
+import { CouponCodeTabSection } from "@/page/coupon/sections/CouponCodeTabSection"
+import { CouponPageHeader } from "@/page/coupon/sections/CouponPageHeader"
+import { CouponIssuedTabSection } from "@/page/coupon/sections/CouponIssuedTabSection"
+import { CouponTabSection } from "@/page/coupon/sections/CouponTabSection"
const CouponPage: React.FC = () => {
- const { isLoading, isAuthenticated } = useAuthGuard();
-
- const [tab, setTab] = useState('coupon');
- const [isDataLoading, setIsDataLoading] = useState(false);
-
- const [coupons, setCoupons] = useState([]);
- const [couponCodes, setCouponCodes] = useState([]);
- const [issuedCoupons, setIssuedCoupons] = useState([]);
- const [members, setMembers] = useState([]);
-
- const [searchText, setSearchText] = useState('');
- const [memberSearchText, setMemberSearchText] = useState('');
-
- const [newCouponName, setNewCouponName] = useState('');
- const [newDiscountAmount, setNewDiscountAmount] = useState('5000');
-
- const [couponNameDrafts, setCouponNameDrafts] = useState>({});
-
- const [selectedCouponIdForCode, setSelectedCouponIdForCode] = useState(0);
- const [newCouponCodeDescription, setNewCouponCodeDescription] = useState('');
- const [selectedCouponIdForIssue, setSelectedCouponIdForIssue] = useState(0);
- const [selectedMemberIdsForIssue, setSelectedMemberIdsForIssue] = useState([]);
-
- const loadAll = async (): Promise => {
- setIsDataLoading(true);
- const [couponsRes, codesRes, issuedRes, membersRes] = await Promise.all([
- getCoupons(),
- getCouponCodes(),
- getIssuedCoupons(),
- getAdminMembers(),
- ]);
-
- if (couponsRes.ok && couponsRes.data) {
- const orderedCoupons = [...couponsRes.data].sort((a, b) => a.couponId - b.couponId);
- setCoupons(orderedCoupons);
- setCouponNameDrafts((prevDrafts) => {
- const nextDrafts: Record = {};
- orderedCoupons.forEach((coupon) => {
- nextDrafts[coupon.couponId] = prevDrafts[coupon.couponId] ?? coupon.couponName;
- });
- return nextDrafts;
- });
- } else {
- showError(mapErrorMessage(couponsRes.errorName));
- }
-
- if (codesRes.ok && codesRes.data) {
- setCouponCodes([...codesRes.data].sort((a, b) => a.codeCouponId - b.codeCouponId));
- } else {
- showError(mapErrorMessage(codesRes.errorName));
- }
-
- if (issuedRes.ok && issuedRes.data) {
- setIssuedCoupons([...issuedRes.data].sort((a, b) => a.issuedCouponId - b.issuedCouponId));
- } else {
- showError(mapErrorMessage(issuedRes.errorName));
- }
-
- if (membersRes.ok && membersRes.data) {
- setMembers([...membersRes.data].sort((a, b) => a.memberId - b.memberId));
- } else {
- showError('회원 목록을 가져오지 못했습니다.');
- }
-
- setIsDataLoading(false);
- };
-
- useEffect(() => {
- if (!isAuthenticated) {
- return;
- }
- void loadAll();
- }, [isAuthenticated]);
-
- useEffect(() => {
- if (coupons.length === 0) {
- setSelectedCouponIdForCode(0);
- setSelectedCouponIdForIssue(0);
- return;
- }
-
- if (!coupons.some((coupon) => coupon.couponId === selectedCouponIdForCode)) {
- setSelectedCouponIdForCode(coupons[0].couponId);
- }
-
- if (!coupons.some((coupon) => coupon.couponId === selectedCouponIdForIssue)) {
- setSelectedCouponIdForIssue(coupons[0].couponId);
- }
- }, [coupons, selectedCouponIdForCode, selectedCouponIdForIssue]);
-
- const keyword = searchText.trim().toLowerCase();
-
- const filteredCoupons = useMemo(() => {
- if (!keyword) {
- return coupons;
- }
-
- return coupons.filter((coupon) => {
- return (
- coupon.couponName.toLowerCase().includes(keyword)
- || String(coupon.couponId).includes(keyword)
- || String(coupon.discountAmount).includes(keyword)
- );
- });
- }, [coupons, keyword]);
-
- const filteredCouponCodes = useMemo(() => {
- if (!keyword) {
- return couponCodes;
- }
-
- return couponCodes.filter((couponCode) => {
- return (
- couponCode.couponName.toLowerCase().includes(keyword)
- || couponCode.code.toLowerCase().includes(keyword)
- || (couponCode.description ?? '').toLowerCase().includes(keyword)
- || String(couponCode.codeCouponId).includes(keyword)
- || String(couponCode.couponId).includes(keyword)
- );
- });
- }, [couponCodes, keyword]);
-
- const studentIdByMemberId = useMemo(() => {
- return new Map(
- members.map((member) => [member.memberId, member.studentId?.toLowerCase() ?? '']),
- );
- }, [members]);
-
- const filteredIssuedCoupons = useMemo(() => {
- if (!keyword) {
- return issuedCoupons;
- }
-
- return issuedCoupons.filter((issuedCoupon) => {
- const studentId = studentIdByMemberId.get(issuedCoupon.memberId) ?? '';
- return (
- issuedCoupon.couponName.toLowerCase().includes(keyword)
- || issuedCoupon.memberName.toLowerCase().includes(keyword)
- || issuedCoupon.memberEmail.toLowerCase().includes(keyword)
- || studentId.includes(keyword)
- || String(issuedCoupon.issuedCouponId).includes(keyword)
- || String(issuedCoupon.memberId).includes(keyword)
- );
- });
- }, [issuedCoupons, keyword, studentIdByMemberId]);
-
- const memberKeyword = memberSearchText.trim().toLowerCase();
-
- const filteredMembersForIssue = useMemo(() => {
- if (!memberKeyword) {
- return members;
- }
-
- return members.filter((member) => {
- return (
- member.name.toLowerCase().includes(memberKeyword)
- || member.email.toLowerCase().includes(memberKeyword)
- || (member.studentId ?? '').toLowerCase().includes(memberKeyword)
- || member.role.toLowerCase().includes(memberKeyword)
- || String(member.memberId).includes(memberKeyword)
- );
- });
- }, [members, memberKeyword]);
-
- const selectedMemberIdSet = useMemo(() => {
- return new Set(selectedMemberIdsForIssue);
- }, [selectedMemberIdsForIssue]);
-
- const filteredMemberIdSet = useMemo(() => {
- return new Set(filteredMembersForIssue.map((member) => member.memberId));
- }, [filteredMembersForIssue]);
-
- const isAllFilteredMembersSelected = filteredMembersForIssue.length > 0
- && filteredMembersForIssue.every((member) => selectedMemberIdSet.has(member.memberId));
-
- const selectedAmongFilteredCount = useMemo(() => {
- return selectedMemberIdsForIssue.filter((memberId) => filteredMemberIdSet.has(memberId)).length;
- }, [filteredMemberIdSet, selectedMemberIdsForIssue]);
-
- const handleCreateCoupon = async (): Promise => {
- if (!newCouponName.trim()) {
- showError('쿠폰 이름을 입력해주세요.');
- return;
- }
-
- const parsedDiscountAmount = Number(newDiscountAmount);
- if (!Number.isFinite(parsedDiscountAmount) || parsedDiscountAmount <= 0) {
- showError('할인 금액은 0보다 커야 합니다.');
- return;
- }
-
- const response = await createCoupon(newCouponName, parsedDiscountAmount);
- if (!response.ok) {
- showError(mapErrorMessage(response.errorName));
- return;
- }
-
- showSuccess('쿠폰을 생성했습니다.');
- setNewCouponName('');
- await loadAll();
- };
-
- const handleUpdateCouponName = async (couponId: number): Promise => {
- const draftName = couponNameDrafts[couponId] ?? '';
- if (!draftName.trim()) {
- showError('쿠폰 이름을 입력해주세요.');
- return;
- }
-
- const response = await updateCouponName(couponId, draftName);
- if (!response.ok) {
- showError(mapErrorMessage(response.errorName));
- return;
- }
-
- showSuccess('쿠폰 이름을 수정했습니다.');
- await loadAll();
- };
-
- const handleDeleteCoupon = async (couponId: number): Promise => {
- const confirmed = await showWarning('쿠폰을 삭제하시겠습니까?');
- if (!confirmed) {
- return;
- }
-
- const response = await deleteCoupon(couponId);
- if (!response.ok) {
- showError(mapErrorMessage(response.errorName));
- return;
- }
-
- showSuccess('쿠폰을 삭제했습니다.');
- await loadAll();
- };
-
- const handleCreateCouponCode = async (): Promise => {
- if (!selectedCouponIdForCode) {
- showError('코드를 생성할 쿠폰을 선택해주세요.');
- return;
- }
-
- const normalizedDescription = newCouponCodeDescription.trim() || null;
- const response = await createCouponCode(selectedCouponIdForCode, normalizedDescription);
- if (!response.ok) {
- showError(mapErrorMessage(response.errorName));
- return;
- }
-
- showSuccess('쿠폰 코드를 생성했습니다.');
- setNewCouponCodeDescription('');
- await loadAll();
- };
-
- const handleDeleteCouponCode = async (codeCouponId: number): Promise => {
- const confirmed = await showWarning('쿠폰 코드를 삭제하시겠습니까?');
- if (!confirmed) {
- return;
- }
-
- const response = await deleteCouponCode(codeCouponId);
- if (!response.ok) {
- showError(mapErrorMessage(response.errorName));
- return;
- }
-
- showSuccess('쿠폰 코드를 삭제했습니다.');
- await loadAll();
- };
-
- const handleCreateIssuedCoupons = async (): Promise => {
- if (!selectedCouponIdForIssue) {
- showError('발급할 쿠폰을 선택해주세요.');
- return;
- }
-
- if (selectedMemberIdsForIssue.length === 0) {
- showError('발급할 회원을 선택해주세요.');
- return;
- }
-
- const response = await createIssuedCoupons(selectedCouponIdForIssue, selectedMemberIdsForIssue);
- if (!response.ok) {
- showError(mapErrorMessage(response.errorName));
- return;
- }
-
- showSuccess(`${response.data?.length ?? 0}건의 쿠폰 발급을 완료했습니다.`);
- setSelectedMemberIdsForIssue([]);
- await loadAll();
- };
-
- const handleDeleteIssuedCoupon = async (issuedCouponId: number): Promise => {
- const confirmed = await showWarning('발급된 쿠폰을 삭제하시겠습니까?');
- if (!confirmed) {
- return;
- }
-
- const response = await deleteIssuedCoupon(issuedCouponId);
- if (!response.ok) {
- showError(mapErrorMessage(response.errorName));
- return;
- }
-
- showSuccess('발급된 쿠폰을 삭제했습니다.');
- await loadAll();
- };
-
- const handleToggleMemberForIssue = (memberId: number): void => {
- setSelectedMemberIdsForIssue((prevIds) => {
- if (prevIds.includes(memberId)) {
- return prevIds.filter((id) => id !== memberId);
- }
-
- return [...prevIds, memberId];
- });
- };
-
- const handleSelectAllFilteredMembers = (): void => {
- setSelectedMemberIdsForIssue((prevIds) => {
- const nextIds = new Set(prevIds);
- filteredMembersForIssue.forEach((member) => {
- nextIds.add(member.memberId);
- });
-
- return Array.from(nextIds);
- });
- };
-
- const handleClearAllSelectedMembers = (): void => {
- setSelectedMemberIdsForIssue([]);
- };
-
- const handleClearFilteredMembers = (): void => {
- setSelectedMemberIdsForIssue((prevIds) => {
- return prevIds.filter((memberId) => !filteredMemberIdSet.has(memberId));
- });
- };
-
- const renderActionPanel = (): React.ReactNode => {
- if (tab === 'coupon') {
- return (
-
-
-
- setNewCouponName(event.target.value)}
- placeholder="예) 2026 신입생 환영 쿠폰"
- className="h-10 px-3 border border-gray-30 rounded-[10px]"
- />
-
-
-
- setNewDiscountAmount(event.target.value)}
- className="h-10 px-3 border border-gray-30 rounded-[10px]"
- />
-
-
-
- );
- }
-
- if (tab === 'code') {
- return (
-
-
-
-
-
-
-
- setNewCouponCodeDescription(event.target.value)}
- placeholder="예) 26학번 단톡방 공지용 / 운영진 DM 전달"
- maxLength={255}
- className="h-10 px-3 border border-gray-30 rounded-[10px]"
- />
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- setMemberSearchText(event.target.value)}
- placeholder="회원 ID, 이름, 학번, 이메일, 권한 검색"
- className="h-10 px-3 border border-gray-30 rounded-[10px]"
- />
-
-
-
-
-
-
- 총 {filteredMembersForIssue.length}명 중 {selectedAmongFilteredCount}명 선택
-
- (전체 선택 {selectedMemberIdsForIssue.length}명)
-
-
-
-
-
- {filteredMembersForIssue.length === 0 ? (
-
- 검색 조건에 맞는 회원이 없습니다.
-
- ) : (
-
- {filteredMembersForIssue.map((member) => (
-
- ))}
-
- )}
-
-
-
- );
- };
-
- const renderTable = (): React.ReactNode => {
- if (tab === 'coupon') {
- return (
-
-
- ID
- 쿠폰 이름
- 할인 금액
- 이름 수정
- 삭제
-
-
- {filteredCoupons.map((coupon) => (
-
- {coupon.couponId}
- {coupon.couponName}
- {Number(coupon.discountAmount).toLocaleString('ko-KR')}원
-
- {
- setCouponNameDrafts((prev) => ({
- ...prev,
- [coupon.couponId]: event.target.value,
- }));
- }}
- className="h-9 px-2 border border-gray-30 rounded-[10px] w-[160px]"
- />
-
-
-
-
-
-
- ))}
-
-
- );
- }
-
- if (tab === 'code') {
- return (
-
-
- 코드 ID
- 쿠폰 ID
- 코드
- 설명
- 상태
- 사용 시각
- 삭제
-
-
- {filteredCouponCodes.map((couponCode) => (
-
- {couponCode.codeCouponId}
- {couponCode.couponId}
- {couponCode.code}
-
- {couponCode.description ?? '-'}
-
-
- {couponCode.isValid ? '사용 가능' : `사용 완료 (${couponCode.couponName})`}
-
- {formatDateTime(couponCode.usedAt)}
-
-
-
-
- ))}
-
-
- );
- }
-
- return (
-
-
- 발급 ID
- 쿠폰
- 회원
- 상태
- 결제 ID
- 사용 시각
- 삭제
-
-
- {filteredIssuedCoupons.map((issuedCoupon) => (
-
- {issuedCoupon.issuedCouponId}
-
- [{issuedCoupon.couponId}] {issuedCoupon.couponName} ({Number(issuedCoupon.discountAmount).toLocaleString('ko-KR')}원)
-
-
-
- [{issuedCoupon.memberId}] {issuedCoupon.memberName}
-
- {issuedCoupon.memberEmail}
-
- {issuedCoupon.isValid ? '미사용' : '사용됨'}
- {issuedCoupon.paymentId ?? '-'}
- {formatDateTime(issuedCoupon.usedAt)}
-
-
-
-
- ))}
-
-
- );
- };
-
- const renderEmpty = (rows: unknown[]): React.ReactNode => {
- if (rows.length > 0) {
- return null;
- }
-
- return (
-
- 조건에 맞는 데이터가 없습니다.
-
- );
- };
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (!isAuthenticated) {
- return null;
- }
-
- const currentRows = tab === 'coupon'
- ? filteredCoupons
- : tab === 'code'
- ? filteredCouponCodes
- : filteredIssuedCoupons;
+ const state = useCouponPageState()
return (
- <>
-
-
-
-
-
-
- 쿠폰 관리
- 쿠폰, 쿠폰 코드, 발급된 쿠폰을 한 화면에서 관리합니다.
-
- setSearchText(event.target.value)}
- placeholder="ID, 쿠폰명, 회원명, 학번, 이메일, 코드, 설명 검색"
- className="h-10 w-[360px] px-3 border border-gray-30 rounded-[10px] bg-white"
- />
-
-
-
-
-
-
-
-
- {renderActionPanel()}
-
- {isDataLoading ? (
-
- 데이터를 불러오는 중입니다.
-
- ) : (
- <>
- {renderTable()}
- {renderEmpty(currentRows)}
- >
- )}
-
-
- >
- );
-};
+
+
+
+ state.setTab(value as typeof state.tab)}>
+
+ 쿠폰
+ 쿠폰 코드
+ 발급된 쿠폰
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {state.isDataLoading && (
+
+ 데이터를 불러오는 중입니다.
+
+ )}
+
+ {!state.isDataLoading && state.currentRows.length === 0 && (
+
+ 조건에 맞는 데이터가 없습니다.
+
+ )}
+
+ )
+}
-export default CouponPage;
+export default CouponPage
diff --git a/src/page/coupon/hooks/useCouponPageState.ts b/src/page/coupon/hooks/useCouponPageState.ts
new file mode 100644
index 0000000..f336623
--- /dev/null
+++ b/src/page/coupon/hooks/useCouponPageState.ts
@@ -0,0 +1,480 @@
+import { useEffect, useMemo, useState } from "react"
+
+import { createCoupon } from "@/api/coupon/post-coupon"
+import { createCouponCode } from "@/api/coupon/post-coupon-code"
+import { createIssuedCoupons } from "@/api/coupon/post-issued-coupons"
+import { deleteCoupon } from "@/api/coupon/delete-coupon"
+import { deleteCouponCode } from "@/api/coupon/delete-coupon-code"
+import { deleteIssuedCoupon } from "@/api/coupon/delete-issued-coupon"
+import { getAdminMembers } from "@/api/coupon/get-admin-members"
+import { getCouponCodes } from "@/api/coupon/get-coupon-codes"
+import { getCoupons } from "@/api/coupon/get-coupons"
+import { getIssuedCoupons } from "@/api/coupon/get-issued-coupons"
+import { updateCouponName } from "@/api/coupon/patch-coupon-name"
+import type {
+ AdminCoupon,
+ AdminCouponCode,
+ AdminIssuedCoupon,
+ AdminMemberSummary,
+} from "@/api/coupon/types"
+import { resolveAdminErrorMessage } from "@/lib/errors/admin-error"
+import { showConfirm, showError, showSuccess } from "@/utils/alert"
+
+export type CouponTab = "coupon" | "code" | "issued"
+
+const couponErrorOverrides: Record = {
+ COUPON_ALREADY_EXISTS: "동일한 이름과 할인 금액을 가진 쿠폰이 이미 존재합니다.",
+ COUPON_NOT_FOUND: "쿠폰을 찾을 수 없습니다.",
+ COUPON_ISSUED_COUPON_EXISTS: "발급된 쿠폰이 남아 있어 쿠폰을 삭제할 수 없습니다.",
+ COUPON_CODE_ALREADY_USED_CANNOT_DELETE: "이미 사용된 쿠폰 코드는 삭제할 수 없습니다.",
+ ISSUED_COUPON_ALREADY_USED: "이미 사용된 쿠폰 발급 내역은 삭제할 수 없습니다.",
+ COUPON_CODE_CANNOT_ISSUE_CODE: "쿠폰 코드 생성에 실패했습니다. 잠시 후 다시 시도해주세요.",
+}
+
+const resolveCouponErrorMessage = (errorName?: string): string => {
+ return resolveAdminErrorMessage(errorName, { overrides: couponErrorOverrides })
+}
+
+export function formatDateTime(value: string | null): string {
+ if (!value) {
+ return "-"
+ }
+
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) {
+ return "-"
+ }
+
+ return parsed.toLocaleString("ko-KR", {
+ hour12: false,
+ })
+}
+
+export interface CouponPageState {
+ tab: CouponTab
+ setTab: (nextTab: CouponTab) => void
+ isDataLoading: boolean
+ searchText: string
+ setSearchText: (value: string) => void
+ newCouponName: string
+ setNewCouponName: (value: string) => void
+ newDiscountAmount: string
+ setNewDiscountAmount: (value: string) => void
+ couponNameDrafts: Record
+ handleCouponNameDraftChange: (couponId: number, value: string) => void
+ selectedCouponIdForCode: number
+ setSelectedCouponIdForCode: (couponId: number) => void
+ newCouponCodeDescription: string
+ setNewCouponCodeDescription: (description: string) => void
+ selectedCouponIdForIssue: number
+ setSelectedCouponIdForIssue: (couponId: number) => void
+ memberSearchText: string
+ setMemberSearchText: (value: string) => void
+ selectedMemberIdsForIssue: number[]
+ coupons: AdminCoupon[]
+ filteredCoupons: AdminCoupon[]
+ filteredCouponCodes: AdminCouponCode[]
+ filteredIssuedCoupons: AdminIssuedCoupon[]
+ filteredMembersForIssue: AdminMemberSummary[]
+ selectedMemberIdSet: Set
+ isAllFilteredMembersSelected: boolean
+ selectedAmongFilteredCount: number
+ currentRows: AdminCoupon[] | AdminCouponCode[] | AdminIssuedCoupon[]
+ handleCreateCoupon: () => Promise
+ handleUpdateCouponName: (couponId: number) => Promise
+ handleDeleteCoupon: (couponId: number) => Promise
+ handleCreateCouponCode: () => Promise
+ handleDeleteCouponCode: (codeCouponId: number) => Promise
+ handleCreateIssuedCoupons: () => Promise
+ handleDeleteIssuedCoupon: (issuedCouponId: number) => Promise
+ handleToggleMemberForIssue: (memberId: number) => void
+ handleSelectAllFilteredMembers: () => void
+ handleClearAllSelectedMembers: () => void
+ handleClearFilteredMembers: () => void
+}
+
+export const useCouponPageState = (): CouponPageState => {
+ const [tab, setTab] = useState("coupon")
+ const [isDataLoading, setIsDataLoading] = useState(false)
+
+ const [coupons, setCoupons] = useState([])
+ const [couponCodes, setCouponCodes] = useState([])
+ const [issuedCoupons, setIssuedCoupons] = useState([])
+ const [members, setMembers] = useState([])
+
+ const [searchText, setSearchText] = useState("")
+ const [memberSearchText, setMemberSearchText] = useState("")
+
+ const [newCouponName, setNewCouponName] = useState("")
+ const [newDiscountAmount, setNewDiscountAmount] = useState("5000")
+
+ const [couponNameDrafts, setCouponNameDrafts] = useState>({})
+
+ const [selectedCouponIdForCode, setSelectedCouponIdForCode] = useState(0)
+ const [newCouponCodeDescription, setNewCouponCodeDescription] = useState("")
+ const [selectedCouponIdForIssue, setSelectedCouponIdForIssue] = useState(0)
+ const [selectedMemberIdsForIssue, setSelectedMemberIdsForIssue] = useState([])
+
+ const loadAll = async (): Promise => {
+ setIsDataLoading(true)
+ const [couponsRes, codesRes, issuedRes, membersRes] = await Promise.all([
+ getCoupons(),
+ getCouponCodes(),
+ getIssuedCoupons(),
+ getAdminMembers(),
+ ])
+
+ if (couponsRes.ok && couponsRes.data) {
+ const orderedCoupons = [...couponsRes.data].sort((a, b) => a.couponId - b.couponId)
+ setCoupons(orderedCoupons)
+ setCouponNameDrafts((prevDrafts) => {
+ const nextDrafts: Record = {}
+ orderedCoupons.forEach((coupon) => {
+ nextDrafts[coupon.couponId] = prevDrafts[coupon.couponId] ?? coupon.couponName
+ })
+ return nextDrafts
+ })
+ } else {
+ showError(resolveCouponErrorMessage(couponsRes.errorName))
+ }
+
+ if (codesRes.ok && codesRes.data) {
+ setCouponCodes([...codesRes.data].sort((a, b) => a.codeCouponId - b.codeCouponId))
+ } else {
+ showError(resolveCouponErrorMessage(codesRes.errorName))
+ }
+
+ if (issuedRes.ok && issuedRes.data) {
+ setIssuedCoupons([...issuedRes.data].sort((a, b) => a.issuedCouponId - b.issuedCouponId))
+ } else {
+ showError(resolveCouponErrorMessage(issuedRes.errorName))
+ }
+
+ if (membersRes.ok && membersRes.data) {
+ setMembers([...membersRes.data].sort((a, b) => a.memberId - b.memberId))
+ } else {
+ showError("회원 목록을 가져오지 못했습니다.")
+ }
+
+ setIsDataLoading(false)
+ }
+
+ useEffect(() => {
+ void loadAll()
+ }, [])
+
+ useEffect(() => {
+ if (coupons.length === 0) {
+ setSelectedCouponIdForCode(0)
+ setSelectedCouponIdForIssue(0)
+ return
+ }
+
+ if (!coupons.some((coupon) => coupon.couponId === selectedCouponIdForCode)) {
+ setSelectedCouponIdForCode(coupons[0].couponId)
+ }
+
+ if (!coupons.some((coupon) => coupon.couponId === selectedCouponIdForIssue)) {
+ setSelectedCouponIdForIssue(coupons[0].couponId)
+ }
+ }, [coupons, selectedCouponIdForCode, selectedCouponIdForIssue])
+
+ const keyword = searchText.trim().toLowerCase()
+
+ const filteredCoupons = useMemo(() => {
+ if (!keyword) {
+ return coupons
+ }
+
+ return coupons.filter((coupon) => {
+ return (
+ coupon.couponName.toLowerCase().includes(keyword) ||
+ String(coupon.couponId).includes(keyword) ||
+ String(coupon.discountAmount).includes(keyword)
+ )
+ })
+ }, [coupons, keyword])
+
+ const filteredCouponCodes = useMemo(() => {
+ if (!keyword) {
+ return couponCodes
+ }
+
+ return couponCodes.filter((couponCode) => {
+ return (
+ couponCode.couponName.toLowerCase().includes(keyword) ||
+ couponCode.code.toLowerCase().includes(keyword) ||
+ (couponCode.description ?? "").toLowerCase().includes(keyword) ||
+ String(couponCode.codeCouponId).includes(keyword) ||
+ String(couponCode.couponId).includes(keyword)
+ )
+ })
+ }, [couponCodes, keyword])
+
+ const studentIdByMemberId = useMemo(() => {
+ return new Map(members.map((member) => [member.memberId, member.studentId?.toLowerCase() ?? ""]))
+ }, [members])
+
+ const filteredIssuedCoupons = useMemo(() => {
+ if (!keyword) {
+ return issuedCoupons
+ }
+
+ return issuedCoupons.filter((issuedCoupon) => {
+ const studentId = studentIdByMemberId.get(issuedCoupon.memberId) ?? ""
+ return (
+ issuedCoupon.couponName.toLowerCase().includes(keyword) ||
+ issuedCoupon.memberName.toLowerCase().includes(keyword) ||
+ issuedCoupon.memberEmail.toLowerCase().includes(keyword) ||
+ studentId.includes(keyword) ||
+ String(issuedCoupon.issuedCouponId).includes(keyword) ||
+ String(issuedCoupon.memberId).includes(keyword)
+ )
+ })
+ }, [issuedCoupons, keyword, studentIdByMemberId])
+
+ const memberKeyword = memberSearchText.trim().toLowerCase()
+
+ const filteredMembersForIssue = useMemo(() => {
+ if (!memberKeyword) {
+ return members
+ }
+
+ return members.filter((member) => {
+ return (
+ member.name.toLowerCase().includes(memberKeyword) ||
+ member.email.toLowerCase().includes(memberKeyword) ||
+ (member.studentId ?? "").toLowerCase().includes(memberKeyword) ||
+ member.role.toLowerCase().includes(memberKeyword) ||
+ String(member.memberId).includes(memberKeyword)
+ )
+ })
+ }, [members, memberKeyword])
+
+ const selectedMemberIdSet = useMemo(() => new Set(selectedMemberIdsForIssue), [selectedMemberIdsForIssue])
+
+ const filteredMemberIdSet = useMemo(
+ () => new Set(filteredMembersForIssue.map((member) => member.memberId)),
+ [filteredMembersForIssue],
+ )
+
+ const isAllFilteredMembersSelected =
+ filteredMembersForIssue.length > 0 &&
+ filteredMembersForIssue.every((member) => selectedMemberIdSet.has(member.memberId))
+
+ const selectedAmongFilteredCount = useMemo(() => {
+ return selectedMemberIdsForIssue.filter((memberId) => filteredMemberIdSet.has(memberId)).length
+ }, [filteredMemberIdSet, selectedMemberIdsForIssue])
+
+ const handleCreateCoupon = async (): Promise => {
+ if (!newCouponName.trim()) {
+ showError("쿠폰 이름을 입력해주세요.")
+ return
+ }
+
+ const parsedDiscountAmount = Number(newDiscountAmount)
+ if (!Number.isFinite(parsedDiscountAmount) || parsedDiscountAmount <= 0) {
+ showError("할인 금액은 0보다 커야 합니다.")
+ return
+ }
+
+ const response = await createCoupon(newCouponName, parsedDiscountAmount)
+ if (!response.ok) {
+ showError(resolveCouponErrorMessage(response.errorName))
+ return
+ }
+
+ showSuccess("쿠폰을 생성했습니다.")
+ setNewCouponName("")
+ await loadAll()
+ }
+
+ const handleUpdateCouponName = async (couponId: number): Promise => {
+ const draftName = couponNameDrafts[couponId] ?? ""
+ if (!draftName.trim()) {
+ showError("쿠폰 이름을 입력해주세요.")
+ return
+ }
+
+ const response = await updateCouponName(couponId, draftName)
+ if (!response.ok) {
+ showError(resolveCouponErrorMessage(response.errorName))
+ return
+ }
+
+ showSuccess("쿠폰 이름을 수정했습니다.")
+ await loadAll()
+ }
+
+ const handleDeleteCoupon = async (couponId: number): Promise => {
+ const confirmed = await showConfirm("쿠폰을 삭제하시겠습니까?")
+ if (!confirmed) {
+ return
+ }
+
+ const response = await deleteCoupon(couponId)
+ if (!response.ok) {
+ showError(resolveCouponErrorMessage(response.errorName))
+ return
+ }
+
+ showSuccess("쿠폰을 삭제했습니다.")
+ await loadAll()
+ }
+
+ const handleCreateCouponCode = async (): Promise => {
+ if (!selectedCouponIdForCode) {
+ showError("코드를 생성할 쿠폰을 선택해주세요.")
+ return
+ }
+
+ const normalizedDescription = newCouponCodeDescription.trim() || null
+ const response = await createCouponCode(selectedCouponIdForCode, normalizedDescription)
+ if (!response.ok) {
+ showError(resolveCouponErrorMessage(response.errorName))
+ return
+ }
+
+ showSuccess("쿠폰 코드를 생성했습니다.")
+ setNewCouponCodeDescription("")
+ await loadAll()
+ }
+
+ const handleDeleteCouponCode = async (codeCouponId: number): Promise => {
+ const confirmed = await showConfirm("쿠폰 코드를 삭제하시겠습니까?")
+ if (!confirmed) {
+ return
+ }
+
+ const response = await deleteCouponCode(codeCouponId)
+ if (!response.ok) {
+ showError(resolveCouponErrorMessage(response.errorName))
+ return
+ }
+
+ showSuccess("쿠폰 코드를 삭제했습니다.")
+ await loadAll()
+ }
+
+ const handleCreateIssuedCoupons = async (): Promise => {
+ if (!selectedCouponIdForIssue) {
+ showError("발급할 쿠폰을 선택해주세요.")
+ return
+ }
+
+ if (selectedMemberIdsForIssue.length === 0) {
+ showError("발급할 회원을 선택해주세요.")
+ return
+ }
+
+ const response = await createIssuedCoupons(selectedCouponIdForIssue, selectedMemberIdsForIssue)
+ if (!response.ok) {
+ showError(resolveCouponErrorMessage(response.errorName))
+ return
+ }
+
+ showSuccess(`${response.data?.length ?? 0}건의 쿠폰 발급을 완료했습니다.`)
+ setSelectedMemberIdsForIssue([])
+ await loadAll()
+ }
+
+ const handleDeleteIssuedCoupon = async (issuedCouponId: number): Promise => {
+ const confirmed = await showConfirm("발급된 쿠폰을 삭제하시겠습니까?")
+ if (!confirmed) {
+ return
+ }
+
+ const response = await deleteIssuedCoupon(issuedCouponId)
+ if (!response.ok) {
+ showError(resolveCouponErrorMessage(response.errorName))
+ return
+ }
+
+ showSuccess("발급된 쿠폰을 삭제했습니다.")
+ await loadAll()
+ }
+
+ const handleToggleMemberForIssue = (memberId: number): void => {
+ setSelectedMemberIdsForIssue((prevIds) => {
+ if (prevIds.includes(memberId)) {
+ return prevIds.filter((id) => id !== memberId)
+ }
+
+ return [...prevIds, memberId]
+ })
+ }
+
+ const handleSelectAllFilteredMembers = (): void => {
+ setSelectedMemberIdsForIssue((prevIds) => {
+ const nextIds = new Set(prevIds)
+ filteredMembersForIssue.forEach((member) => {
+ nextIds.add(member.memberId)
+ })
+
+ return Array.from(nextIds)
+ })
+ }
+
+ const handleClearAllSelectedMembers = (): void => {
+ setSelectedMemberIdsForIssue([])
+ }
+
+ const handleClearFilteredMembers = (): void => {
+ setSelectedMemberIdsForIssue((prevIds) => {
+ return prevIds.filter((memberId) => !filteredMemberIdSet.has(memberId))
+ })
+ }
+
+ const currentRows =
+ tab === "coupon" ? filteredCoupons : tab === "code" ? filteredCouponCodes : filteredIssuedCoupons
+
+ const handleCouponNameDraftChange = (couponId: number, value: string): void => {
+ setCouponNameDrafts((prev) => ({
+ ...prev,
+ [couponId]: value,
+ }))
+ }
+
+ return {
+ tab,
+ setTab,
+ isDataLoading,
+ searchText,
+ setSearchText,
+ newCouponName,
+ setNewCouponName,
+ newDiscountAmount,
+ setNewDiscountAmount,
+ couponNameDrafts,
+ handleCouponNameDraftChange,
+ selectedCouponIdForCode,
+ setSelectedCouponIdForCode,
+ newCouponCodeDescription,
+ setNewCouponCodeDescription,
+ selectedCouponIdForIssue,
+ setSelectedCouponIdForIssue,
+ memberSearchText,
+ setMemberSearchText,
+ selectedMemberIdsForIssue,
+ coupons,
+ filteredCoupons,
+ filteredCouponCodes,
+ filteredIssuedCoupons,
+ filteredMembersForIssue,
+ selectedMemberIdSet,
+ isAllFilteredMembersSelected,
+ selectedAmongFilteredCount,
+ currentRows,
+ handleCreateCoupon,
+ handleUpdateCouponName,
+ handleDeleteCoupon,
+ handleCreateCouponCode,
+ handleDeleteCouponCode,
+ handleCreateIssuedCoupons,
+ handleDeleteIssuedCoupon,
+ handleToggleMemberForIssue,
+ handleSelectAllFilteredMembers,
+ handleClearAllSelectedMembers,
+ handleClearFilteredMembers,
+ }
+}
diff --git a/src/page/coupon/sections/CouponCodeTabSection.tsx b/src/page/coupon/sections/CouponCodeTabSection.tsx
new file mode 100644
index 0000000..c967f80
--- /dev/null
+++ b/src/page/coupon/sections/CouponCodeTabSection.tsx
@@ -0,0 +1,123 @@
+import type { AdminCoupon, AdminCouponCode } from "@/api/coupon/types"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+
+interface CouponCodeTabSectionProps {
+ coupons: AdminCoupon[]
+ selectedCouponIdForCode: number
+ onSelectedCouponIdForCodeChange: (couponId: number) => void
+ newCouponCodeDescription: string
+ onNewCouponCodeDescriptionChange: (description: string) => void
+ onCreateCouponCode: () => Promise
+ filteredCouponCodes: AdminCouponCode[]
+ onDeleteCouponCode: (codeCouponId: number) => Promise
+ formatDateTime: (value: string | null) => string
+}
+
+export const CouponCodeTabSection: React.FC = ({
+ coupons,
+ selectedCouponIdForCode,
+ onSelectedCouponIdForCodeChange,
+ newCouponCodeDescription,
+ onNewCouponCodeDescriptionChange,
+ onCreateCouponCode,
+ filteredCouponCodes,
+ onDeleteCouponCode,
+ formatDateTime,
+}) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ onNewCouponCodeDescriptionChange(event.target.value)}
+ placeholder="예) 26학번 단톡방 공지용"
+ maxLength={255}
+ />
+
+
+
+
+
+
+
+
+
+
+ 코드 ID
+ 쿠폰 ID
+ 코드
+ 설명
+ 상태
+ 사용 시각
+ 삭제
+
+
+
+ {filteredCouponCodes.map((couponCode) => (
+
+ {couponCode.codeCouponId}
+ {couponCode.couponId}
+ {couponCode.code}
+
+ {couponCode.description ?? "-"}
+
+ {couponCode.isValid ? "사용 가능" : `사용 완료 (${couponCode.couponName})`}
+ {formatDateTime(couponCode.usedAt)}
+
+
+
+
+ ))}
+
+
+
+
+ >
+ )
+}
diff --git a/src/page/coupon/sections/CouponIssuedTabSection.tsx b/src/page/coupon/sections/CouponIssuedTabSection.tsx
new file mode 100644
index 0000000..14c3f71
--- /dev/null
+++ b/src/page/coupon/sections/CouponIssuedTabSection.tsx
@@ -0,0 +1,209 @@
+import type { AdminCoupon, AdminIssuedCoupon, AdminMemberSummary } from "@/api/coupon/types"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+
+interface CouponIssuedTabSectionProps {
+ coupons: AdminCoupon[]
+ selectedCouponIdForIssue: number
+ onSelectedCouponIdForIssueChange: (couponId: number) => void
+ onCreateIssuedCoupons: () => Promise
+ memberSearchText: string
+ onMemberSearchTextChange: (value: string) => void
+ filteredMembersForIssue: AdminMemberSummary[]
+ selectedMemberIdSet: Set
+ isAllFilteredMembersSelected: boolean
+ selectedAmongFilteredCount: number
+ selectedMemberIdsForIssue: number[]
+ onSelectAllFilteredMembers: () => void
+ onClearFilteredMembers: () => void
+ onClearAllSelectedMembers: () => void
+ onToggleMemberForIssue: (memberId: number) => void
+ filteredIssuedCoupons: AdminIssuedCoupon[]
+ onDeleteIssuedCoupon: (issuedCouponId: number) => Promise
+ formatDateTime: (value: string | null) => string
+}
+
+export const CouponIssuedTabSection: React.FC = ({
+ coupons,
+ selectedCouponIdForIssue,
+ onSelectedCouponIdForIssueChange,
+ onCreateIssuedCoupons,
+ memberSearchText,
+ onMemberSearchTextChange,
+ filteredMembersForIssue,
+ selectedMemberIdSet,
+ isAllFilteredMembersSelected,
+ selectedAmongFilteredCount,
+ selectedMemberIdsForIssue,
+ onSelectAllFilteredMembers,
+ onClearFilteredMembers,
+ onClearAllSelectedMembers,
+ onToggleMemberForIssue,
+ filteredIssuedCoupons,
+ onDeleteIssuedCoupon,
+ formatDateTime,
+}) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ onMemberSearchTextChange(event.target.value)}
+ placeholder="회원 ID, 이름, 학번, 이메일, 권한 검색"
+ />
+
+
+
+
+
+
+ 총 {filteredMembersForIssue.length}명 중 {selectedAmongFilteredCount}명 선택
+
+ 전체 선택 {selectedMemberIdsForIssue.length}명
+
+
+
+ {filteredMembersForIssue.length === 0 ? (
+
+ 검색 조건에 맞는 회원이 없습니다.
+
+ ) : (
+
+ {filteredMembersForIssue.map((member) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ 발급 ID
+ 쿠폰
+ 회원
+ 상태
+ 결제 ID
+ 사용 시각
+ 삭제
+
+
+
+ {filteredIssuedCoupons.map((issuedCoupon) => (
+
+ {issuedCoupon.issuedCouponId}
+
+ [{issuedCoupon.couponId}] {issuedCoupon.couponName} ({Number(issuedCoupon.discountAmount).toLocaleString("ko-KR")}원)
+
+
+
+
+ [{issuedCoupon.memberId}] {issuedCoupon.memberName}
+
+ {issuedCoupon.memberEmail}
+
+
+ {issuedCoupon.isValid ? "미사용" : "사용됨"}
+ {issuedCoupon.paymentId ?? "-"}
+ {formatDateTime(issuedCoupon.usedAt)}
+
+
+
+
+ ))}
+
+
+
+
+ >
+ )
+}
diff --git a/src/page/coupon/sections/CouponPageHeader.tsx b/src/page/coupon/sections/CouponPageHeader.tsx
new file mode 100644
index 0000000..3701a38
--- /dev/null
+++ b/src/page/coupon/sections/CouponPageHeader.tsx
@@ -0,0 +1,29 @@
+import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+
+interface CouponPageHeaderProps {
+ searchText: string
+ onSearchTextChange: (value: string) => void
+}
+
+export const CouponPageHeader: React.FC = ({
+ searchText,
+ onSearchTextChange,
+}) => {
+ return (
+
+
+
+ 쿠폰 관리
+ 쿠폰, 쿠폰 코드, 발급된 쿠폰을 한 화면에서 관리합니다.
+
+ onSearchTextChange(event.target.value)}
+ placeholder="ID, 쿠폰명, 회원명, 학번, 이메일, 코드, 설명 검색"
+ />
+
+
+ )
+}
diff --git a/src/page/coupon/sections/CouponTabSection.tsx b/src/page/coupon/sections/CouponTabSection.tsx
new file mode 100644
index 0000000..cd085a6
--- /dev/null
+++ b/src/page/coupon/sections/CouponTabSection.tsx
@@ -0,0 +1,113 @@
+import type { AdminCoupon } from "@/api/coupon/types"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+
+interface CouponTabSectionProps {
+ newCouponName: string
+ onNewCouponNameChange: (value: string) => void
+ newDiscountAmount: string
+ onNewDiscountAmountChange: (value: string) => void
+ onCreateCoupon: () => Promise
+ filteredCoupons: AdminCoupon[]
+ couponNameDrafts: Record
+ onCouponNameDraftChange: (couponId: number, value: string) => void
+ onUpdateCouponName: (couponId: number) => Promise
+ onDeleteCoupon: (couponId: number) => Promise
+}
+
+export const CouponTabSection: React.FC = ({
+ newCouponName,
+ onNewCouponNameChange,
+ newDiscountAmount,
+ onNewDiscountAmountChange,
+ onCreateCoupon,
+ filteredCoupons,
+ couponNameDrafts,
+ onCouponNameDraftChange,
+ onUpdateCouponName,
+ onDeleteCoupon,
+}) => {
+ return (
+ <>
+
+
+
+
+ onNewCouponNameChange(event.target.value)}
+ placeholder="예) 2026 신입생 환영 쿠폰"
+ />
+
+
+
+ onNewDiscountAmountChange(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/page/event.tsx b/src/page/event.tsx
index d95ca31..b0b84c9 100644
--- a/src/page/event.tsx
+++ b/src/page/event.tsx
@@ -1,79 +1,43 @@
-import { useEffect, useState } from 'react';
-import EventTable from "../components/EventTable";
-import Header from "../components/Header";
-import Sidebar from '../components/SideBar';
-import SearchBar from "../components/Search";
-import { useAuthGuard } from '../hooks/useAuthGuard';
-import { GetActivities, type Activity } from '../api/activity/get-activities';
-
-const Event: React.FC = () => {
- const { isLoading, isAuthenticated } = useAuthGuard();
- const [activities, setActivities] = useState([]);
-
- useEffect(() => {
- // 활동 목록 전체 조회
- const fetchActivities = async () => {
- try {
- const data = await GetActivities();
- setActivities(data);
- } catch (error) {
- console.error('활동 목록 조회 중 오류 발생:', error);
- }
- };
-
- // 권한 확인
- if (isAuthenticated) {
- fetchActivities();
- }
- }, [isAuthenticated]);
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (!isAuthenticated) {
- return null; // 리디렉션 중이므로 아무것도 렌더링하지 않음
- }
-
- // 활동 데이터를 EventTable이 기대하는 형식으로 변환
- const tableRows = activities.map(activity => ({
- id: activity.activityId,
- name: activity.name,
- amount: activity.pointAmount
- }));
-
- // 테이블 행 업데이트 함수
- const updateTableRows = (newRows: Array<{id: number, name: string, amount: number}>) => {
- const updatedActivities = newRows.map(row => ({
- activityId: row.id,
- name: row.name,
- pointAmount: row.amount
- }));
- setActivities(updatedActivities);
- };
-
- return(
- <>
-
-
- >
- )
+import { useEventPageState } from "./event/hooks/useEventPageState"
+import { EventDialogsSection } from "./event/sections/EventDialogsSection"
+import { EventTableSection } from "./event/sections/EventTableSection"
+import { EventToolbarSection } from "./event/sections/EventToolbarSection"
+
+const Event = () => {
+ const state = useEventPageState()
+
+ return (
+
+
+
+
+
+
+
+ )
}
-export default Event;
\ No newline at end of file
+export default Event
diff --git a/src/page/event/hooks/useEventPageState.ts b/src/page/event/hooks/useEventPageState.ts
new file mode 100644
index 0000000..c74a938
--- /dev/null
+++ b/src/page/event/hooks/useEventPageState.ts
@@ -0,0 +1,226 @@
+import { useCallback, useEffect, useMemo, useState, type Dispatch, type FormEvent, type SetStateAction } from "react"
+
+import { deleteActivity } from "@/api/activity/delete-acitivites"
+import { GetActivities } from "@/api/activity/get-activities"
+import { createActivity } from "@/api/activity/post-activities"
+import { getActivityById, updateActivity } from "@/api/activity/put-activities"
+import type { Activity } from "@/api/activity/types"
+import { resolveAdminErrorMessage } from "@/lib/errors/admin-error"
+import { showConfirm, showError, showSuccess } from "@/utils/alert"
+
+export interface EventRow {
+ id: number
+ name: string
+ amount: number
+}
+
+interface UseEventPageStateResult {
+ isLoading: boolean
+ searchText: string
+ isSortAsc: boolean
+ rows: EventRow[]
+ showEditDialog: boolean
+ setShowEditDialog: Dispatch>
+ editingActivityId: number | null
+ eventName: string
+ setEventName: Dispatch>
+ pointAmount: string
+ setPointAmount: Dispatch>
+ qrActivityId: number | null
+ setSearchText: Dispatch>
+ toggleSortOrder: () => void
+ handleCloseQR: () => void
+ handleOpenQR: (eventId: number) => void
+ handleOpenCreateDialog: () => void
+ handleOpenEditDialog: (activityId: number) => Promise
+ handleDelete: (activityId: number) => Promise
+ handleSubmitEvent: (event: FormEvent) => Promise
+}
+
+export const useEventPageState = (): UseEventPageStateResult => {
+ const [activities, setActivities] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [searchText, setSearchText] = useState("")
+ const [isSortAsc, setIsSortAsc] = useState(true)
+
+ const [showEditDialog, setShowEditDialog] = useState(false)
+ const [editingActivityId, setEditingActivityId] = useState(null)
+ const [eventName, setEventName] = useState("")
+ const [pointAmount, setPointAmount] = useState("")
+
+ const [qrActivityId, setQrActivityId] = useState(null)
+
+ const loadActivities = useCallback(async () => {
+ setIsLoading(true)
+ const response = await GetActivities()
+ if (!response.ok || !response.data) {
+ setActivities([])
+ showError(resolveAdminErrorMessage(response.errorName, { fallback: "활동 목록을 불러올 수 없습니다." }))
+ setIsLoading(false)
+ return
+ }
+
+ setActivities(response.data)
+ setIsLoading(false)
+ }, [])
+
+ useEffect(() => {
+ void loadActivities()
+ }, [loadActivities])
+
+ const rows = useMemo(() => {
+ const keyword = searchText.trim().toLowerCase()
+
+ const mapped = activities.map((activity) => ({
+ id: activity.activityId,
+ name: activity.name,
+ amount: activity.pointAmount,
+ }))
+
+ const filtered = keyword
+ ? mapped.filter((row) => {
+ return (
+ row.name.toLowerCase().includes(keyword) ||
+ String(row.id).includes(keyword) ||
+ String(row.amount).includes(keyword)
+ )
+ })
+ : mapped
+
+ return filtered.sort((a, b) => (isSortAsc ? a.id - b.id : b.id - a.id))
+ }, [activities, isSortAsc, searchText])
+
+ const toggleSortOrder = useCallback(() => {
+ setIsSortAsc((prev) => !prev)
+ }, [])
+
+ const handleCloseQR = useCallback(() => {
+ const url = new URL(window.location.href)
+ url.searchParams.delete("id")
+ window.history.pushState({}, "", url)
+
+ setQrActivityId(null)
+ }, [])
+
+ const handleOpenQR = useCallback((eventId: number) => {
+ const url = new URL(window.location.href)
+ url.searchParams.set("id", eventId.toString())
+ window.history.pushState({}, "", url)
+
+ setQrActivityId(eventId)
+ }, [])
+
+ const handleOpenCreateDialog = useCallback(() => {
+ setEditingActivityId(null)
+ setEventName("")
+ setPointAmount("")
+ setShowEditDialog(true)
+ }, [])
+
+ const handleOpenEditDialog = useCallback(
+ async (activityId: number) => {
+ const currentRow = rows.find((row) => row.id === activityId)
+
+ if (currentRow) {
+ setEditingActivityId(activityId)
+ setEventName(currentRow.name)
+ setPointAmount(String(currentRow.amount))
+ setShowEditDialog(true)
+ return
+ }
+
+ const response = await getActivityById(activityId)
+ if (!response.ok || !response.data) {
+ showError(resolveAdminErrorMessage(response.errorName, { fallback: "활동 정보를 불러올 수 없습니다." }))
+ return
+ }
+
+ setEditingActivityId(activityId)
+ setEventName(response.data.name)
+ setPointAmount(String(response.data.pointAmount))
+ setShowEditDialog(true)
+ },
+ [rows],
+ )
+
+ const handleDelete = useCallback(
+ async (activityId: number) => {
+ const confirmed = await showConfirm("정말로 이 활동을 삭제하시겠습니까?")
+ if (!confirmed) {
+ return
+ }
+
+ const response = await deleteActivity(activityId)
+ if (!response.ok) {
+ showError(resolveAdminErrorMessage(response.errorName, { fallback: "삭제에 실패했습니다." }))
+ return
+ }
+
+ showSuccess("활동이 삭제되었습니다.")
+ await loadActivities()
+ },
+ [loadActivities],
+ )
+
+ const handleSubmitEvent = useCallback(
+ async (event: FormEvent) => {
+ event.preventDefault()
+
+ if (!eventName.trim()) {
+ showError("행사 이름을 입력하세요.")
+ return
+ }
+
+ const pointValue = Number(pointAmount)
+ if (!Number.isInteger(pointValue) || pointValue < 0) {
+ showError("올바른 포인트 값을 입력하세요.")
+ return
+ }
+
+ if (editingActivityId) {
+ const response = await updateActivity(editingActivityId, eventName.trim(), pointValue)
+ if (!response.ok) {
+ showError(resolveAdminErrorMessage(response.errorName, { fallback: "행사 수정에 실패했습니다." }))
+ return
+ }
+
+ showSuccess("행사가 수정되었습니다.")
+ } else {
+ const response = await createActivity(eventName.trim(), pointValue)
+ if (!response.ok) {
+ showError(resolveAdminErrorMessage(response.errorName, { fallback: "행사 생성에 실패했습니다." }))
+ return
+ }
+
+ showSuccess("행사가 생성되었습니다.")
+ }
+
+ setShowEditDialog(false)
+ await loadActivities()
+ },
+ [editingActivityId, eventName, loadActivities, pointAmount],
+ )
+
+ return {
+ isLoading,
+ searchText,
+ isSortAsc,
+ rows,
+ showEditDialog,
+ setShowEditDialog,
+ editingActivityId,
+ eventName,
+ setEventName,
+ pointAmount,
+ setPointAmount,
+ qrActivityId,
+ setSearchText,
+ toggleSortOrder,
+ handleCloseQR,
+ handleOpenQR,
+ handleOpenCreateDialog,
+ handleOpenEditDialog,
+ handleDelete,
+ handleSubmitEvent,
+ }
+}
diff --git a/src/page/event/sections/EventDialogsSection.tsx b/src/page/event/sections/EventDialogsSection.tsx
new file mode 100644
index 0000000..fc8df2b
--- /dev/null
+++ b/src/page/event/sections/EventDialogsSection.tsx
@@ -0,0 +1,98 @@
+import type { Dispatch, FormEvent, SetStateAction } from "react"
+
+import QRScannerComponent from "@/components/QRScanner"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+
+interface EventDialogsSectionProps {
+ showEditDialog: boolean
+ onShowEditDialogChange: Dispatch |