Skip to content
Merged
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
10 changes: 1 addition & 9 deletions src/components/admin/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,8 @@ export default function AdminDashboard() {
);

return (
<div className="h-screen overflow-y-auto bg-gray-50 p-6">
<div className="h-screen overflow-y-auto bg-white p-6">
<div className="mx-auto max-w-5xl space-y-6">
{/* 헤더 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
백오피스 관리자 페이지
</h1>
<p className="text-gray-600">콘텐츠 현황 및 관리</p>
</div>

{/* 콘텐츠 분포 차트 */}
<div className="w-full flex justify-center">
<div className="w-full max-w-5xl">
Expand Down
15 changes: 5 additions & 10 deletions src/components/admin/userManagement/GenreBarChart.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { GenreFeedback } from '@type/admin/user';
import { getGenreLabel } from '@utils/admin/genres';
import { mergeGenreFeedbacks } from '@utils/admin/genres';
import {
BarChart,
Bar,
Expand All @@ -25,15 +25,10 @@ interface GenreBarChartProps {
}

export default function GenreBarChart({ genres }: GenreBarChartProps) {
const chartData = genres
.map((genre) => ({
...genre,
genreName: getGenreLabel(genre.genreType),
total: genre.likeCount + genre.dislikeCount + genre.uninterestedCount,
}))
.filter((g) => g.total > 0) // 총합 0인 항목 제거
.sort((a, b) => b.total - a.total) // 총합 기준 내림차순 정렬
.slice(0, 10); // 상위 10개 항목만 시각화
const chartData = mergeGenreFeedbacks(genres)
.filter((g) => g.total > 0)
.sort((a, b) => b.total - a.total)
.slice(0, 10);

//튤팁을 통한 실제 값 자세히보기 컴포넌트
const CustomTooltip = ({
Expand Down
9 changes: 2 additions & 7 deletions src/components/admin/userManagement/GenreList.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
'use client';

import { GenreFeedback } from '@type/admin/user';
import { getGenreLabel } from '@utils/admin/genres';
import { mergeGenreFeedbacks } from '@utils/admin/genres';

interface GenreListProps {
genres: GenreFeedback[];
}

//전체 목록 차트
export default function GenreList({ genres }: GenreListProps) {
const chartData = genres
.map((g) => ({
...g,
genreName: getGenreLabel(g.genreType),
total: g.likeCount + g.dislikeCount + g.uninterestedCount,
}))
const chartData = mergeGenreFeedbacks(genres)
.filter((g) => g.total > 0)
.sort((a, b) => b.total - a.total);

Expand Down
12 changes: 9 additions & 3 deletions src/components/admin/userManagement/GenrePieChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
import { useMemo } from 'react';
import { getGenreColor, getGenreLabel } from '@utils/admin/genres';
import {
getGenreColor,
getGenreLabel,
mergeGenreFeedbacks,
} from '@utils/admin/genres';
import { GenreFeedback } from '@type/admin/user';

// 좋아요,싫어요 분포 차트
Expand All @@ -14,8 +18,10 @@ interface GenrePieChartProps {
}

export default function GenrePieChart({ genres, type }: GenrePieChartProps) {
const mergedGenres = useMemo(() => mergeGenreFeedbacks(genres), [genres]);

const filtered = useMemo(() => {
return genres
return mergedGenres
.filter((g) => {
const count = type === 'like' ? g.likeCount : g.dislikeCount;
return count > 0;
Expand All @@ -26,7 +32,7 @@ export default function GenrePieChart({ genres, type }: GenrePieChartProps) {
return bVal - aVal;
})
.slice(0, 10);
}, [genres, type]);
}, [mergedGenres, type]);

const total = filtered.reduce(
(sum, g) => sum + (type === 'like' ? g.likeCount : g.dislikeCount),
Expand Down
7 changes: 4 additions & 3 deletions src/components/admin/userManagement/SummaryStats.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ✅ components/chart/SummaryStats.tsx
'use client';

import { mergeGenreFeedbacks } from '@utils/admin/genres';
import { UserDetail } from '@type/admin/user';

interface SummaryStatsProps {
Expand All @@ -13,9 +14,9 @@ export default function SummaryStats({ userDetail }: SummaryStatsProps) {
userDetail;

// 활동한 장르 수 계산 (합이 0보다 큰 장르만 카운트)
const totalGenres = genres.filter(
(g) => g.likeCount + g.dislikeCount + g.uninterestedCount > 0,
).length;
const mergedGenres = mergeGenreFeedbacks(genres);
// 활동한 장르 수 (합계가 0 초과인 것만 카운트)
const totalGenres = mergedGenres.filter((g) => g.total > 0).length;

return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4 ">
Expand Down
12 changes: 1 addition & 11 deletions src/components/admin/userManagement/UserList.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
'use client';

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@components/ui/card';
import UserCard from '@components/admin/userManagement/UserCard';
import { User } from '@type/admin/user';

Expand Down Expand Up @@ -34,10 +28,6 @@ export default function UserList({
<CardTitle className="text-xl font-semibold text-gray-900">
등록된 회원 목록
</CardTitle>
<CardDescription className="text-gray-500">
{/*나중에 count int로 주면 그때 타입 수정 및 아래 받는 값 수정 */}
전체 {users.length}명의 회원
</CardDescription>
</div>
</CardHeader>

Expand Down
80 changes: 61 additions & 19 deletions src/components/admin/userManagement/UserManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import { useInfiniteUsers } from '@hooks/admin/useInfiniteScroll';
import { Label } from '@components/ui/label';
import { Input } from '@components/ui/input';
import { User } from '@type/admin/user';
import { postFeedbackFullScan } from '@lib/apis/admin/postFeedbackFullScan';
import { Button } from '@components/ui/button';
import { formatDateHour } from '@utils/admin/formatDate';

//유저 전체 흐름 관라(검색 + 리스트 + 상세보기)

export default function UserManagement() {
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [lastSyncedAt, setLastSyncedAt] = useState<Date | null>(null);

const {
users,
Expand All @@ -24,6 +28,30 @@ export default function UserManagement() {
fetchNextPage,
} = useInfiniteUsers(searchKeyword);

const [isSyncing, setIsSyncing] = useState(false);

useEffect(() => {
const stored = localStorage.getItem('lastSyncedAt');
if (stored) {
setLastSyncedAt(new Date(stored));
}
}, []);

const handleManualSync = async () => {
try {
setIsSyncing(true);
await postFeedbackFullScan();
const now = new Date();
setLastSyncedAt(now);
localStorage.setItem('lastSyncedAt', now.toISOString()); // ✅ 저장
alert('피드백 동기화가 완료되었습니다.');
} catch {
alert('동기화에 실패했습니다.');
} finally {
setIsSyncing(false);
}
};

useEffect(() => {
if (!hasNextPage || isFetching) return;

Expand Down Expand Up @@ -52,27 +80,41 @@ export default function UserManagement() {
}, []);

return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="min-h-screen bg-white p-6">
<div className="mx-auto max-w-6xl space-y-6">
{/* 헤더 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
회원 정보 관리
</h1>
<p className="text-gray-600">
회원 현황 및 유저별 선호 장르 통계 확인
</p>
</div>
{/* 키워드 검색*/}
<div className="flex justify-end items-center gap-2 mb-4">
<Label htmlFor="user-search">검색:</Label>
<Input
id="user-search"
placeholder="이름 또는 이메일"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-[240px]"
/>
<div className="flex justify-between items-center gap-2 mb-4">
{/* 왼쪽: 동기화 버튼 */}

<div className="flex justify-between items-center gap-2">
<Button
onClick={handleManualSync}
disabled={isSyncing}
variant="default"
className="text-sm"
>
{isSyncing ? '동기화 중...' : '데이터 동기화'}
</Button>
{lastSyncedAt && (
<span className="text-xs text-gray-500 mt-1">
최근 수동 업데이트 시간:{' '}
<span className="text-blue-600 font-medium">
{formatDateHour(lastSyncedAt.toISOString())}
</span>
</span>
)}
</div>
{/* 오른쪽: 검색 입력창 */}
<div className="flex items-center gap-2">
<Label htmlFor="user-search">검색:</Label>
<Input
id="user-search"
placeholder="이름 또는 이메일"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-[240px]"
/>
</div>
</div>

{/* 유저 목록 */}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/apis/admin/postFeedbackFullScan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import axiosInstance from '@lib/apis/axiosInstance';

export const postFeedbackFullScan = async (): Promise<void> => {
await axiosInstance.post('/api/admin/feedback/full-scan');
};
5 changes: 5 additions & 0 deletions src/types/admin/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,8 @@ export type adminGenre = {
label: string;
id: string;
};

export interface WithTotalGenreFeedback extends GenreFeedback {
genreName: string;
total: number;
}
43 changes: 42 additions & 1 deletion src/utils/admin/genres.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { adminGenre } from '@type/admin/user';
import {
adminGenre,
GenreFeedback,
WithTotalGenreFeedback,
} from '@type/admin/user';

//장르 목록 정의
export const GENRES: adminGenre[] = [
Expand Down Expand Up @@ -71,3 +75,40 @@ export const GENRE_COLOR_MAP: Record<string, string> = {

export const getGenreColor = (raw: string) =>
GENRE_COLOR_MAP[raw.toUpperCase().replace(/-/g, '_')] || '#CCCCCC';

export const mergeGenreFeedbacks = (
genres: GenreFeedback[],
): WithTotalGenreFeedback[] => {
const mergedMap = new Map<string, WithTotalGenreFeedback>();

for (const genre of genres) {
const genreName = getGenreLabel(genre.genreType); // 예: '코미디'

const existing = mergedMap.get(genreName);

if (existing) {
mergedMap.set(genreName, {
genreType: genre.genreType, // 그냥 하나만 유지
genreName,
likeCount: existing.likeCount + genre.likeCount,
dislikeCount: existing.dislikeCount + genre.dislikeCount,
uninterestedCount: existing.uninterestedCount + genre.uninterestedCount,
total:
existing.likeCount +
genre.likeCount +
existing.dislikeCount +
genre.dislikeCount +
existing.uninterestedCount +
genre.uninterestedCount,
});
} else {
mergedMap.set(genreName, {
...genre,
genreName,
total: genre.likeCount + genre.dislikeCount + genre.uninterestedCount,
});
}
}

return Array.from(mergedMap.values());
};