유저 지표 잔존율 생성

This commit is contained in:
2025-07-16 18:39:30 +09:00
parent 7fa9abcad4
commit 7041d4a649
10 changed files with 252 additions and 309 deletions

View File

@@ -62,10 +62,14 @@ export const userIndexExport = async (token, filename, sendDate, endDate) => {
}; };
// Retention // Retention
export const RetentionIndexView = async (token, start_dt, end_dt) => { export const RetentionIndexView = async (token, startDate, endDate, order, size, currentPage) => {
try { try {
const res = await Axios.get(`/api/v1/indicators/retention/list?start_dt=${start_dt}&end_dt=${end_dt}`, { const res = await Axios.get(`/api/v1/indicators/retention/list?start_dt=${startDate}&end_dt=${endDate}
headers: { Authorization: `Bearer ${token}` }, &orderby=${order}&page_no=${currentPage}&page_size=${size}`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
}); });
return res.data.data; return res.data.data;

View File

@@ -19,6 +19,13 @@ export const TabEconomicIndexList = [
// { value: 'instance', name: '인스턴스' }, // { value: 'instance', name: '인스턴스' },
]; ];
export const TabUserIndexList = [
{ value: 'USER', name: '이용자 지표' },
{ value: 'RETENTION', name: '잔존율' },
// { value: 'SEGMENT', name: 'Segment' },
// { value: 'PLAYTIME', name: '플레이타임' },
];
export const mailSendType = [ export const mailSendType = [
{ value: 'ALL', name: '전체' }, { value: 'ALL', name: '전체' },
{ value: 'RESERVE_SEND', name: '예약 발송' }, { value: 'RESERVE_SEND', name: '예약 발송' },

View File

@@ -1,108 +1,76 @@
import { Fragment, useEffect, useState } from 'react'; import React, { Fragment, useRef } from 'react';
import Button from '../../components/common/button/Button';
import { TableStyle, TableInfo, ListOption, IndexTableWrap } from '../../styles/Components'; import { TableStyle, TableInfo, ListOption, IndexTableWrap } from '../../styles/Components';
import { RetentionSearchBar } from '../../components/IndexManage/index'; import { AnimatedPageWrapper } from '../common/Layout';
import { RetentionIndexExport, RetentionIndexView } from '../../apis'; import { useRetentionSearch, RetentionSearchBar } from '../searchBar';
import { TableSkeleton } from '../Skeleton/TableSkeleton';
import { numberFormatter } from '../../utils';
import { ExcelDownButton } from '../common';
import { useTranslation } from 'react-i18next';
const RetentionContent = () => { const RetentionContent = () => {
const { t } = useTranslation();
const tableRef = useRef(null);
const token = sessionStorage.getItem('token'); const token = sessionStorage.getItem('token');
let d = new Date();
const START_DATE = new Date(new Date(d.setDate(d.getDate() - 1)).setHours(0, 0, 0, 0));
const END_DATE = new Date(new Date(d.setDate(d.getDate() - 1)).setHours(24, 0, 0, 0));
const [dataList, setDataList] = useState([]); const {
const [resultData, setResultData] = useState([]); searchParams,
const [retentionData, setRetention] = useState(1); loading: dataLoading,
data: dataList,
handleSearch,
handleReset,
updateSearchParams
} = useRetentionSearch(token);
const [sendDate, setSendDate] = useState(START_DATE);
const [finishDate, setFinishDate] = useState(END_DATE);
const [excelBtn, setExcelBtn] = useState(true); //true 시 비활성화
useEffect(() => {
fetchData(START_DATE, END_DATE);
}, []);
// Retention 지표 데이터
const fetchData = async (startDate, endDate) => {
const startDateToLocal =
startDate.getFullYear() +
'-' +
(startDate.getMonth() + 1 < 9 ? '0' + (startDate.getMonth() + 1) : startDate.getMonth() + 1) +
'-' +
(startDate.getDate() < 9 ? '0' + startDate.getDate() : startDate.getDate());
const endDateToLocal =
endDate.getFullYear() +
'-' +
(endDate.getMonth() + 1 < 9 ? '0' + (endDate.getMonth() + 1) : endDate.getMonth() + 1) +
'-' +
(endDate.getDate() < 9 ? '0' + endDate.getDate() : endDate.getDate());
setDataList(await RetentionIndexView(token, startDateToLocal, endDateToLocal));
console.log(dataList);
setSendDate(startDateToLocal);
setFinishDate(endDateToLocal);
};
// 검색 함수
const handleSearch = (send_dt, end_dt) => {
fetchData(send_dt, end_dt);
setRetention(resultData.retention);
};
// 엑셀 다운로드
const handleXlsxExport = () => {
const fileName = 'Caliverse_Retention_Index.xlsx';
if(!excelBtn){
RetentionIndexExport(token, fileName, sendDate, finishDate);
}
};
return ( return (
<> <AnimatedPageWrapper>
<RetentionSearchBar setResultData={setResultData} resultData={resultData} <RetentionSearchBar
handleSearch={handleSearch} fetchData={fetchData} setRetention={setRetention} setExcelBtn={setExcelBtn} /> searchParams={searchParams}
onSearch={(newParams, executeSearch = true) => {
if (executeSearch) {
handleSearch(newParams);
} else {
updateSearchParams(newParams);
}
}}
onReset={handleReset}
/>
<TableInfo> <TableInfo>
<ListOption> <ListOption>
<Button <ExcelDownButton tableRef={tableRef} fileName={t('FILE_INDEX_USER_RETENTION')} />
theme={excelBtn === true ? "disable" : "line"}
text="엑셀 다운로드"
disabled={handleXlsxExport}
handleClick={handleXlsxExport} />
</ListOption> </ListOption>
</TableInfo> </TableInfo>
<IndexTableWrap> {dataLoading ? <TableSkeleton width='100%' count={15} /> :
<TableStyle> <IndexTableWrap>
<caption></caption> <TableStyle ref={tableRef}>
<thead> <caption></caption>
<tr> <thead>
{/* <th width="100">국가</th> */} <tr>
<th width="150">일자</th> <th>일자</th>
<th className="cell-nru">NRU</th> <th>NRU</th>
{[...Array(Number(retentionData))].map((value, index) => { <th>D+1</th>
return <th key={index}>{`D+${index + 1}`}</th>; <th>D+7</th>
})} <th>D+30</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{dataList.retention && {dataList?.map((data, index) => (
dataList.retention.map(data => ( <Fragment key={index}>
<tr className="cell-nru-th" key={data.date}> <tr>
<td>{data.date}</td> <td>{data.logDay}</td>
{data['d-day'].map((day, index) => ( <td>{data.totalCreated}</td>
<td key={index}>{day.dif}</td> <td>{numberFormatter.formatPercent(data.d1_rate)}</td>
))} <td>{numberFormatter.formatPercent(data.d7_rate)}</td>
</tr> <td>{numberFormatter.formatPercent(data.d30_rate)}</td>
))} </tr>
</tbody> </Fragment>
</TableStyle> ))}
</IndexTableWrap> </tbody>
</> </TableStyle>
</IndexTableWrap>
}
</AnimatedPageWrapper>
); );
}; };

View File

@@ -1,8 +1,7 @@
import { TextInput, InputLabel, SelectInput, InputGroup } from '../../styles/Components'; import { InputLabel } from '../../styles/Components';
import { SearchBarLayout, SearchPeriod } from '../common/SearchBar'; import { SearchBarLayout, SearchPeriod } from '../common/SearchBar';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { userSearchType2 } from '../../assets/data/options'; import { getCurrencyList } from '../../apis/Log';
import { getCurrencyDetailList, getCurrencyList } from '../../apis/Log';
import { useAlert } from '../../context/AlertProvider'; import { useAlert } from '../../context/AlertProvider';
import { alertTypes } from '../../assets/data/types'; import { alertTypes } from '../../assets/data/types';

View File

@@ -1,166 +1,151 @@
import { useEffect, useState } from 'react'; import { InputLabel } from '../../styles/Components';
import { styled } from 'styled-components'; import { SearchBarLayout, SearchPeriod } from '../common/SearchBar';
import Button from '../common/button/Button'; import { useCallback, useEffect, useState } from 'react';
import DatePickerComponent from '../common/Date/DatePickerComponent'; import { useAlert } from '../../context/AlertProvider';
import { alertTypes } from '../../assets/data/types';
import { RetentionIndexView } from '../../apis';
import { FormWrapper, InputLabel, TextInput, SelectInput, BtnWrapper, InputGroup, DatePickerWrapper, AlertText } from '../../styles/Components'; export const useRetentionSearch = (token, initialPageSize) => {
const {showToast} = useAlert();
const RetentionSearchBar = ({ resultData, setResultData, handleSearch, fetchData, setRetention, setExcelBtn }) => { const [searchParams, setSearchParams] = useState({
// 초기 날짜 세팅 start_dt: (() => {
let d = new Date(); const date = new Date();
const START_DATE = new Date(new Date(d.setDate(d.getDate() - 1)).setHours(0, 0, 0, 0)); date.setDate(date.getDate() - 1);
const END_DATE = new Date(new Date(d.setDate(d.getDate() - 1)).setHours(24, 0, 0, 0)); return date;
const [errorMessage, setErrorMessage] = useState(''); })(),
const [period, setPeriod] = useState(0); end_dt: (() => {
const date = new Date();
date.setDate(date.getDate());
return date;
})(),
order_by: 'ASC',
page_size: initialPageSize,
page_no: 1
});
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
// resultData에 임의 날짜 넣어주기
useEffect(() => { useEffect(() => {
setResultData({ const initialLoad = async () => {
send_dt: START_DATE, await fetchData(searchParams);
end_dt: '', };
retention: 0,
}); initialLoad();
}, [token]);
const fetchData = useCallback(async (params) => {
if (!token) return;
try {
setLoading(true);
const result = await RetentionIndexView(
token,
params.start_dt.toISOString(),
params.end_dt.toISOString(),
params.order_by,
params.page_size,
params.page_no
);
if(result.result === "ERROR_LOG_MEMORY_LIMIT"){
showToast('LOG_MEMORY_LIMIT_WARNING', {type: alertTypes.error});
}else if(result.result === "ERROR_MONGODB_QUERY"){
showToast('LOG_MONGGDB_QUERY_WARNING', {type: alertTypes.error});
}else if(result.result === "ERROR"){
showToast(result.result, {type: alertTypes.error});
}
setData(result.retention);
return result.retention;
} catch (error) {
showToast('error', {type: alertTypes.error});
throw error;
} finally {
setLoading(false);
}
}, [token]);
const updateSearchParams = useCallback((newParams) => {
setSearchParams(prev => ({
...prev,
...newParams
}));
}, []); }, []);
// 발송 날짜 세팅 로직 const handleSearch = useCallback(async (newParams = {}, executeSearch = true) => {
const handleSelectedDate = data => { const updatedParams = {
const sendDate = new Date(data); ...searchParams,
const resultSendData = new Date(sendDate.getFullYear(), sendDate.getMonth(), sendDate.getDate()); ...newParams,
page_no: newParams.page_no || 1 // Reset to first page on new search
};
updateSearchParams(updatedParams);
const resultEndDate = new Date(resultSendData); if (executeSearch) {
resultEndDate.setDate(resultEndDate.getDate() + Number(resultData.retention)); return await fetchData(updatedParams);
}
return null;
}, [searchParams, fetchData]);
setResultData({ ...resultData, send_dt: resultSendData, end_dt: resultEndDate }); const handleReset = useCallback(async () => {
const now = new Date();
now.setDate(now.getDate() - 1);
const resetParams = {
start_dt: now,
end_dt: new Date(),
order_by: 'ASC',
page_size: initialPageSize,
page_no: 1
};
setSearchParams(resetParams);
return await fetchData(resetParams);
}, [initialPageSize, fetchData]);
const handlePageChange = useCallback(async (newPage) => {
return await handleSearch({ page_no: newPage }, true);
}, [handleSearch]);
const handlePageSizeChange = useCallback(async (newSize) => {
return await handleSearch({ page_size: newSize, page_no: 1 }, true);
}, [handleSearch]);
const handleOrderByChange = useCallback(async (newOrder) => {
return await handleSearch({ order_by: newOrder }, true);
}, [handleSearch]);
return {
searchParams,
loading,
data,
handleSearch,
handleReset,
handlePageChange,
handlePageSizeChange,
handleOrderByChange,
updateSearchParams
};
};
const RetentionSearchBar = ({ searchParams, onSearch, onReset }) => {
const handleSubmit = event => {
event.preventDefault();
onSearch(searchParams, true);
}; };
// // 발송 날짜 세팅 로직 const searchList = [
// const handleEndDate = data => {
// const endDate = new Date(data);
// const resultSendData = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
// setResultData({ ...resultData, end_dt: resultSendData });
// };
// Retention 세팅 로직
const handleRetention = e => {
const value = e.target.value;
const resultEndDate = new Date(resultData.send_dt);
resultEndDate.setDate(resultEndDate.getDate() + Number(value));
setResultData({ ...resultData, end_dt: resultEndDate, retention: value });
setPeriod(value);
};
//Retention 범위 선택 후 disable 처리 로직
const handleSearchBtn = e => {
e.preventDefault();
if(period == 0) {
setErrorMessage("필수값을 선택하세요.");
return false;
} else {
setErrorMessage("");
setExcelBtn(false); //활성화
handleSearch(resultData.send_dt, resultData.end_dt);
}
}
const handleReset = e => {
e.preventDefault();
setResultData({ send_dt: START_DATE, end_dt: '', retention: 0 });
setRetention(1);
setErrorMessage("");
setPeriod(1);
setExcelBtn(true); //비활성화
fetchData(START_DATE, END_DATE);
};
return (
<> <>
<FormWrapper> <InputLabel>일자</InputLabel>
<SearchbarStyle> <SearchPeriod
<SearchItem> startDate={searchParams.start_dt}
<InputLabel>집계 기준일</InputLabel> handleStartDate={date => onSearch({ start_dt: date }, false)}
<InputGroup> endDate={searchParams.end_dt}
<DatePickerWrapper> handleEndDate={date => onSearch({ end_dt: date }, false)}
<DatePickerComponent />
name="시작 일자" selectedDate={resultData.send_dt} </>,
handleSelectedDate={data => handleSelectedDate(data)} ];
maxDate={new Date()} />
<span>~</span> return <SearchBarLayout firstColumnData={searchList} direction={'column'} onReset={onReset} handleSubmit={handleSubmit} />;
<DatePickerComponent
name="종료 일자"
selectedDate={resultData.end_dt}
maxDate={new Date()}
readOnly={true}
disabled={true}
type="retention" />
</DatePickerWrapper>
</InputGroup>
</SearchItem>
<SearchItem>
<InputLabel>Retention 범위</InputLabel>
<SelectInput
onChange={e => handleRetention(e)} value={resultData.retention}>
<option value={0}>선택</option>
<option value={1}>D+1</option>
<option value={7}>D+7</option>
<option value={30}>D+30</option>
</SelectInput>
</SearchItem>
{/* 기획 보류 */}
{/* <SearchItem>
<InputLabel>조회 국가</InputLabel>
<SelectInput>
<option value="">ALL</option>
<option value="">KR</option>
<option value="">EN</option>
<option value="">JP</option>
<option value="">TH</option>
</SelectInput>
</SearchItem> */}
<BtnWrapper $gap="8px">
<Button theme="reset" handleClick={handleReset} />
<Button
theme="search"
text="검색"
handleClick={handleSearchBtn}
/>
<AlertText>{errorMessage}</AlertText>
</BtnWrapper>
</SearchbarStyle>
</FormWrapper>
</>
);
}; };
export default RetentionSearchBar; export default RetentionSearchBar;
const SearchbarStyle = styled.div`
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 20px 0;
font-size: 14px;
padding: 20px;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
margin-bottom: 40px;
`;
const SearchItem = styled.div`
display: flex;
align-items: center;
gap: 20px;
margin-right: 50px;
${TextInput}, ${SelectInput} {
height: 35px;
}
${TextInput} {
padding: 0 10px;
max-width: 400px;
}
`;

View File

@@ -18,7 +18,7 @@ import PlayTimeSearchBar from './PlayTimeSearchBar';
import UserViewSearchBar from './UserViewSearchBar'; import UserViewSearchBar from './UserViewSearchBar';
import AdminViewSearchBar from './AdminViewSearchBar'; import AdminViewSearchBar from './AdminViewSearchBar';
import EventListSearchBar from './EventListSearchBar'; import EventListSearchBar from './EventListSearchBar';
import RetentionSearchBar from './RetentionSearchBar'; import RetentionSearchBar, {useRetentionSearch} from './RetentionSearchBar';
import UserBlockSearchBar from './UserBlockSearchBar'; import UserBlockSearchBar from './UserBlockSearchBar';
import UserIndexSearchBar from './UserIndexSearchBar'; import UserIndexSearchBar from './UserIndexSearchBar';
import ReportListSearchBar from './ReportListSearchBar'; import ReportListSearchBar from './ReportListSearchBar';
@@ -53,6 +53,7 @@ export {
AdminViewSearchBar, AdminViewSearchBar,
EventListSearchBar, EventListSearchBar,
RetentionSearchBar, RetentionSearchBar,
useRetentionSearch,
UserBlockSearchBar, UserBlockSearchBar,
UserIndexSearchBar, UserIndexSearchBar,
ReportListSearchBar, ReportListSearchBar,

View File

@@ -163,6 +163,7 @@ const resources = {
FILE_SIZE_OVER_ERROR: "파일의 사이즈가 5MB를 초과하였습니다.", FILE_SIZE_OVER_ERROR: "파일의 사이즈가 5MB를 초과하였습니다.",
//파일명칭 //파일명칭
FILE_INDEX_USER_CONTENT: 'Caliverse_User_Index.xlsx', FILE_INDEX_USER_CONTENT: 'Caliverse_User_Index.xlsx',
FILE_INDEX_USER_RETENTION: 'Caliverse_Retention.xlsx',
FILE_CALIUM_REQUEST: 'Caliverse_Calium_Request.xlsx', FILE_CALIUM_REQUEST: 'Caliverse_Calium_Request.xlsx',
FILE_LAND_AUCTION: 'Caliverse_Land_Auction.xlsx', FILE_LAND_AUCTION: 'Caliverse_Land_Auction.xlsx',
FILE_BUSINESS_LOG: 'Caliverse_Log.xlsx', FILE_BUSINESS_LOG: 'Caliverse_Log.xlsx',

View File

@@ -72,22 +72,3 @@ const GameLogView = () => {
}; };
export default withAuth(authType.gameLogRead)(GameLogView); export default withAuth(authType.gameLogRead)(GameLogView);
const TableWrapper = styled.div`
width: 100%;
min-width: 680px;
overflow: auto;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background: #666666;
}
&::-webkit-scrollbar-track {
background: #d9d9d9;
}
${TableStyle} {
width: 100%;
min-width: max-content;
}
`;

View File

@@ -1,65 +1,42 @@
import { Fragment, useEffect, useState } from 'react'; import { useState } from 'react';
import { styled } from 'styled-components'; import { styled } from 'styled-components';
import { Link, useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { AnimatedPageWrapper } from '../../components/common/Layout'; import { AnimatedPageWrapper } from '../../components/common/Layout';
import { Title } from '../../styles/Components';
import Modal from '../../components/common/modal/Modal';
import Button from '../../components/common/button/Button';
import { Title, BtnWrapper, ButtonClose, ModalText } from '../../styles/Components';
import { authList } from '../../store/authList';
import { userIndexView, userTotalIndex } from '../../apis';
import { UserContent, SegmentContent, PlayTimeContent, RetentionContent, DailyActiveUserContent, DailyMedalContent } from '../../components/IndexManage/index'; import { UserContent, SegmentContent, PlayTimeContent, RetentionContent, DailyActiveUserContent, DailyMedalContent } from '../../components/IndexManage/index';
import AuthModal from '../../components/common/modal/AuthModal';
import { authType } from '../../assets/data'; import { authType } from '../../assets/data';
import { withAuth } from '../../hooks/hook'; import { withAuth } from '../../hooks/hook';
import { TabUserIndexList } from '../../assets/data/options';
const UserIndex = () => { const UserIndex = () => {
const token = sessionStorage.getItem('token'); const [activeTab, setActiveTab] = useState('USER');
const navigate = useNavigate();
const userInfo = useRecoilValue(authList);
const [activeTab, setActiveTab] = useState('이용자 지표');
const handleTab = (e, content) => { const handleTab = (e, content) => {
// e.preventDefault(); e.preventDefault();
setActiveTab(content); setActiveTab(content);
}; };
const TabList = [
{ title: '이용자 지표' },
// { title: 'Retention' },
// { title: 'Segment' },
// { title: '플레이타임' },
// { title: 'DAU' },
// { title: '메달' },
];
return ( return (
<AnimatedPageWrapper> <AnimatedPageWrapper>
<Title>유저 지표</Title> <Title>유저 지표</Title>
<TabWrapper> <TabWrapper>
{TabList.map((el, idx) => { {TabUserIndexList.map((el, idx) => {
return ( return (
<TabItem <li key={idx}>
key={idx} <TabItem $state={activeTab === el.value ? 'active' : 'none'} onClick={e => handleTab(e, el.value)}>
$state={el.title === activeTab ? 'active' : 'unactive'} {el.name}
onClick={(e) => handleTab(e, el.title)} </TabItem>
> </li>
{el.title} )
</TabItem>
);
})} })}
</TabWrapper> </TabWrapper>
{/*{activeTab === 'DAU' && <DailyActiveUserContent />}*/} {/*{activeTab === 'DAU' && <DailyActiveUserContent />}*/}
{activeTab === '이용자 지표' && <UserContent />} {activeTab === 'USER' && <UserContent />}
{activeTab === 'Retention' && <RetentionContent />} {activeTab === 'RETENTION' && <RetentionContent />}
{activeTab === 'Segment' && <SegmentContent />} {activeTab === 'SEGMENT' && <SegmentContent />}
{activeTab === '플레이타임' && <PlayTimeContent />} {activeTab === 'PLAYTIME' && <PlayTimeContent />}
{/*{activeTab === '메달' && <DailyMedalContent />}*/} {/*{activeTab === '메달' && <DailyMedalContent />}*/}

View File

@@ -114,6 +114,26 @@ export const numberFormatter = {
console.error('Currency formatting error:', e); console.error('Currency formatting error:', e);
return '0'; return '0';
} }
},
formatPercent: (number, decimals = 2) => {
if (number === null || number === undefined) return '0%';
try {
const num = typeof number === 'string' ? parseFloat(number) : number;
if (isNaN(num)) return '0%';
const valueToFormat = num / 100;
return new Intl.NumberFormat('ko-KR', {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: decimals
}).format(valueToFormat);
} catch (e) {
console.error('Currency formatting error:', e);
return '0%';
}
} }
}; };