캘린더 수정정

This commit is contained in:
2025-10-15 12:17:36 +00:00
parent 0ab4b037eb
commit 9c0aeb73e8
3 changed files with 157 additions and 38 deletions

View File

@@ -4,11 +4,18 @@ import Arrow from "./svgs/arrow";
import { DateRangeEnum } from "@/app/constants/dateRange"; import { DateRangeEnum } from "@/app/constants/dateRange";
import { useState,useEffect } from "react"; import { useState,useEffect } from "react";
export default function CalenderSelector( {dateString, is_small, onRangeChange}: {dateString: string, is_ri: boolean, is_small: boolean, onRangeChange?: (start: Date, end: Date) => void} ) { export default function CalenderSelector({ dateString, is_small, onRangeChange, isOpen: controlledOpen, onOpenChange }: { dateString: string, is_ri: boolean, is_small: boolean, onRangeChange?: (start: Date, end: Date) => void, isOpen?: boolean, onOpenChange?: (open: boolean) => void }) {
const [isOpen, setIsOpen] = useState(false); const CUSTOM = -1; // 커스텀(년/월) 선택 표기용
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = typeof controlledOpen === 'boolean' ? controlledOpen : internalOpen;
const setOpen = (next: boolean) => { if (onOpenChange) onOpenChange(next); else setInternalOpen(next); };
const [selected, setSelected] = useState<number>(DateRangeEnum.ONE_MONTH); const [selected, setSelected] = useState<number>(DateRangeEnum.ONE_MONTH);
const [rangeStart, setRangeStart] = useState<Date>(new Date()); const [rangeStart, setRangeStart] = useState<Date>(new Date());
const [rangeEnd, setRangeEnd] = useState<Date>(new Date()); const [rangeEnd, setRangeEnd] = useState<Date>(new Date());
const [yearInput, setYearInput] = useState<string>('');
const [monthInput, setMonthInput] = useState<string>('');
const [customLabel, setCustomLabel] = useState<string>('');
const [isCustomActive, setIsCustomActive] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
@@ -16,6 +23,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
setRangeStart(startDate); setRangeStart(startDate);
setRangeEnd(endDate); setRangeEnd(endDate);
onRangeChange?.(startDate, endDate); onRangeChange?.(startDate, endDate);
const now = new Date();
setYearInput(String(now.getFullYear()));
setMonthInput(String(now.getMonth() + 1).padStart(2,'0'));
}, []); }, []);
return ( return (
@@ -27,7 +37,7 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
border-1 rounded-lg flex flex-row items-center justify-between gap-2 px-2 cursor-pointer`} border-1 rounded-lg flex flex-row items-center justify-between gap-2 px-2 cursor-pointer`}
onClick={() => { onClick={() => {
console.log('click'); console.log('click');
setIsOpen(!isOpen); setOpen(!isOpen);
}} }}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -38,13 +48,15 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
)} )}
</div> </div>
<div className={`flex-1 min-w-0 font-semibold ${is_small ? 'text-xs':'text-sm'}`}> <div className={`flex-1 min-w-0 font-semibold ${is_small ? 'text-xs':'text-sm'}`}>
{selected === DateRangeEnum.ONE_MONTH&& '최근 1개월'} {customLabel || (
{selected === DateRangeEnum.ONE_WEEK&& '최근 1주일'} selected === DateRangeEnum.ONE_MONTH ? '최근 1개월' :
{selected === DateRangeEnum.TWO_MONTHS&& '최근 2개월'} selected === DateRangeEnum.ONE_WEEK ? '최근 1주일' :
{selected === DateRangeEnum.THREE_MONTHS&& '최근 3개월'} selected === DateRangeEnum.TWO_MONTHS ? '최근 2개월' :
{selected === DateRangeEnum.SIX_MONTHS&& '최근 6개월'} selected === DateRangeEnum.THREE_MONTHS ? '최근 3개월' :
{selected === DateRangeEnum.ONE_YEAR&& '최근 1년'} selected === DateRangeEnum.SIX_MONTHS ? '최근 6개월' :
{selected === DateRangeEnum.ALL&& '전체'} selected === DateRangeEnum.ONE_YEAR ? '최근 1년' :
selected === DateRangeEnum.ALL ? '전체' : ''
)}
</div> </div>
<div className={`pt-[3px] transition-transform ${isOpen ? 'rotate-180' : ''} flex-shrink-0`}> <div className={`pt-[3px] transition-transform ${isOpen ? 'rotate-180' : ''} flex-shrink-0`}>
<Arrow color="#A4A0A0" width={is_small ? 12 : 18} height={is_small ? 8 : 12} /> <Arrow color="#A4A0A0" width={is_small ? 12 : 18} height={is_small ? 8 : 12} />
@@ -59,11 +71,13 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
onClick={() => { onClick={() => {
setSelected(DateRangeEnum.ALL); setSelected(DateRangeEnum.ALL);
const end = new Date(); const end = new Date();
const start = new Date(0); const start = new Date(Date.UTC(2025, 0, 1));
setRangeStart(start); setRangeStart(start);
setRangeEnd(end); setRangeEnd(end);
onRangeChange?.(start, end); onRangeChange?.(start, end);
setIsOpen(false); setCustomLabel('');
setIsCustomActive(false);
setOpen(false);
}} }}
> >
<Realtime color={selected === DateRangeEnum.ALL ? '#F94B37' : '#848484'} width={16} height={16} /> <Realtime color={selected === DateRangeEnum.ALL ? '#F94B37' : '#848484'} width={16} height={16} />
@@ -79,7 +93,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
setRangeStart(start); setRangeStart(start);
setRangeEnd(end); setRangeEnd(end);
onRangeChange?.(start, end); onRangeChange?.(start, end);
setIsOpen(false); setCustomLabel('');
setIsCustomActive(false);
setOpen(false);
}} }}
> >
<OneMonth color={selected === DateRangeEnum.ONE_WEEK ? '#F94B37' : '#848484'} width={16} height={16} /> <OneMonth color={selected === DateRangeEnum.ONE_WEEK ? '#F94B37' : '#848484'} width={16} height={16} />
@@ -95,7 +111,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
setRangeStart(start); setRangeStart(start);
setRangeEnd(end); setRangeEnd(end);
onRangeChange?.(start, end); onRangeChange?.(start, end);
setIsOpen(false); setCustomLabel('');
setIsCustomActive(false);
setOpen(false);
}} }}
> >
<OneMonth color={selected === DateRangeEnum.ONE_MONTH ? '#F94B37' : '#848484'} width={16} height={16} /> <OneMonth color={selected === DateRangeEnum.ONE_MONTH ? '#F94B37' : '#848484'} width={16} height={16} />
@@ -111,7 +129,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
setRangeStart(start); setRangeStart(start);
setRangeEnd(end); setRangeEnd(end);
onRangeChange?.(start, end); onRangeChange?.(start, end);
setIsOpen(false); setCustomLabel('');
setIsCustomActive(false);
setOpen(false);
}} }}
> >
<OneMonth color={selected === DateRangeEnum.TWO_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} /> <OneMonth color={selected === DateRangeEnum.TWO_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
@@ -127,7 +147,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
setRangeStart(start); setRangeStart(start);
setRangeEnd(end); setRangeEnd(end);
onRangeChange?.(start, end); onRangeChange?.(start, end);
setIsOpen(false); setCustomLabel('');
setIsCustomActive(false);
setOpen(false);
}} }}
> >
<OneMonth color={selected === DateRangeEnum.THREE_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} /> <OneMonth color={selected === DateRangeEnum.THREE_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
@@ -143,7 +165,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
setRangeStart(start); setRangeStart(start);
setRangeEnd(end); setRangeEnd(end);
onRangeChange?.(start, end); onRangeChange?.(start, end);
setIsOpen(false); setCustomLabel('');
setIsCustomActive(false);
setOpen(false);
}} }}
> >
<OneMonth color={selected === DateRangeEnum.SIX_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} /> <OneMonth color={selected === DateRangeEnum.SIX_MONTHS ? '#F94B37' : '#848484'} width={16} height={16} />
@@ -159,7 +183,9 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
setRangeStart(start); setRangeStart(start);
setRangeEnd(end); setRangeEnd(end);
onRangeChange?.(start, end); onRangeChange?.(start, end);
setIsOpen(false); setCustomLabel('');
setIsCustomActive(false);
setOpen(false);
}} }}
> >
<OneMonth color={selected === DateRangeEnum.ONE_YEAR ? '#F94B37' : '#848484'} width={16} height={16} /> <OneMonth color={selected === DateRangeEnum.ONE_YEAR ? '#F94B37' : '#848484'} width={16} height={16} />
@@ -167,6 +193,52 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
</button> </button>
</div> </div>
<div className="mt-2 pt-2 border-t-1 border-border-pale">
<div className={`flex items-center gap-2 px-1 ${isCustomActive ? 'bg-[#FFF3F2] font-semibold rounded-md py-2' : ''}`}>
<input
type="number"
className="w-[88px] h-[32px] border-1 border-border-pale rounded px-2 pr-1 text-sm"
placeholder="YYYY"
min={2000}
max={2100}
value={yearInput}
onChange={(e)=> setYearInput(e.target.value.replace(/[^0-9]/g,''))}
/>
<span className="text-sm"></span>
<input
type="number"
className="w-[50px] h-[32px] border-1 border-border-pale rounded px-2 pr-1 text-sm"
placeholder="MM"
min={1}
max={12}
value={monthInput}
onChange={(e)=> {
const v = e.target.value.replace(/[^0-9]/g,'');
setMonthInput(v);
}}
/>
<span className="text-sm"></span>
<button
className="ml-auto h-[32px] min-w-[80px] px-4 shrink-0 whitespace-nowrap rounded-md bg-[#F94B37] border-1 border-[#D73B29] text-white text-sm font-semibold hover:bg-[#D73B29]"
onClick={() => {
const y = parseInt(yearInput, 10);
const m = parseInt(monthInput, 10);
if (!Number.isFinite(y) || y < 2000 || y > 2100) return;
if (!Number.isFinite(m) || m < 1 || m > 12) return;
const start = new Date(y, m - 1, 1);
const end = new Date(y, m, 0, 23, 59, 59, 999);
setRangeStart(start);
setRangeEnd(end);
onRangeChange?.(start, end);
const mm = String(m).padStart(2, '0');
setCustomLabel(`${y}.${mm}`);
setIsCustomActive(true);
setSelected(CUSTOM);
setOpen(false);
}}
></button>
</div>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -7,12 +7,18 @@ export default function ChanalFilter({
channel_list, channel_list,
visibleSet, visibleSet,
onChangeVisible, onChangeVisible,
isOpen: controlledOpen,
onOpenChange,
}:{ }:{
channel_list: Channel[]; channel_list: Channel[];
visibleSet: Set<string>; visibleSet: Set<string>;
onChangeVisible: (nextVisibleIds: string[]) => void; onChangeVisible: (nextVisibleIds: string[]) => void;
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const isOpen = typeof controlledOpen === 'boolean' ? controlledOpen : internalOpen;
const setOpen = (next: boolean) => { if (onOpenChange) onOpenChange(next); else setInternalOpen(next); };
const allIds = useMemo(() => channel_list.map(c => c.id), [channel_list]); const allIds = useMemo(() => channel_list.map(c => c.id), [channel_list]);
const allChecked = useMemo(() => allIds.every(id => visibleSet.has(id)) && allIds.length > 0, [allIds, visibleSet]); const allChecked = useMemo(() => allIds.every(id => visibleSet.has(id)) && allIds.length > 0, [allIds, visibleSet]);
@@ -45,7 +51,7 @@ export default function ChanalFilter({
<div className="relative"> <div className="relative">
<div className={`w-[36px] h-[36px] border-1 rounded-lg flex items-center justify-center cursor-pointer <div className={`w-[36px] h-[36px] border-1 rounded-lg flex items-center justify-center cursor-pointer
${isOpen ? 'border-[#F94B37] bg-[#FFF3F2]' : 'border-border-pale bg-white '} `} ${isOpen ? 'border-[#F94B37] bg-[#FFF3F2]' : 'border-border-pale bg-white '} `}
onClick={()=>{setIsOpen(!isOpen)}} onClick={()=>{setOpen(!isOpen)}}
> >
<SvgChannelFilter color={isOpen ? '#F94B37' : '#848484'} width={20} height={20} /> <SvgChannelFilter color={isOpen ? '#F94B37' : '#848484'} width={20} height={20} />
</div> </div>
@@ -83,7 +89,7 @@ export default function ChanalFilter({
</div> </div>
<button <button
className="mt-2 w-full h-[36px] rounded-md bg-[#F94B37] border-1 border-[#D73B29] text-white font-semibold hover:bg-[#D73B29]" className="mt-2 w-full h-[36px] rounded-md bg-[#F94B37] border-1 border-[#D73B29] text-white font-semibold hover:bg-[#D73B29]"
onClick={() => setIsOpen(false)} onClick={() => setOpen(false)}
> >
</button> </button>

View File

@@ -109,6 +109,8 @@ export default function Page({user}: {user: any}) {
const [dateString, setDateString] = useState<string>("2025.03.01 ~ 2025.03.31"); const [dateString, setDateString] = useState<string>("2025.03.01 ~ 2025.03.31");
const chartRef = useRef<HTMLDivElement>(null); const chartRef = useRef<HTMLDivElement>(null);
const [hasRun, setHasRun] = useState(false); const [hasRun, setHasRun] = useState(false);
const [calendarOpen, setCalendarOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
// 전역 상수는 공용 파일에서 import // 전역 상수는 공용 파일에서 import
@@ -316,9 +318,16 @@ export default function Page({user}: {user: any}) {
expectedRevenue: { border: '#16A34A', bg: 'rgba(22, 163, 74, 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 selectedRows = useMemo(() => sortedVisibleRows.filter(r => checkedIds.has(r.id)), [sortedVisibleRows, checkedIds])
const [seriesMode, setSeriesMode] = useState<'items'|'series'>('items') 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 [seriesInterval, setSeriesInterval] = useState<'day'|'week'|'month'>('day')
const [seriesItems, setSeriesItems] = useState<Array<{ period: string, views: number, validViews: number, premiumViews: number, expectedRevenue: number }>>([]) const [seriesItems, setSeriesItems] = useState<Array<{ period: string, views: number, validViews: number, premiumViews: number, expectedRevenue: number }>>([])
const [seriesLoading, setSeriesLoading] = useState<boolean>(false)
const chartData = useMemo(() => { const chartData = useMemo(() => {
const col = metricColor[selectedMetric] const col = metricColor[selectedMetric]
if (seriesMode === 'series') { if (seriesMode === 'series') {
@@ -337,6 +346,37 @@ export default function Page({user}: {user: any}) {
elements: { line: { tension: 0.35 } }, 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]);
// 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제하되 // 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제하되
// 초기 로딩 등으로 가시 채널이 비어있을 땐 해제하지 않음. // 초기 로딩 등으로 가시 채널이 비어있을 땐 해제하지 않음.
// 가시 채널이 채워지고 선택이 비어있으면 전체 선택으로 초기화. // 가시 채널이 채워지고 선택이 비어있으면 전체 선택으로 초기화.
@@ -364,7 +404,7 @@ export default function Page({user}: {user: any}) {
py-2 py-2
"> ">
<div className="order-1 col-[1/5] row-[1/2] flex flex-row"> <div className="order-1 col-[1/5] row-[1/2] flex flex-row">
<div className="grow flex flex-row items-center overflow-y-hidden overflow-x-auto mx-1 scrollbar-thin-x scrollbar-outside-x"> <div className="grow h-[48px] flex flex-row items-center overflow-y-hidden overflow-x-auto mx-1 scrollbar-thin-x scrollbar-outside-x" style={{ scrollbarGutter: 'stable both-edges' }}>
{myChannelList.length === 0 ? ( {myChannelList.length === 0 ? (
<div className=" h-[32px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold text-[#848484]"> <div className=" h-[32px] border-1 border-border-pale rounded-xl flex items-center justify-center bg-white mx-1 px-2 min-w-[fit-content] font-semibold text-[#848484]">
. .
@@ -394,6 +434,8 @@ export default function Page({user}: {user: any}) {
dateString={dateString} dateString={dateString}
is_ri={true} is_ri={true}
is_small={false} is_small={false}
isOpen={calendarOpen}
onOpenChange={(open)=>{ setCalendarOpen(open); if (open) setFilterOpen(false); }}
onRangeChange={(s,e)=>{ onRangeChange={(s,e)=>{
setStartDate(s); setEndDate(e); setStartDate(s); setEndDate(e);
setDateString(`${s.toISOString().slice(0,10)} ~ ${e.toISOString().slice(0,10)}`); setDateString(`${s.toISOString().slice(0,10)} ~ ${e.toISOString().slice(0,10)}`);
@@ -405,6 +447,8 @@ export default function Page({user}: {user: any}) {
channel_list={myChannelList} channel_list={myChannelList}
visibleSet={visibleChannelIds} visibleSet={visibleChannelIds}
onChangeVisible={(next) => setVisibleChannelIds(new Set(next))} onChangeVisible={(next) => setVisibleChannelIds(new Set(next))}
isOpen={filterOpen}
onOpenChange={(open)=>{ setFilterOpen(open); if (open) setCalendarOpen(false); }}
/> />
<div className="w-2"> </div> <div className="w-2"> </div>
</div> </div>
@@ -596,20 +640,11 @@ export default function Page({user}: {user: any}) {
<option value="series"></option> <option value="series"></option>
</select> </select>
{seriesMode === 'series' && ( {seriesMode === 'series' && (
<>
<select className="border-1 border-[#e6e9ef] rounded px-2 py-1" value={seriesInterval} onChange={e => setSeriesInterval(e.target.value as any)}> <select className="border-1 border-[#e6e9ef] rounded px-2 py-1" value={seriesInterval} onChange={e => setSeriesInterval(e.target.value as any)}>
<option value="day"></option> <option value="day"></option>
<option value="week"></option> <option value="week"></option>
<option value="month"></option> <option value="month"></option>
</select> </select>
<button className="border-1 border-[#e6e9ef] rounded px-2 py-1" onClick={async () => {
const s = startDate; const e = endDate;
const qs = new URLSearchParams({ start: s.toISOString(), end: e.toISOString(), mode: 'series', interval: seriesInterval })
const res = await fetch(`/api/contents/mycontent?${qs.toString()}`, { cache: 'no-store' })
const data = await res.json()
if (res.ok) setSeriesItems(data.items || [])
}}></button>
</>
)} )}
</div> </div>
<div className="flex flex-row justify-start items-center"> <div className="flex flex-row justify-start items-center">
@@ -621,7 +656,13 @@ export default function Page({user}: {user: any}) {
{/* <div className=" p-2 pt-5 text-sm font-normal text-[#F94B37]"> + 0.24%</div> */} {/* <div className=" p-2 pt-5 text-sm font-normal text-[#F94B37]"> + 0.24%</div> */}
</div> </div>
<div className="w-full flex-1 items-center justify-center flex" ref={chartRef}> <div className="w-full flex-1 items-center justify-center flex" ref={chartRef}>
{seriesMode === 'series' && seriesLoading ? (
<div className="flex items-center justify-center w-full h-full">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-[#e6e9ef]" style={{ borderTopColor: metricColor[selectedMetric].border }} />
</div>
) : (
<Line data={chartData} options={chartOptions} /> <Line data={chartData} options={chartOptions} />
)}
</div> </div>
</div> </div>
</div> </div>