diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6a73432..f77c30c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -19,7 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} - + diff --git a/src/components/common/skeletons/ChartCardSkeleton.tsx b/src/components/common/skeletons/ChartCardSkeleton.tsx new file mode 100644 index 0000000..1e9eb92 --- /dev/null +++ b/src/components/common/skeletons/ChartCardSkeleton.tsx @@ -0,0 +1,75 @@ +import { Skeleton } from "./Skeleton"; + +type ChartCardSkeletonProps = { + titleWidth?: string; + subtitleWidth?: string; + controls?: boolean; + variant?: "line" | "donut" | "bars"; +}; + +export function ChartCardSkeleton({ + titleWidth = "w-40", + subtitleWidth = "w-28", + controls = false, + variant = "line", +}: ChartCardSkeletonProps) { + return ( +
+
+
+ + +
+ + {controls ? ( +
+ + +
+ ) : null} +
+ + {variant === "donut" ? ( +
+ +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + + +
+ ))} +
+
+ ) : ( +
+
+ {(variant === "bars" ? Array.from({ length: 10 }) : Array.from({ length: 12 })).map( + (_, index) => ( + + ), + )} +
+
+ )} +
+ ); +} diff --git a/src/components/common/skeletons/FeedCardSkeleton.tsx b/src/components/common/skeletons/FeedCardSkeleton.tsx new file mode 100644 index 0000000..b992e9f --- /dev/null +++ b/src/components/common/skeletons/FeedCardSkeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "./Skeleton"; + +type FeedCardSkeletonProps = { + itemCount?: number; +}; + +export function FeedCardSkeleton({ itemCount = 6 }: FeedCardSkeletonProps) { + return ( +
+ + +
+ + +
+ {Array.from({ length: itemCount }).map((_, index) => ( +
+ + + +
+ ))} +
+
+
+ ); +} diff --git a/src/components/common/skeletons/Skeleton.tsx b/src/components/common/skeletons/Skeleton.tsx new file mode 100644 index 0000000..6033ef8 --- /dev/null +++ b/src/components/common/skeletons/Skeleton.tsx @@ -0,0 +1,9 @@ +import { cn } from "@/lib/utils"; + +type SkeletonProps = { + className?: string; +}; + +export function Skeleton({ className }: SkeletonProps) { + return
; +} diff --git a/src/components/common/skeletons/TableCardSkeleton.tsx b/src/components/common/skeletons/TableCardSkeleton.tsx new file mode 100644 index 0000000..8a53522 --- /dev/null +++ b/src/components/common/skeletons/TableCardSkeleton.tsx @@ -0,0 +1,58 @@ +import { Skeleton } from "./Skeleton"; + +type TableCardSkeletonProps = { + rowCount?: number; + columnCount?: number; +}; + +export function TableCardSkeleton({ rowCount = 10, columnCount = 10 }: TableCardSkeletonProps) { + return ( +
+
+ + +
+ +
+
+
+ {Array.from({ length: columnCount }).map((_, index) => ( +
+ +
+ ))} +
+ +
+ {Array.from({ length: rowCount }).map((_, rowIndex) => ( +
+ {Array.from({ length: columnCount }).map((__, cellIndex) => ( +
+ +
+ ))} +
+ ))} +
+
+
+ +
+ +
+ + + + + +
+
+
+
+ ); +} diff --git a/src/components/domain/churn/ChurnDelta.tsx b/src/components/domain/churn/ChurnDelta.tsx index d300397..1e57daa 100644 --- a/src/components/domain/churn/ChurnDelta.tsx +++ b/src/components/domain/churn/ChurnDelta.tsx @@ -14,6 +14,7 @@ import { YAxis, } from "recharts"; +import { ChartCardSkeleton } from "@/components/common/skeletons/ChartCardSkeleton"; import { useChurnTrend } from "@/lib/tanstack/query/churn/useChurnTrend"; const COLOR_POS = "var(--danger-500)"; @@ -22,22 +23,16 @@ const COLOR_ZERO = "var(--neutral-400)"; export function ChurnDelta() { const { data, isLoading, isError } = useChurnTrend(); - const [range, setRange] = useState<9 | 31>(9); const rawData = data?.data.data ?? []; - - const chartData = rawData.slice(-range).map((d) => ({ - date: d.date, - delta: d.delta, + const chartData = rawData.slice(-range).map((item) => ({ + date: item.date, + delta: item.delta, })); if (isLoading) { - return ( -
-
차트 로딩중...
-
- ); + return ; } if (isError) { @@ -58,7 +53,6 @@ export function ChurnDelta() {

- {/* 기간 선택 */}
- {/* 기간 선택 */}
- +
    {coupons.map((coupon) => (
  • @@ -38,7 +38,7 @@ export function CouponSelect({ type="button" onClick={() => onChange(coupon.id)} className={cn( - "flex w-full items-center justify-between rounded-md px-3 py-2 text-sm", + "flex w-full items-center justify-between rounded-md px-3 py-2 text-start text-sm", value === coupon.id ? "bg-primary-500 text-neutral-0" : "hover:bg-primary-100 text-neutral-900", diff --git a/src/components/domain/customers/CustomersList.tsx b/src/components/domain/customers/CustomersList.tsx index d3a4ed6..4249bc6 100644 --- a/src/components/domain/customers/CustomersList.tsx +++ b/src/components/domain/customers/CustomersList.tsx @@ -7,6 +7,7 @@ import type { RowSelectionState } from "@tanstack/react-table"; import { toast } from "sonner"; +import { TableCardSkeleton } from "@/components/common/skeletons/TableCardSkeleton"; import type { CustomerFilters } from "@/components/domain/customers/filter/FilterList"; import { DataTable } from "@/components/domain/customers/list/DataTable"; import { type CustomerRow, getColumns } from "@/components/domain/customers/list/getColumns"; @@ -17,39 +18,33 @@ import { toAdminMembersParams } from "@/services/customers/toAdminMembersParams" import { ConfirmModal } from "./modals/ConfirmModal"; -// 백엔드 status -> UI 상태 매핑 -function toUiStatus(s: string): CustomerRow["status"] { - if (s === "ACTIVE") return "정상"; - if (s === "BANNED") return "정지"; - if (s === "DELETED") return "탈퇴"; - return "가입중"; // PROCESSING 등 +function toUiStatus(status: string): CustomerRow["status"] { + if (status === "ACTIVE") return "정상"; + if (status === "BANNED") return "정지"; + if (status === "DELETED") return "탈퇴"; + return "가입중"; } -// 백엔드 gender -> UI 성별 매핑 -function toUiGender(g: string): CustomerRow["gender"] { - return g === "M" ? "남" : "여"; +function toUiGender(gender: string): CustomerRow["gender"] { + return gender === "M" ? "남" : "여"; } -// 백엔드 membership -> UI 등급 매핑 (우수=GOLD로 처리) -function toUiGrade(m: string): CustomerRow["grade"] { - if (m === "VIP") return "VIP"; - if (m === "VVIP") return "VVIP"; - return "우수"; // GOLD -> 우수 +function toUiGrade(membership: string): CustomerRow["grade"] { + if (membership === "VIP") return "VIP"; + if (membership === "VVIP") return "VVIP"; + return "우수"; } -// birthDate "YYYY-MM-DD" -> "YYYY.MM.DD" -function dotDate(d: string): string { - return d?.replaceAll("-", ".") ?? ""; +function dotDate(date: string): string { + return date?.replaceAll("-", ".") ?? ""; } type Props = { keyword: string; filters: CustomerFilters; - - page: number; // 1-based + page: number; size: number; onPageChange: (next: number) => void; - rowSelection: RowSelectionState; onRowSelectionChange: (next: RowSelectionState) => void; }; @@ -72,39 +67,31 @@ export function CustomersList({ } | null>(null); const params = toAdminMembersParams({ page, size, keyword, filters }); - const { data, isLoading, isError } = useAdminMembers(params, true); - const members = data?.members ?? []; - // API -> UI rows - const rows: CustomerRow[] = members.map((m) => ({ - id: String(m.id), - grade: toUiGrade(m.membership), - gender: toUiGender(m.gender), - name: m.name, - birth: dotDate(m.birthDate), - phone: m.phone, - email: m.email, - planText: m.planName, - status: toUiStatus(m.status), + const rows: CustomerRow[] = members.map((member) => ({ + id: String(member.id), + grade: toUiGrade(member.membership), + gender: toUiGender(member.gender), + name: member.name, + birth: dotDate(member.birthDate), + phone: member.phone, + email: member.email, + planText: member.planName, + status: toUiStatus(member.status), })); - // 선택된 id 목록 const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]); const selectedCount = selectedIds.length; - - // id -> customer 맵 - const customerById = new Map(rows.map((c) => [c.id, c])); - - // 선택된 status 집합 + const customerById = new Map(rows.map((customer) => [customer.id, customer])); const selectedStatuses = new Set(); + for (const id of selectedIds) { - const c = customerById.get(id); - if (c) selectedStatuses.add(c.status); + const customer = customerById.get(id); + if (customer) selectedStatuses.add(customer.status); } - // 일괄 버튼 노출 규칙 let bulkAction: "BANNED" | "ACTIVE" | null = null; if (selectedCount > 0) { @@ -118,47 +105,34 @@ export function CustomersList({ } const queryClient = useQueryClient(); - const statusMutation = useAdminMembersStatus({ - onSuccess: (res) => { - console.log("[STATUS PATCH SUCCESS]", res); + onSuccess: () => { toast.success("상태를 변경하였습니다."); onRowSelectionChange({}); queryClient.invalidateQueries({ queryKey: ["adminMembers"] }); }, - onError: (err) => { - console.log("[STATUS PATCH ERROR]", err); + onError: () => { toast.error("상태 변경에 실패했습니다."); }, }); const openConfirm = (to: "BANNED" | "ACTIVE", ids: string[]) => { if (ids.length === 0) return; - setPendingAction({ to, ids }); setConfirmOpen(true); }; - const handleBulk = (to: "BANNED" | "ACTIVE") => { - openConfirm(to, selectedIds); - }; - const columns = getColumns({ bulkAction, - onBulkAction: handleBulk, + onBulkAction: (to) => openConfirm(to, selectedIds), onRowAction: (to, id) => openConfirm(to, [id]), - isMutating: statusMutation.isPending, }); const totalCount = data?.pagination.totalCount ?? 0; if (isLoading) { - return ( -
    - 데이터를 불러오는 중 입니다... -
    - ); + return ; } if (isError) { @@ -200,7 +174,7 @@ export function CustomersList({ memberId={selectedCustomer} /> - {confirmOpen && pendingAction && ( + {confirmOpen && pendingAction ? ( - )} + ) : null}
); } diff --git a/src/components/domain/customers/MembershipChart.tsx b/src/components/domain/customers/MembershipChart.tsx index 284188b..56d7a10 100644 --- a/src/components/domain/customers/MembershipChart.tsx +++ b/src/components/domain/customers/MembershipChart.tsx @@ -2,25 +2,23 @@ import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; +import { ChartCardSkeleton } from "@/components/common/skeletons/ChartCardSkeleton"; + interface StatusItem { name: string; value: number; fill: string; } -export function MembershipChart({ - isFiltered, - data, - totalLabel, - isLoading, - isError, -}: { +type Props = { isFiltered: boolean; data: StatusItem[]; totalLabel: string; isLoading?: boolean; isError?: boolean; -}) { +}; + +export function MembershipChart({ isFiltered, data, totalLabel, isLoading, isError }: Props) { const total = data.reduce((acc, cur) => acc + cur.value, 0); return ( @@ -33,7 +31,7 @@ export function MembershipChart({
{isLoading ? ( -
불러오는 중...
+ ) : isError ? (
통계 조회에 실패했습니다
) : ( diff --git a/src/components/domain/customers/MonthlyMembersChart.tsx b/src/components/domain/customers/MonthlyMembersChart.tsx index a680079..a1d3bcd 100644 --- a/src/components/domain/customers/MonthlyMembersChart.tsx +++ b/src/components/domain/customers/MonthlyMembersChart.tsx @@ -10,6 +10,7 @@ import { YAxis, } from "recharts"; +import { ChartCardSkeleton } from "@/components/common/skeletons/ChartCardSkeleton"; import { useMonthlyMembersChart } from "@/lib/tanstack/query/useMonthlyMembersChart"; import type { MonthlyMembers } from "@/models/customers/monthlyMembersChart"; @@ -17,18 +18,14 @@ export function MonthlyMembersChart() { const { data, isLoading, isError } = useMonthlyMembersChart(); const chartData = - data?.data.map((d: MonthlyMembers) => ({ - month: d.month, - joined: d.joinedCount, - left: d.leftCount, + data?.data.map((item: MonthlyMembers) => ({ + month: item.month, + joined: item.joinedCount, + left: item.leftCount, })) ?? []; if (isLoading) { - return ( -
-
차트 로딩중...
-
- ); + return ; } if (isError) { @@ -50,24 +47,18 @@ export function MonthlyMembersChart() { - - {/* X축: 월 */} { - const value = typeof v === "string" ? v : String(v ?? ""); - return value.length >= 7 ? value.slice(5, 7) : value; + tickFormatter={(value) => { + const text = typeof value === "string" ? value : String(value ?? ""); + return text.length >= 7 ? text.slice(5, 7) : text; }} /> - - [`${Number(value ?? 0).toLocaleString()}명`, name]} labelFormatter={(label) => `월: ${label}`} /> - - {/* 가입자 라인 */} - - {/* 탈퇴자 라인 */} ; + } + const member = data?.data; const effectiveStatus = pendingStatus ?? member?.status ?? ""; const effectiveMembership = pendingMembership ?? member?.membership ?? ""; - const currentStatus = member?.status ?? ""; const currentMembership = member?.membership ?? ""; const getContractStatus = () => { - if (!member || !member.isContracted) return { color: "bg-neutral-500", text: "없음" }; + if (!member || !member.isContracted) { + return { color: "bg-neutral-500", text: "없음" }; + } if (member.isExpired || (member.remainingDays ?? 0) <= 0) { return { color: "bg-danger-500", text: "만료" }; @@ -75,10 +80,10 @@ export function CustomerModal({ open, onOpenChange, memberId }: ModalProps) { return { color: "bg-warning-500", text: "임박" }; } - return { color: "bg-success-500", text: "가입" }; + return { color: "bg-success-500", text: "유지" }; }; - const contractStatus = getContractStatus(); + const contractStatus = getContractStatus(); const formattedBirthDate = formatDate(member?.birthDate); const formattedJoinDate = formatDate(member?.joinDate); const dotContractStartDate = formatDate(member?.contractStartDate); @@ -105,28 +110,25 @@ export function CustomerModal({ open, onOpenChange, memberId }: ModalProps) { }} tabIndex={-1} ref={(el) => el?.focus()}> - {/* 배경 */}
- {/* 모달창 */}

고객 상세 정보

{!memberId ? (
고객을 선택해주세요
- ) : isLoading ? ( -
불러오는 중...
) : isError || !member ? (
- 상세 정보를 불러오지 못했습니다 + 상세 정보를 불러오지 못했습니다.
) : (
@@ -177,7 +179,7 @@ export function CustomerModal({ open, onOpenChange, memberId }: ModalProps) {
- + 0 - ? `${member.remainingDays} 일` + ? `${member.remainingDays}일` : "-" } /> @@ -220,7 +222,7 @@ export function CustomerModal({ open, onOpenChange, memberId }: ModalProps) {
- +
@@ -236,12 +238,14 @@ export function CustomerModal({ open, onOpenChange, memberId }: ModalProps) { className="bg-secondary-500 cursor-pointer rounded-sm px-4 py-1" onClick={() => { setUpdateOpen(true); - }}> + }} + type="button"> 수정 @@ -250,7 +254,7 @@ export function CustomerModal({ open, onOpenChange, memberId }: ModalProps) { open={updateOpen} onOpenChange={setUpdateOpen} memberId={memberId} - memberName={member?.name ?? ""} + memberName={member.name} currentStatus={currentStatus} currentMembership={currentMembership} pendingStatus={effectiveStatus} diff --git a/src/components/domain/customers/modals/CustomerModalSkeleton.tsx b/src/components/domain/customers/modals/CustomerModalSkeleton.tsx new file mode 100644 index 0000000..9ffadb8 --- /dev/null +++ b/src/components/domain/customers/modals/CustomerModalSkeleton.tsx @@ -0,0 +1,120 @@ +import { X } from "lucide-react"; + +import { Skeleton } from "@/components/common/skeletons/Skeleton"; + +function SkeletonInfoRow({ interactive = false }: { interactive?: boolean }) { + return ( +
+
+ +
+
+ {interactive ? ( + + ) : ( + + )} +
+
+ ); +} + +function SkeletonSection({ + titleWidth = "w-32", + leftRows = 3, + rightRows = 3, + interactiveRows = [], +}: { + titleWidth?: string; + leftRows?: number; + rightRows?: number; + interactiveRows?: Array<"left" | "right">; +}) { + return ( + <> +
+
+
+ +
+
+ +
+ {Array.from({ length: leftRows }).map((_, index) => ( + + ))} +
+ +
+ {Array.from({ length: rightRows }).map((_, index) => ( + + ))} +
+
+ +
+
+
+
+ + ); +} + +export function CustomerModalSkeleton() { + return ( +
+
+ +
+
+ + +
+ +
+ + + +
+
+
+ +
+
+
+ + + +
+
+ + + +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/constants/coupons.ts b/src/constants/coupons.ts index bf3855b..f3133d9 100644 --- a/src/constants/coupons.ts +++ b/src/constants/coupons.ts @@ -1,4 +1,5 @@ export const CHURN_COUPONS = [ - { id: 1, name: "이탈 방지 쿠폰" }, - { id: 2, name: "요금제 할인 쿠폰" }, + { id: 1, name: "요금 5000원 할인 쿠폰" }, + { id: 2, name: "생일 축하 데이터 쿠폰" }, + { id: 3, name: "신규 가입자 웰컴 데이터 쿠폰" }, ]; diff --git a/src/lib/axios.ts b/src/lib/axios.ts index b90e262..7c6dd7e 100644 --- a/src/lib/axios.ts +++ b/src/lib/axios.ts @@ -1,15 +1,7 @@ -// import axios from "axios"; - -// export const api = axios.create({ -// baseURL: "", -// timeout: 10000, -// withCredentials: true, -// }); - import axios from "axios"; export const api = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, - timeout: 10000, + baseURL: "", + timeout: 18000, withCredentials: true, });