"use client"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { DateRangeEnum } from "@/app/constants/dateRange"; import { useSession, signIn } from "next-auth/react"; import CalenderSelector from "@/app/components/CalenderSelector"; import SvgChannelFilter from "@/app/components/svgs/svgChannelFilter"; import ChannelFilter from "@/app/components/ChannelFilter"; // App.tsx import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, } from "chart.js"; import { Line } from "react-chartjs-2"; // Chart.js 모듈 등록 (꼭 필요) ChartJS.register( CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend ); type ListChannelRawItem = { id: string; email?: string; handle: string; isApproved: boolean; createtime: string; icon?: string; }; type ListChannelItem = { id: string; email?: string; handle: string; createtime: string; is_approved: boolean; icon?: string; }; export default function Page({user}: {user: any}) { const [myChannelList, setMyChannelList] = useState([]); const [visibleChannelIds, setVisibleChannelIds] = useState>(new Set()); const [channelsReady, setChannelsReady] = useState(false); const fetchListChannel = useCallback(async (options?: { cache?: RequestCache; signal?: AbortSignal; }): Promise => { const response = await fetch('/api/channel/list', { cache: options?.cache ?? 'no-store', signal: options?.signal, }); console.log('list_channel response:', response); if (!response.ok) { let message = ''; try { message = await response.text(); } catch {} throw new Error(`list_channel 요청 실패: ${response.status} ${message}`); } const data = (await response.json()) as { items: ListChannelRawItem[] } | unknown; const items = (data as { items: ListChannelRawItem[] })?.items ?? []; return items.map((item) => ({ id: item.id, email: item.email, handle: item.handle, createtime: item.createtime, is_approved: item.isApproved, icon: item.icon, })); }, []); const [chanellist, setChanellist] = useState([]); const [contentlist, setContentlist] = useState<{ id: string; subject: string; pubDate: Date; views: number; validViews: number; premiumViews: number; watchTime: number; publisher?: string; }[]>([]); const [selectedChannels, setSelectedChannels] = useState(chanellist); const [loading, setLoading] = useState(false); const [dateRange, setDateRange] = useState<{ startDate: Date; endDate: Date; }>({ startDate: new Date(), endDate: new Date() }); const [dateString, setDateString] = useState("2025.03.01 ~ 2025.03.31"); const chartRef = useRef(null); const [hasRun, setHasRun] = useState(false); const [calendarOpen, setCalendarOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false); // 전역 상수는 공용 파일에서 import const [globalDateRangeType, setGlobalDateRangeType] = useState(DateRangeEnum.ALL); const [dateRangeBlock1, setDateRangeBlock1] = useState(DateRangeEnum.ALL); const [dateRangeBlock2, setDateRangeBlock2] = useState(DateRangeEnum.ALL); const [dateRangeBlock3, setDateRangeBlock3] = useState(DateRangeEnum.ALL); const [dateRangeBlock4, setDateRangeBlock4] = useState(DateRangeEnum.ALL); const [dateRangeGraph, setDateRangeGraph] = useState(DateRangeEnum.ALL); const [startDate, setStartDate] = useState(new Date(Date.now()-30*24*60*60*1000)); const [endDate, setEndDate] = useState(new Date()); const [cpv, setCpv] = useState(0); const [rows, setRows] = useState>([]); const [checkedIds, setCheckedIds] = useState>(new Set()); const [sortKey, setSortKey] = useState<'handle'|'subject'|'pubDate'|'watchTime'|'views'|'premiumViews'|'validViews'|'expectedRevenue'>('pubDate'); const [sortDir, setSortDir] = useState<'asc'|'desc'>('desc'); const handleSort = useCallback((key: 'handle'|'subject'|'pubDate'|'watchTime'|'views'|'premiumViews'|'validViews'|'expectedRevenue') => { setSortDir(prev => (sortKey === key ? (prev === 'asc' ? 'desc' : 'asc') : 'desc')); setSortKey(key); }, [sortKey]); // 그래프에 표시할 선택 지표 const [selectedMetric, setSelectedMetric] = useState<'views'|'validViews'|'premiumViews'|'expectedRevenue'>('validViews') const fetchMyContents = useCallback(async (s: Date, e: Date) => { const qs = `start=${encodeURIComponent(s.toISOString())}&end=${encodeURIComponent(e.toISOString())}`; const res = await fetch(`/api/contents/mycontent?${qs}`, { cache: 'no-store' }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? 'LOAD_FAILED'); const items = data.items ?? []; setCpv(data.cpv ?? 0); console.log(items) setRows(items); // 초기 로드 시 전체 선택 setCheckedIds(new Set(items.map((it: any) => it.id))); }, []); const data = { labels: ["Jan", "Feb", "Mar", "Apr", "May"], datasets: [ { label: "", data: [120000, 150000, 200000, 190000, 230000], borderColor: "#F94B37", // 새로운 색상 적용 backgroundColor: "rgba(249, 75, 55, 0.5)", // 배경색도 변경 }, ], }; const options = { responsive: true, plugins: { legend: { display: false }, title: { display: false, text: "정산 수익 추이" }, }, scales: { x: { grid: { display: false, }, }, y: { grid: { display: true, }, ticks: { stepSize: 20000, }, }, }, elements: { line: { tension: 0.4, // 곡선으로 변경 }, }, }; useEffect(() => { if (!hasRun) { if (chartRef.current) { setHasRun(true); chartRef.current.style.width = '10px'; setTimeout(() => { chartRef.current!.style.width = '100%'; }, 100); } } }, [hasRun, chartRef.current]); const handleCheckboxChange = (channel: string) => { setSelectedChannels(prev => prev.includes(channel) ? prev.filter(c => c !== channel) : [...prev, channel] ); }; const filteredContentList = contentlist.filter(content => selectedChannels.length === 0 || (content.publisher && selectedChannels.includes(content.publisher)) ); const formatNumberWithCommas = (number: number): string => { return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); }; useEffect(() => { const handleResize = () => { window.resizeTo(500, 500); setTimeout(() => { window.resizeTo(window.screen.availWidth, window.screen.availHeight); }, 1000); }; handleResize(); }, []); // 내 채널 목록: 최초 1회만 호출 useEffect(() => { (async () => { try { const items = await fetchListChannel({ cache: 'no-store' }); console.log('list_channel:', items); setMyChannelList(items); // 초기에는 전체 채널 가시 setVisibleChannelIds(new Set(items.map(i => i.id))); setChannelsReady(true); } catch (e) { console.error('list_channel 요청 에러:', e); } })(); }, [fetchListChannel]); if (loading) { return
Loading...
; } // 빈 집합은 "아무 채널도 선택되지 않음"으로 간주하여 행을 숨깁니다 const visibleRows = rows.filter(r => visibleChannelIds.size > 0 && r.handleId && visibleChannelIds.has(r.handleId)); console.log(visibleRows) const sortedVisibleRows = useMemo(() => { const copy = [...visibleRows]; const factor = sortDir === 'asc' ? 1 : -1; copy.sort((a,b) => { let va: any, vb: any; switch (sortKey) { case 'pubDate': va = new Date(a.pubDate).getTime(); vb = new Date(b.pubDate).getTime(); break; case 'watchTime': va = a.watchTime; vb = b.watchTime; break; case 'views': va = a.views; vb = b.views; break; case 'premiumViews': va = a.premiumViews; vb = b.premiumViews; break; case 'validViews': va = a.validViews; vb = b.validViews; break; case 'expectedRevenue': va = a.expectedRevenue; vb = b.expectedRevenue; break; case 'handle': va = a.handle; vb = b.handle; break; case 'subject': va = a.subject; vb = b.subject; break; default: va = 0; vb = 0; } if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * factor; return ((va as number) - (vb as number)) * factor; }); return copy; }, [visibleRows, sortKey, sortDir]); const allVisibleChecked = visibleRows.length > 0 && visibleRows.every(r => checkedIds.has(r.id)); const totals = useMemo(() => { let views = 0, premium = 0, valid = 0, revenue = 0; for (const r of sortedVisibleRows) { if (checkedIds.has(r.id)) { views += r.views; premium += r.premiumViews; valid += r.validViews; revenue += r.expectedRevenue; } } return { views, premium, valid, revenue: Math.round(revenue) }; }, [sortedVisibleRows, checkedIds]); // 그래프 데이터/옵션 const metricLabel: Record<'views'|'validViews'|'premiumViews'|'expectedRevenue', string> = { views: '총 조회수', validViews: '유효 조회수', premiumViews: '프리미엄 조회수', expectedRevenue: '예상 수익', } const metricColor: Record<'views'|'validViews'|'premiumViews'|'expectedRevenue', { border: string, bg: string }> = { views: { border: '#1f6feb', bg: 'rgba(31, 111, 235, 0.25)' }, validViews: { border: '#F94B37', bg: 'rgba(249, 75, 55, 0.25)' }, premiumViews: { border: '#7C3AED', bg: 'rgba(124, 58, 237, 0.25)' }, expectedRevenue: { border: '#16A34A', bg: 'rgba(22, 163, 74, 0.25)' }, } const selectedRows = useMemo(() => sortedVisibleRows.filter(r => checkedIds.has(r.id)), [sortedVisibleRows, checkedIds]) const selectedIdsKey = useMemo(() => { const ids = Array.from(checkedIds) if (ids.length === 0) return '' ids.sort() return ids.join(',') }, [checkedIds]) const [seriesMode, setSeriesMode] = useState<'items'|'series'>('series') const [seriesInterval, setSeriesInterval] = useState<'day'|'week'|'month'>('day') const [seriesItems, setSeriesItems] = useState>([]) const [seriesLoading, setSeriesLoading] = useState(false) const chartData = useMemo(() => { const col = metricColor[selectedMetric] if (seriesMode === 'series') { const labels = seriesItems.map(it => it.period) const dataVals = seriesItems.map(it => (it as any)[selectedMetric] as number) return { labels, datasets: [{ label: `${metricLabel[selectedMetric]} (${seriesInterval})`, data: dataVals, borderColor: col.border, backgroundColor: col.bg, tension: 0.35 }] } } const labels = selectedRows.map(r => r.subject) const dataVals = selectedRows.map(r => (r as any)[selectedMetric] as number) return { labels, datasets: [{ label: metricLabel[selectedMetric], data: dataVals, borderColor: col.border, backgroundColor: col.bg, tension: 0.35 }] } }, [selectedRows, selectedMetric, seriesMode, seriesItems, seriesInterval]) const chartOptions = useMemo(() => ({ responsive: true, plugins: { legend: { display: false }, title: { display: false } }, scales: { x: { grid: { display: false } }, y: { grid: { display: true } } }, elements: { line: { tension: 0.35 } }, }), []) // 시리즈 모드일 때 옵션/기간 변경 시 자동 데이터 로드 useEffect(() => { if (seriesMode !== 'series') return; const s = startDate; const e = endDate; const qs = new URLSearchParams(); qs.set('start', s.toISOString()); qs.set('end', e.toISOString()); qs.set('mode', 'series'); qs.set('interval', seriesInterval); if (selectedIdsKey) qs.set('contentIds', selectedIdsKey); let cancelled = false; setSeriesLoading(true); (async () => { try { const res = await fetch(`/api/contents/mycontent?${qs.toString()}`, { cache: 'no-store' }); const data = await res.json(); if (res.ok && !cancelled) setSeriesItems(data.items || []); } catch (e) { if (!cancelled) console.error(e); } finally { if (!cancelled) setSeriesLoading(false); } })(); return () => { cancelled = true; }; }, [seriesMode, seriesInterval, startDate, endDate, selectedIdsKey]); // 시리즈 모드 해제 시 스피너 숨김 보장 useEffect(() => { if (seriesMode !== 'series') setSeriesLoading(false); }, [seriesMode]); // 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제하되 // 초기 로딩 등으로 가시 채널이 비어있을 땐 해제하지 않음. // 가시 채널이 채워지고 선택이 비어있으면 전체 선택으로 초기화. useEffect(() => { if (visibleChannelIds.size === 0) return; const visibleIdSet = new Set(visibleRows.map(r => r.id)); setCheckedIds(prev => { if (prev.size === 0) return visibleIdSet; // 기본 전체 선택 const next = new Set(Array.from(prev).filter(id => visibleIdSet.has(id))); return next; }); }, [visibleChannelIds, rows]); return (
{myChannelList.length === 0 ? (
등록된 채널이 없습니다.
) : visibleChannelIds.size === 0 ? (
선택된 채널이 없습니다. 오른쪽 필터에서 채널을 선택하세요.
) : ( myChannelList.filter(c => visibleChannelIds.has(c.id)).map((channel, index) => (
{channel.icon ? ( channel icon ) : (
)}
{channel.handle}
)) )}
{ setCalendarOpen(open); if (open) setFilterOpen(false); }} onRangeChange={(s,e)=>{ setStartDate(s); setEndDate(e); setDateString(`${s.toISOString().slice(0,10)} ~ ${e.toISOString().slice(0,10)}`); fetchMyContents(s,e).catch(console.error); }} />
setVisibleChannelIds(new Set(next))} isOpen={filterOpen} onOpenChange={(open)=>{ setFilterOpen(open); if (open) setCalendarOpen(false); }} />
{visibleChannelIds.size === 0 && ( )} {visibleChannelIds.size > 0 && visibleRows.length === 0 && ( )} {sortedVisibleRows.map((r) => ( ))} {sortedVisibleRows.length === 1 && ( )}
{ const next = new Set(checkedIds); if (allVisibleChecked) { visibleRows.forEach(r => next.delete(r.id)); } else { visibleRows.forEach(r => next.add(r.id)); } setCheckedIds(next); }} /> handleSort('handle')}>핸들 {sortDir==='asc'?'▲':'▼'} handleSort('pubDate')}>게시일 {sortDir==='asc'?'▲':'▼'} handleSort('subject')}>영상 제목 {sortDir==='asc'?'▲':'▼'} handleSort('watchTime')}>시청시간 {sortDir==='asc'?'▲':'▼'} handleSort('views')}>조회수 {sortDir==='asc'?'▲':'▼'} handleSort('premiumViews')}>premium 조회수 {sortDir==='asc'?'▲':'▼'} handleSort('validViews')}>유효 조회수 {sortDir==='asc'?'▲':'▼'} handleSort('expectedRevenue')}>예상수익 {sortDir==='asc'?'▲':'▼'}
선택된 채널이 없습니다. 채널 필터에서 채널을 선택하세요.
표시할 데이터가 없습니다.
{ const next = new Set(checkedIds); if (e.target.checked) next.add(r.id); else next.delete(r.id); setCheckedIds(next); }} />
{r.icon && icon} {r.handle}
{new Date(r.pubDate).toISOString().slice(0,10)} {r.subject} {formatNumberWithCommas(r.watchTime)} {formatNumberWithCommas(r.views)} {formatNumberWithCommas(r.premiumViews)} {formatNumberWithCommas(r.validViews)} {formatNumberWithCommas(Math.round(r.expectedRevenue))}
setSelectedMetric('views')} >
총 조회수
{/*
*/}
{formatNumberWithCommas(totals.views)}
{/*
+ 0.24%
*/}
setSelectedMetric('premiumViews')} >
프리미엄 조회수
{/*
*/}
{formatNumberWithCommas(totals.premium)}
{/*
+ 0.24%
*/}
setSelectedMetric('validViews')} >
유효 조회수
{/*
*/}
{formatNumberWithCommas(totals.valid)}
{/*
+ 0.24%
*/}
setSelectedMetric('expectedRevenue')} >
예상 수익
{/*
*/}
{formatNumberWithCommas(totals.revenue)}
{/*
- 1.24%
*/}
{metricLabel[selectedMetric]} 추이
{seriesMode === 'series' && ( )}
{formatNumberWithCommas( selectedMetric === 'views' ? totals.views : selectedMetric === 'validViews' ? totals.valid : selectedMetric === 'premiumViews' ? totals.premium : totals.revenue )}
{/*
+ 0.24%
*/}
{seriesMode === 'series' && seriesLoading ? (
) : ( )}
); }