From e7f6f5f6bc1a69aa97d130f72491abf41698fb8b Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 11 May 2026 15:06:07 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=B6=A9=EC=A0=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/common/ChargeDrawer.tsx | 10 ++- .../common/charge/ChargeHistoryContent.tsx | 73 +++++++++++++++---- .../common/charge/ChargeHistoryListItem.tsx | 69 ++++++++---------- components/common/charge/QuickBundleCard.tsx | 14 ++-- components/common/charge/ShopRows.tsx | 2 +- hooks/useItemHistory.ts | 70 ++++++++++++++++++ 6 files changed, 178 insertions(+), 60 deletions(-) create mode 100644 hooks/useItemHistory.ts diff --git a/components/common/ChargeDrawer.tsx b/components/common/ChargeDrawer.tsx index 9b9b204..25c52c5 100644 --- a/components/common/ChargeDrawer.tsx +++ b/components/common/ChargeDrawer.tsx @@ -44,7 +44,7 @@ export default function ChargeDrawer({ trigger }: ChargeDrawerProps) { className="rounded-t-[16px] bg-white outline-none" showHandle={false} > -
+
{/* ... */} @@ -60,10 +60,14 @@ export default function ChargeDrawer({ trigger }: ChargeDrawerProps) { {/* ── 탭 칩 ── */} - +
+ + {/* 스크롤 시 뚝 끊기지 않도록 가려주는 그라데이션 */} +
+
{/* ── Scrollable Content ── */} -
+
diff --git a/components/common/charge/ChargeHistoryContent.tsx b/components/common/charge/ChargeHistoryContent.tsx index 86ad940..99eb8de 100644 --- a/components/common/charge/ChargeHistoryContent.tsx +++ b/components/common/charge/ChargeHistoryContent.tsx @@ -1,36 +1,83 @@ "use client"; -import React from "react"; -import { useItems } from "@/hooks/useItems"; +import React, { useState } from "react"; import ChargeInventoryCard from "./ChargeInventoryCard"; import ChargeHistoryListItem from "./ChargeHistoryListItem"; -import { - BUSINESS_INFO, - NOTICE_INFO, - CHARGE_HISTORY, -} from "@/lib/constants/charge"; +import { BUSINESS_INFO, NOTICE_INFO } from "@/lib/constants/charge"; +import { useItemHistory } from "@/hooks/useItemHistory"; export default function ChargeHistoryContent() { + const [filterType, setFilterType] = useState(undefined); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useItemHistory({ + historyType: filterType, // You can map to "USE" or "CHARGE" based on filters if you add UI for it + }); + + const allItems = + data?.pages.flatMap((page) => page?.data?.content || []) || []; + return (
{/* ── 보유현황 카드 ── */} + {/* ── 필터 옵션 (선택 사항) ── */} +
+ + + +
+ {/* ── 충전 내역 리스트 ── */} -
- {CHARGE_HISTORY.map((item) => ( - - ))} +
+ {isLoading ? ( +
+ 불러오는 중... +
+ ) : allItems.length === 0 ? ( +
+ 내역이 없습니다. +
+ ) : ( + allItems.map((item) => ( + + )) + )} + + {hasNextPage && ( + + )}
{/* ── 유의사항 ── */}
유의사항
-

+

{NOTICE_INFO}

-

+

{BUSINESS_INFO}

diff --git a/components/common/charge/ChargeHistoryListItem.tsx b/components/common/charge/ChargeHistoryListItem.tsx index ef5b1bc..df39d28 100644 --- a/components/common/charge/ChargeHistoryListItem.tsx +++ b/components/common/charge/ChargeHistoryListItem.tsx @@ -2,62 +2,57 @@ import React from "react"; import { cn } from "@/lib/utils"; +import { ItemHistoryItem } from "@/hooks/useItemHistory"; -export type ChargeStatus = "success" | "failed" | "cancelled" | "event"; - -export interface ChargeHistoryItemProps { - date: string; - orderId: string; - points: string; - paymentAmount: string; - status: ChargeStatus; - statusLabel: string; -} - -/* ── 상태별 스타일 헬퍼 ── */ -function getStatusStyles(status: ChargeStatus) { - const isInactive = status === "failed" || status === "cancelled"; - return { - dateColor: isInactive ? "text-color-gray-400" : "text-color-text-black", - pointsColor: isInactive ? "text-color-gray-400" : "text-color-text-black", - amountColor: isInactive ? "text-color-gray-400" : "text-color-text-black", - amountStrike: isInactive, - }; +interface ChargeHistoryItemProps { + item: ItemHistoryItem; } export default function ChargeHistoryListItem({ - date, - orderId, - points, - paymentAmount, - status, - statusLabel, + item, }: ChargeHistoryItemProps) { - const styles = getStatusStyles(status); + const dateObj = new Date(item.createdAt); + const formattedDate = `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, "0")}.${String(dateObj.getDate()).padStart(2, "0")} ${String(dateObj.getHours()).padStart(2, "0")}:${String(dateObj.getMinutes()).padStart(2, "0")}`; + + const isUse = item.historyType === "USE"; + const quantityPrefix = item.quantity > 0 ? "+" : ""; + + const unit = + item.itemType === "MATCHING_TICKET" + ? "장" + : item.itemType === "OPTION_TICKET" + ? "개" + : "코인"; + const typeLabel = + item.historyType === "CHARGE" + ? "충전" + : item.historyType === "EVENT" + ? "이벤트/보너스" + : "사용"; return (
- {/* 날짜 | 주문번호 */} - - {date} | {orderId} + + {formattedDate} | 내역번호: {item.historyId} - {/* 포인트 · 결제금액 */}
- {points} + + {item.description} + - {paymentAmount} + {quantityPrefix} + {item.quantity} + {unit}
- {/* 상태 */} - {statusLabel} + {typeLabel}
); } diff --git a/components/common/charge/QuickBundleCard.tsx b/components/common/charge/QuickBundleCard.tsx index 008c9d3..5a50661 100644 --- a/components/common/charge/QuickBundleCard.tsx +++ b/components/common/charge/QuickBundleCard.tsx @@ -34,18 +34,20 @@ export default function QuickBundleCard({ product }: QuickBundleCardProps) { height={ICON_SIZE.lg} />
- + {product.name}
{description} - {bonusText && ( - - {bonusText} - - )} + + {bonusText || "보너스 무료 증정!"} +
diff --git a/components/common/charge/ShopRows.tsx b/components/common/charge/ShopRows.tsx index 17eb252..f49dc66 100644 --- a/components/common/charge/ShopRows.tsx +++ b/components/common/charge/ShopRows.tsx @@ -52,7 +52,7 @@ export function ShopBundleRow({ product }: ShopBundleRowProps) {
- + {product.name} diff --git a/hooks/useItemHistory.ts b/hooks/useItemHistory.ts new file mode 100644 index 0000000..44a4f4b --- /dev/null +++ b/hooks/useItemHistory.ts @@ -0,0 +1,70 @@ +import { api } from "@/lib/axios"; +import { useInfiniteQuery } from "@tanstack/react-query"; + +export type ItemHistoryType = "CHARGE" | "USE" | "EVENT"; +export type ItemType = "MATCHING_TICKET" | "OPTION_TICKET" | "COIN"; + +export interface ItemHistoryItem { + historyId: number; + itemType: ItemType | string; + historyType: ItemHistoryType | string; + quantity: number; + description: string; + createdAt: string; +} + +export interface ItemHistoryPageData { + content: ItemHistoryItem[]; + currentPage: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; +} + +export interface ItemHistoryResponse { + code: string; + status: number; + message: string; + data: ItemHistoryPageData; +} + +export interface FetchItemHistoryParams { + type?: string; + historyType?: string; + sort?: string; + page?: number; + size?: number; +} + +export const fetchItemHistoryPage = async ( + params: FetchItemHistoryParams, +): Promise => { + const { data } = await api.get("/api/items/history", { + params: { + page: params.page || 0, + size: params.size || 20, + type: params.type, + historyType: params.historyType, + sort: params.sort || "createdAt,desc", + }, + }); + return data; +}; + +export const useItemHistory = ( + params: Omit = {}, +) => { + return useInfiniteQuery({ + queryKey: ["itemHistory", params], + queryFn: ({ pageParam }) => + fetchItemHistoryPage({ ...params, page: pageParam as number }), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + return lastPage?.data?.hasNext + ? lastPage.data.currentPage + 1 + : undefined; + }, + }); +}; From 4eb97982382595d49926224c7a44dec8fc93841b Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 11 May 2026 15:18:09 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EC=B6=A9=EC=A0=84=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=EC=A4=91=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main/_components/ChargeRequestWaiting.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/main/_components/ChargeRequestWaiting.tsx b/app/main/_components/ChargeRequestWaiting.tsx index 813e42a..d8a72f1 100644 --- a/app/main/_components/ChargeRequestWaiting.tsx +++ b/app/main/_components/ChargeRequestWaiting.tsx @@ -1,6 +1,11 @@ +"use client"; + import { RotateCw } from "lucide-react"; +import { useRequestStatus } from "@/hooks/useRequestStatus"; const ChargeRequestWaiting = () => { + const { refetch, isFetching } = useRequestStatus(); + return (
@@ -26,15 +31,23 @@ const ChargeRequestWaiting = () => {
- {/* 텍스트 영역 (Frame 2612794) */} + {/* 텍스트 영역 */}
- {/* 타이틀 행 (Frame 2612797) */} + {/* 타이틀 행 */}
대기중 - {/* ArrowClockwise #9E9E9E 반영 */} - +
{/* 설명 문구 */} From c43d03573e24b6112e113946b8bb384cee999ec8 Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 11 May 2026 15:21:42 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EC=B6=A9=EC=A0=84=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=EC=A4=91=20=ED=95=B4=EC=A0=9C=20=EC=8B=9C=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EB=B0=8F=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=BA=90=EC=8B=B1=20=EC=83=88=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/useRequestStatus.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/hooks/useRequestStatus.ts b/hooks/useRequestStatus.ts index 094584a..c1b703d 100644 --- a/hooks/useRequestStatus.ts +++ b/hooks/useRequestStatus.ts @@ -1,5 +1,6 @@ import { api } from "@/lib/axios"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import React, { useEffect, useRef } from "react"; import { AxiosError } from "axios"; export interface RequestStatusResponse { @@ -19,6 +20,7 @@ export const fetchRequestStatus = async (): Promise => { }; export const useRequestStatus = () => { + const queryClient = useQueryClient(); const query = useQuery< RequestStatusResponse, AxiosError<{ code: string; message: string }> @@ -33,5 +35,17 @@ export const useRequestStatus = () => { query.error?.response?.data?.code === "GATEWAY-001" || query.error?.response?.data?.code === "GATEWAY-002"; + // 상태가 PENDING에서 NONE으로 변했을 때를 감지하기 위한 ref + const prevPendingRef = useRef(isPurchasePending); + + useEffect(() => { + // 이전엔 대기중(true)이었는데, 지금은 대기중이 아님(false) -> 충전 완료됨! + if (prevPendingRef.current === true && isPurchasePending === false) { + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["itemHistory"] }); + } + prevPendingRef.current = isPurchasePending; + }, [isPurchasePending, queryClient]); + return { ...query, isPurchasePending }; }; From c0d79e060165077c85237db1cb17b54cc8db7dd1 Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 11 May 2026 16:02:09 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/_components/AdminMainScreen.tsx | 10 + .../notices/_components/AdminNotices.tsx | 424 ++++++++++++++++++ app/adminpage/notices/page.tsx | 27 ++ hooks/admin/useAdminNotices.ts | 135 ++++++ 4 files changed, 596 insertions(+) create mode 100644 app/adminpage/notices/_components/AdminNotices.tsx create mode 100644 app/adminpage/notices/page.tsx create mode 100644 hooks/admin/useAdminNotices.ts diff --git a/app/adminpage/main/_components/AdminMainScreen.tsx b/app/adminpage/main/_components/AdminMainScreen.tsx index b8c731c..d7f40f1 100644 --- a/app/adminpage/main/_components/AdminMainScreen.tsx +++ b/app/adminpage/main/_components/AdminMainScreen.tsx @@ -10,6 +10,7 @@ import { BarChart3, LogOut, Shield, + Bell, } from "lucide-react"; const MENU_ITEMS = [ @@ -32,6 +33,15 @@ const MENU_ITEMS = [ active: true, gradient: "from-[#6366f1] to-[#8b5cf6]", }, + { + id: "notices", + title: "공지사항 관리", + description: "서비스 공지사항을 등록, 수정, 삭제합니다", + icon: Bell, + href: "/adminpage/notices", + active: true, + gradient: "from-[#10b981] to-[#059669]", + }, { id: "users", title: "사용자 관리", diff --git a/app/adminpage/notices/_components/AdminNotices.tsx b/app/adminpage/notices/_components/AdminNotices.tsx new file mode 100644 index 0000000..cf38167 --- /dev/null +++ b/app/adminpage/notices/_components/AdminNotices.tsx @@ -0,0 +1,424 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import { + useActiveNotices, + useCreateNotice, + useUpdateNotice, + useDeleteNotice, + type CreateNoticeBody, + type NoticeItem, +} from "@/hooks/admin/useAdminNotices"; +import { + ArrowLeft, + Bell, + Loader2, + Plus, + Trash2, + X, + Inbox, + Pencil, + Clock, + Calendar, + Check, +} from "lucide-react"; + +/* ── 날짜 포맷 헬퍼 ── */ +function formatDateTime(dateStr: string) { + const d = new Date(dateStr); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function toLocalDateTimeString(date: Date) { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function getStatusInfo(startTime: string, endTime: string) { + const now = new Date(); + const start = new Date(startTime); + const end = new Date(endTime); + + if (now < start) { + return { label: "예정", color: "text-amber-400", bg: "bg-amber-500/10" }; + } else if (now > end) { + return { label: "종료", color: "text-[#4a4e69]", bg: "bg-[#1e2030]" }; + } else { + return { + label: "활성", + color: "text-emerald-400", + bg: "bg-emerald-500/10", + }; + } +} + +/* ── 공지사항 작성/수정 폼 컴포넌트 ── */ +function NoticeForm({ + onSuccess, + editingNotice, + onCancelEdit, +}: { + onSuccess: () => void; + editingNotice?: NoticeItem | null; + onCancelEdit?: () => void; +}) { + const isEditMode = !!editingNotice; + const createMutation = useCreateNotice(); + const updateMutation = useUpdateNotice(); + const isPending = createMutation.isPending || updateMutation.isPending; + + const [title, setTitle] = useState(editingNotice?.title || ""); + const [content, setContent] = useState(editingNotice?.content || ""); + const [startTime, setStartTime] = useState( + editingNotice + ? toLocalDateTimeString(new Date(editingNotice.startTime)) + : "", + ); + const [endTime, setEndTime] = useState( + editingNotice ? toLocalDateTimeString(new Date(editingNotice.endTime)) : "", + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!title.trim()) return alert("제목을 입력해 주세요."); + if (title.length > 200) return alert("제목은 200자 이내로 입력해 주세요."); + if (!content.trim()) return alert("내용을 입력해 주세요."); + if (!startTime) return alert("시작 시간을 선택해 주세요."); + if (!endTime) return alert("종료 시간을 선택해 주세요."); + if (new Date(startTime) >= new Date(endTime)) + return alert("시작 시간은 종료 시간보다 이전이어야 합니다."); + + const body: CreateNoticeBody = { + title: title.trim(), + content: content.trim(), + startTime, + endTime, + }; + + if (isEditMode && editingNotice) { + updateMutation.mutate( + { noticeId: editingNotice.noticeId, body }, + { + onSuccess: () => { + onSuccess(); + onCancelEdit?.(); + }, + onError: (error) => { + alert( + error.response?.data?.message || "공지사항 수정에 실패했습니다.", + ); + }, + }, + ); + } else { + createMutation.mutate(body, { + onSuccess: () => { + setTitle(""); + setContent(""); + setStartTime(""); + setEndTime(""); + onSuccess(); + }, + onError: (error) => { + alert( + error.response?.data?.message || "공지사항 등록에 실패했습니다.", + ); + }, + }); + } + }; + + return ( +
+

+ {isEditMode ? "공지사항 수정" : "새 공지사항 등록"} +

+ +
+ {/* 제목 */} +
+ + setTitle(e.target.value)} + maxLength={200} + placeholder="공지사항 제목을 입력하세요" + className="rounded-xl border border-[#2a2d42] bg-[#0f1117] px-4 py-3 text-sm text-white placeholder:text-[#4a4e69] focus:border-[#10b981] focus:outline-none" + /> + + {title.length}/200 + +
+ + {/* 내용 */} +
+ +