From 6155de6c88de06059ed487c1c670457450319195 Mon Sep 17 00:00:00 2001 From: Yun Seongmin Date: Thu, 12 Feb 2026 02:05:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 4 +- src/api/coupon/delete-coupon-code.ts | 6 + src/api/coupon/delete-coupon.ts | 6 + src/api/coupon/delete-issued-coupon.ts | 6 + src/api/coupon/get-admin-members.ts | 6 + src/api/coupon/get-coupon-codes.ts | 6 + src/api/coupon/get-coupons.ts | 6 + src/api/coupon/get-issued-coupons.ts | 6 + src/api/coupon/patch-coupon-name.ts | 9 + src/api/coupon/post-coupon-code.ts | 9 + src/api/coupon/post-coupon.ts | 9 + src/api/coupon/post-issued-coupons.ts | 9 + src/api/coupon/request.ts | 39 ++ src/api/coupon/types.ts | 48 ++ src/page/coupon.tsx | 807 +++++++++++++++++++++++++ 15 files changed, 975 insertions(+), 1 deletion(-) create mode 100644 src/api/coupon/delete-coupon-code.ts create mode 100644 src/api/coupon/delete-coupon.ts create mode 100644 src/api/coupon/delete-issued-coupon.ts create mode 100644 src/api/coupon/get-admin-members.ts create mode 100644 src/api/coupon/get-coupon-codes.ts create mode 100644 src/api/coupon/get-coupons.ts create mode 100644 src/api/coupon/get-issued-coupons.ts create mode 100644 src/api/coupon/patch-coupon-name.ts create mode 100644 src/api/coupon/post-coupon-code.ts create mode 100644 src/api/coupon/post-coupon.ts create mode 100644 src/api/coupon/post-issued-coupons.ts create mode 100644 src/api/coupon/request.ts create mode 100644 src/api/coupon/types.ts create mode 100644 src/page/coupon.tsx diff --git a/src/App.tsx b/src/App.tsx index 8157e02..30fea31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { ToastContainer, Bounce } from 'react-toastify'; import Home from './page/home'; import Login from './page/login'; import Event from './page/event'; +import Coupon from './page/coupon'; import NotFound from './page/notfound'; const App: React.FC = () => { @@ -12,6 +13,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/api/coupon/delete-coupon-code.ts b/src/api/coupon/delete-coupon-code.ts new file mode 100644 index 0000000..ed96e24 --- /dev/null +++ b/src/api/coupon/delete-coupon-code.ts @@ -0,0 +1,6 @@ +import { requestApi } from './request'; +import type { ApiResult } from './types'; + +export async function deleteCouponCode(codeCouponId: number): Promise> { + return requestApi(`/admin/coupons/code/${codeCouponId}`, { method: 'DELETE' }); +} diff --git a/src/api/coupon/delete-coupon.ts b/src/api/coupon/delete-coupon.ts new file mode 100644 index 0000000..607e13d --- /dev/null +++ b/src/api/coupon/delete-coupon.ts @@ -0,0 +1,6 @@ +import { requestApi } from './request'; +import type { ApiResult } from './types'; + +export async function deleteCoupon(couponId: number): Promise> { + return requestApi(`/admin/coupons/${couponId}`, { method: 'DELETE' }); +} diff --git a/src/api/coupon/delete-issued-coupon.ts b/src/api/coupon/delete-issued-coupon.ts new file mode 100644 index 0000000..14e6bdf --- /dev/null +++ b/src/api/coupon/delete-issued-coupon.ts @@ -0,0 +1,6 @@ +import { requestApi } from './request'; +import type { ApiResult } from './types'; + +export async function deleteIssuedCoupon(issuedCouponId: number): Promise> { + return requestApi(`/admin/coupons/issued/${issuedCouponId}`, { method: 'DELETE' }); +} diff --git a/src/api/coupon/get-admin-members.ts b/src/api/coupon/get-admin-members.ts new file mode 100644 index 0000000..4590b9c --- /dev/null +++ b/src/api/coupon/get-admin-members.ts @@ -0,0 +1,6 @@ +import { requestApi } from './request'; +import type { AdminMemberSummary, ApiResult } from './types'; + +export async function getAdminMembers(): Promise> { + return requestApi('/admin/members', { method: 'GET' }); +} diff --git a/src/api/coupon/get-coupon-codes.ts b/src/api/coupon/get-coupon-codes.ts new file mode 100644 index 0000000..3f25431 --- /dev/null +++ b/src/api/coupon/get-coupon-codes.ts @@ -0,0 +1,6 @@ +import { requestApi } from './request'; +import type { AdminCouponCode, ApiResult } from './types'; + +export async function getCouponCodes(): Promise> { + return requestApi('/admin/coupons/code', { method: 'GET' }); +} diff --git a/src/api/coupon/get-coupons.ts b/src/api/coupon/get-coupons.ts new file mode 100644 index 0000000..0257a3e --- /dev/null +++ b/src/api/coupon/get-coupons.ts @@ -0,0 +1,6 @@ +import { requestApi } from './request'; +import type { AdminCoupon, ApiResult } from './types'; + +export async function getCoupons(): Promise> { + return requestApi('/admin/coupons', { method: 'GET' }); +} diff --git a/src/api/coupon/get-issued-coupons.ts b/src/api/coupon/get-issued-coupons.ts new file mode 100644 index 0000000..f5782ef --- /dev/null +++ b/src/api/coupon/get-issued-coupons.ts @@ -0,0 +1,6 @@ +import { requestApi } from './request'; +import type { AdminIssuedCoupon, ApiResult } from './types'; + +export async function getIssuedCoupons(): Promise> { + return requestApi('/admin/coupons/issued', { method: 'GET' }); +} diff --git a/src/api/coupon/patch-coupon-name.ts b/src/api/coupon/patch-coupon-name.ts new file mode 100644 index 0000000..8b091c9 --- /dev/null +++ b/src/api/coupon/patch-coupon-name.ts @@ -0,0 +1,9 @@ +import { requestApi } from './request'; +import type { AdminCoupon, ApiResult } from './types'; + +export async function updateCouponName(couponId: number, couponName: string): Promise> { + return requestApi(`/admin/coupons/${couponId}/name`, { + method: 'PATCH', + body: JSON.stringify({ couponName }), + }); +} diff --git a/src/api/coupon/post-coupon-code.ts b/src/api/coupon/post-coupon-code.ts new file mode 100644 index 0000000..4d8ff21 --- /dev/null +++ b/src/api/coupon/post-coupon-code.ts @@ -0,0 +1,9 @@ +import { requestApi } from './request'; +import type { AdminCouponCode, ApiResult } from './types'; + +export async function createCouponCode(couponId: number, description: string | null): Promise> { + return requestApi('/admin/coupons/code', { + method: 'POST', + body: JSON.stringify({ couponId, description }), + }); +} diff --git a/src/api/coupon/post-coupon.ts b/src/api/coupon/post-coupon.ts new file mode 100644 index 0000000..52f35f7 --- /dev/null +++ b/src/api/coupon/post-coupon.ts @@ -0,0 +1,9 @@ +import { requestApi } from './request'; +import type { AdminCoupon, ApiResult } from './types'; + +export async function createCoupon(couponName: string, discountAmount: number): Promise> { + return requestApi('/admin/coupons', { + method: 'POST', + body: JSON.stringify({ couponName, discountAmount }), + }); +} diff --git a/src/api/coupon/post-issued-coupons.ts b/src/api/coupon/post-issued-coupons.ts new file mode 100644 index 0000000..67a6e20 --- /dev/null +++ b/src/api/coupon/post-issued-coupons.ts @@ -0,0 +1,9 @@ +import { requestApi } from './request'; +import type { AdminIssuedCoupon, ApiResult } from './types'; + +export async function createIssuedCoupons(couponId: number, memberIds: number[]): Promise> { + return requestApi('/admin/coupons/issued', { + method: 'POST', + body: JSON.stringify({ couponId, memberIds }), + }); +} diff --git a/src/api/coupon/request.ts b/src/api/coupon/request.ts new file mode 100644 index 0000000..c48c985 --- /dev/null +++ b/src/api/coupon/request.ts @@ -0,0 +1,39 @@ +import type { ApiResult } from './types'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +export async function requestApi(path: string, init?: RequestInit): Promise> { + try { + const initHeaders = init?.headers ?? {}; + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + credentials: 'include', + headers: { + accept: 'application/json', + 'Content-Type': 'application/json', + ...initHeaders, + }, + }); + + 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('[Coupon API Error]', error); + return { ok: false, status: 0 }; + } +} diff --git a/src/api/coupon/types.ts b/src/api/coupon/types.ts new file mode 100644 index 0000000..ff8bd5d --- /dev/null +++ b/src/api/coupon/types.ts @@ -0,0 +1,48 @@ +export interface AdminCoupon { + couponId: number; + couponName: string; + discountAmount: number; + createdAt: string; + updatedAt: string; +} + +export interface AdminCouponCode { + codeCouponId: number; + couponId: number; + couponName: string; + code: string; + description: string | null; + isValid: boolean; + issuedCouponId: number | null; + usedAt: string | null; + createdAt: string; +} + +export interface AdminIssuedCoupon { + issuedCouponId: number; + couponId: number; + couponName: string; + discountAmount: number; + memberId: number; + memberName: string; + memberEmail: string; + isValid: boolean; + paymentId: number | null; + usedAt: string | null; + createdAt: string; +} + +export interface AdminMemberSummary { + memberId: number; + studentId: string | null; + name: string; + email: string; + role: string; +} + +export interface ApiResult { + ok: boolean; + status: number; + data?: T; + errorName?: string; +} diff --git a/src/page/coupon.tsx b/src/page/coupon.tsx new file mode 100644 index 0000000..82453ec --- /dev/null +++ b/src/page/coupon.tsx @@ -0,0 +1,807 @@ +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, + }); +} + +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 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}명 중 {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; + + 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)} + + )} +
+
+ + ); +}; + +export default CouponPage; From 4799281385242c052ab5b5f0058dff12cda85593 Mon Sep 17 00:00:00 2001 From: Yun Seongmin Date: Thu, 12 Feb 2026 02:14:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=ED=95=84=ED=84=B0=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=97=90=20=EB=94=B0=EB=9D=BC=20N=EB=AA=85=20?= =?UTF-8?q?=EC=A4=91=20M=EB=AA=85=20=EC=84=A0=ED=83=9D=EC=97=90=EC=84=9C?= =?UTF-8?q?=20N=EC=9D=B4=20M=EB=B3=B4=EB=8B=A4=20=EC=9E=91=EC=9D=84=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/coupon.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/page/coupon.tsx b/src/page/coupon.tsx index 82453ec..6864e34 100644 --- a/src/page/coupon.tsx +++ b/src/page/coupon.tsx @@ -236,6 +236,10 @@ const CouponPage: React.FC = () => { 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('쿠폰 이름을 입력해주세요.'); @@ -528,7 +532,10 @@ const CouponPage: React.FC = () => { 전체 선택 해제 - 총 {filteredMembersForIssue.length}명 중 {selectedMemberIdsForIssue.length}명 선택 + 총 {filteredMembersForIssue.length}명 중 {selectedAmongFilteredCount}명 선택 + + (전체 선택 {selectedMemberIdsForIssue.length}명) +