Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body className={`${pretendard.variable}`}>
<TanstackProvider>
{children}
<Toaster position="bottom-center" richColors />
<Toaster position="top-center" richColors />
</TanstackProvider>
</body>
</html>
Expand Down
75 changes: 75 additions & 0 deletions src/components/common/skeletons/ChartCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-neutral-0 rounded-xl border border-neutral-300 p-6">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="space-y-2">
<Skeleton className={`h-6 ${titleWidth}`} />
<Skeleton className={`h-4 ${subtitleWidth}`} />
</div>

{controls ? (
<div className="flex gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
) : null}
</div>

{variant === "donut" ? (
<div className="flex items-center justify-evenly gap-8 py-4">
<Skeleton className="h-56 w-56 rounded-full" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-14" />
</div>
))}
</div>
</div>
) : (
<div className="h-64 w-full rounded-lg border border-neutral-200 px-6 py-5">
<div className="flex h-full items-end gap-3">
{(variant === "bars" ? Array.from({ length: 10 }) : Array.from({ length: 12 })).map(
(_, index) => (
<Skeleton
key={index}
className={`w-full rounded-t-md ${
variant === "bars"
? index % 3 === 0
? "h-32"
: index % 3 === 1
? "h-20"
: "h-24"
: index % 4 === 0
? "h-20"
: index % 4 === 1
? "h-28"
: index % 4 === 2
? "h-16"
: "h-24"
}`}
/>
),
)}
</div>
</div>
)}
</div>
);
}
27 changes: 27 additions & 0 deletions src/components/common/skeletons/FeedCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Skeleton } from "./Skeleton";

type FeedCardSkeletonProps = {
itemCount?: number;
};

export function FeedCardSkeleton({ itemCount = 6 }: FeedCardSkeletonProps) {
return (
<div className="bg-neutral-0 flex flex-col gap-4 rounded-xl border border-neutral-300 p-6">
<Skeleton className="h-6 w-36" />

<div className="flex h-[669px] px-6">
<Skeleton className="h-full w-px rounded-none" />

<div className="w-full space-y-6 py-6 pl-6">
{Array.from({ length: itemCount }).map((_, index) => (
<div key={index} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-full" />
</div>
))}
</div>
</div>
</div>
);
}
9 changes: 9 additions & 0 deletions src/components/common/skeletons/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { cn } from "@/lib/utils";

type SkeletonProps = {
className?: string;
};

export function Skeleton({ className }: SkeletonProps) {
return <div className={cn("animate-pulse rounded-md bg-neutral-200", className)} />;
}
58 changes: 58 additions & 0 deletions src/components/common/skeletons/TableCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Skeleton } from "./Skeleton";

type TableCardSkeletonProps = {
rowCount?: number;
columnCount?: number;
};

export function TableCardSkeleton({ rowCount = 10, columnCount = 10 }: TableCardSkeletonProps) {
return (
<div className="bg-neutral-0 rounded-xl border border-neutral-300">
<div className="flex items-center justify-between px-5 pt-6 pb-4">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-5 w-20" />
</div>

<div className="px-5 pb-4">
<div className="overflow-hidden rounded-lg border border-neutral-300">
<div
className="grid border-b border-neutral-300"
style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}>
{Array.from({ length: columnCount }).map((_, index) => (
<div key={index} className="px-4 py-4">
<Skeleton className="h-4 w-full" />
</div>
))}
</div>

<div className="divide-y divide-neutral-300">
{Array.from({ length: rowCount }).map((_, rowIndex) => (
<div
key={rowIndex}
className="grid"
style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}>
{Array.from({ length: columnCount }).map((__, cellIndex) => (
<div key={cellIndex} className="px-4 py-4">
<Skeleton className="h-4 w-full" />
</div>
))}
</div>
))}
</div>
</div>
</div>

<div className="flex items-center justify-between px-5 py-4">
<Skeleton className="h-5 w-20" />
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="h-9 w-9" />
<Skeleton className="h-9 w-9" />
<Skeleton className="h-9 w-9" />
<Skeleton className="h-9 w-9 rounded-full" />
</div>
<div className="w-20" />
</div>
</div>
);
}
37 changes: 14 additions & 23 deletions src/components/domain/churn/ChurnDelta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
Expand All @@ -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 (
<div className="bg-neutral-0 rounded-xl border border-neutral-300 p-6">
<div className="text-neutral-500">차트 λ‘œλ”©μ€‘...</div>
</div>
);
return <ChartCardSkeleton variant="bars" controls titleWidth="w-40" subtitleWidth="w-56" />;
}

if (isError) {
Expand All @@ -58,7 +53,6 @@ export function ChurnDelta() {
</p>
</div>

{/* κΈ°κ°„ 선택 */}
<div className="flex gap-2">
<button
onClick={() => setRange(9)}
Expand Down Expand Up @@ -86,27 +80,24 @@ export function ChurnDelta() {
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid stroke="#eee" strokeDasharray="3 3" />

<XAxis dataKey="date" tickFormatter={(v) => String(v).slice(5).replace("-", "/")} />

<XAxis
dataKey="date"
tickFormatter={(value) => String(value).slice(5).replace("-", "/")}
/>
<YAxis domain={["dataMin - 1", "dataMax + 1"]} />

{/* κΈ°μ€€μ„  */}
<ReferenceLine y={0} stroke="#9ca3af" />

<Tooltip
formatter={(value) => {
const v = Number(value ?? 0);
return [`${v > 0 ? "+" : ""}${v}λͺ…`, "증감"];
const parsed = Number(value ?? 0);
return [`${parsed > 0 ? "+" : ""}${parsed}λͺ…`, "증감"];
}}
labelFormatter={(label) => `λ‚ μ§œ: ${label}`}
/>

<Bar dataKey="delta">
{chartData.map((d, idx) => (
{chartData.map((item, index) => (
<Cell
key={idx}
fill={d.delta > 0 ? COLOR_POS : d.delta < 0 ? COLOR_NEG : COLOR_ZERO}
key={index}
fill={item.delta > 0 ? COLOR_POS : item.delta < 0 ? COLOR_NEG : COLOR_ZERO}
/>
))}
</Bar>
Expand Down
25 changes: 9 additions & 16 deletions src/components/domain/churn/ChurnTotal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,21 @@ import {
YAxis,
} from "recharts";

import { ChartCardSkeleton } from "@/components/common/skeletons/ChartCardSkeleton";
import { useChurnTrend } from "@/lib/tanstack/query/churn/useChurnTrend";

export function ChurnTotal() {
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,
riskCount: d.riskCount,
const chartData = rawData.slice(-range).map((item) => ({
date: item.date,
riskCount: item.riskCount,
}));

if (isLoading) {
return (
<div className="bg-neutral-0 rounded-xl border border-neutral-300 p-6">
<div className="text-neutral-500">차트 λ‘œλ”©μ€‘...</div>
</div>
);
return <ChartCardSkeleton variant="line" controls titleWidth="w-40" subtitleWidth="w-36" />;
}

if (isError) {
Expand All @@ -50,7 +45,6 @@ export function ChurnTotal() {
<p className="text-sm text-neutral-500">일자 별 λˆ„μ  μœ„ν—˜ 고객 수</p>
</div>

{/* κΈ°κ°„ 선택 */}
<div className="flex gap-2">
<button
onClick={() => setRange(9)}
Expand Down Expand Up @@ -78,16 +72,15 @@ export function ChurnTotal() {
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid stroke="#eee" strokeDasharray="3 3" />

<XAxis dataKey="date" tickFormatter={(v) => String(v).slice(5).replace("-", "/")} />

<XAxis
dataKey="date"
tickFormatter={(value) => String(value).slice(5).replace("-", "/")}
/>
<YAxis domain={[0, "dataMax + 1"]} />

<Tooltip
formatter={(value) => [`${Number(value ?? 0)}λͺ…`, "μœ„ν—˜ 고객 수"]}
labelFormatter={(label) => `λ‚ μ§œ: ${label}`}
/>

<Line
type="monotone"
dataKey="riskCount"
Expand Down
9 changes: 6 additions & 3 deletions src/components/domain/churn/feed/ChurnFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useQuery, useQueryClient } from "@tanstack/react-query";

import { FeedCardSkeleton } from "@/components/common/skeletons/FeedCardSkeleton";
import { useChurnLatest } from "@/lib/tanstack/query/churn/useChurnLatest";
import type {
ChurnLatestItem,
Expand Down Expand Up @@ -98,6 +99,10 @@ export function ChurnFeed() {
},
});

if (isLoading) {
return <FeedCardSkeleton />;
}

return (
<div className="bg-neutral-0 flex flex-col gap-4 rounded-xl border border-neutral-300 p-6">
<h3 className="text-lg font-semibold text-neutral-900">μ‹€μ‹œκ°„ μ΄νƒˆ 고객 ν”Όλ“œ</h3>
Expand All @@ -106,9 +111,7 @@ export function ChurnFeed() {
<div className="h-full w-px bg-neutral-300"></div>

<div className="w-full overflow-auto py-6">
{isLoading ? (
<div className="px-4 text-sm text-neutral-500">λΆˆλŸ¬μ˜€λŠ” 쀑...</div>
) : isError ? (
{isError ? (
<div className="px-4 text-sm text-neutral-500">
μ΄νƒˆ 고객 ν”Όλ“œλ₯Ό λΆˆλŸ¬μ˜€μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.
</div>
Expand Down
Loading
Loading