Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
950698e
feat: 단어조합기 영어 6글자 단어 지원
hafskjfha Oct 31, 2025
0e7cadb
fix: 오타 수정
hafskjfha Oct 31, 2025
e1323d6
feat: 미션단어 추출B 동률 허용
hafskjfha Nov 19, 2025
3ec688f
feat: 영어 대문자 -> 소문자 변환기 추가
hafskjfha Nov 19, 2025
92e358b
feat: 추가요청 처리의 주제를 추가로 선택가능
hafskjfha Nov 24, 2025
e7a57ce
fix: 단어 추가요청시 주제 선택 필수 해제
hafskjfha Nov 24, 2025
09e2cf5
fix: 단어 대량 조회 로직 수정
hafskjfha Nov 24, 2025
906378c
chore: 패키지 업데이트
hafskjfha Nov 25, 2025
a90d2e7
feat: 단어 고급 검색 페이지 제작
hafskjfha Nov 26, 2025
8f3ab37
fix: 릴리즈 노트에 링크 연결
hafskjfha Nov 26, 2025
7e8ce61
fix: 관리자 페이지 총 단어수 표시 로직 수정
hafskjfha Nov 26, 2025
6db6f22
fix: 검색 딜레이 추가, 단어 정보 표시에서 존재하는 버그 수정
hafskjfha Nov 26, 2025
65d89ff
feat: 단어 통계 표시 페이지 제작
hafskjfha Nov 27, 2025
7533d6d
feat: 리플레이 분석에 단어 연결 그래프 추가
hafskjfha Nov 27, 2025
138067c
feat: 특수 문서 처리 일부 구현
hafskjfha Nov 27, 2025
7069caa
fix: 검색 악용을 방지하기 위해 결과창에 가림막 추가
hafskjfha Nov 27, 2025
9bcb9ec
fix: 파트너십 표시 추가
hafskjfha Nov 27, 2025
e158288
chore: 메타데이터 수정
hafskjfha Nov 27, 2025
3d94eac
chore: 테스트 코드 임시 삭제
hafskjfha Nov 27, 2025
cf79760
fix: pr 제안반영
hafskjfha Nov 27, 2025
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
84 changes: 0 additions & 84 deletions __tests__/admin/wordsCount.test.ts

This file was deleted.

46 changes: 13 additions & 33 deletions app/admin/add-words/AddWordsHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,16 +223,18 @@ export default function WordsAddHome() {
// 문서 및 주제 정보 맵핑
const letterDocsInfo: Record<string, number> = {};
const themeDocsInfo: Record<string, number> = {};
const themeCodeInfo: Record<string, number> = {}
const themeCodeInfo: Record<string, number> = {};
const themeInInfo: Record<number, {id: number, name: string, code: string}> = {};

docsDatas.filter(({ typez }) => typez === "letter").forEach(({ id, name }) => {
letterDocsInfo[name] = id
})
docsDatas.filter(({ typez }) => typez === "theme").forEach(({ id, name }) => {
themeDocsInfo[name] = id
})
themeData.forEach(({ id, code }) => {
themeData.forEach(({ id, code, name }) => {
themeCodeInfo[code] = id
themeInInfo[id] = { id, name, code };
})

// DB 쿼리 준비
Expand Down Expand Up @@ -319,10 +321,8 @@ export default function WordsAddHome() {
}

setProgress(40);
const existingWordThemes: Record<number, { themeId: number, themeCode: string, themeName: string }[]> = {}; // 단어id - 주제들
const checkedData: Record<string, number> = {}; // 단어 - 단어id
const existingWordMap: Record<string, { wordId: number, themes: { id: number, code: string, name: string }[] }> = {}; // 기존단어 - {단어id, 주제들}


// 기존 단어 체크
const { data: needCheckedWordsData, error: ff } = await supabaseInQueryChunk(
needCheckWord,
Expand All @@ -342,41 +342,21 @@ export default function WordsAddHome() {
if (ff) return makeError(ff);

// 기존 단어 맵핑
const existingWordThemes: Record<string, { themeId: number; themeCode: string; themeName: string; }[]> = {}; // 단어 - 기존주제들
for (const data of needCheckedWordsData) {
checkedData[data.word] = data.id
existingWordMap[data.word] = { wordId: data.id, themes: [] };
existingWordThemes[data.word] = data.wthemes.map((themeId) => {
const themeInfo = themeInInfo[themeId];
if (!themeInfo) return undefined;
return { themeId: themeInfo.id, themeCode: themeInfo.code, themeName: themeInfo.name };
}).filter(Boolean) as { themeId: number; themeCode: string; themeName: string; }[];
}

// 기존 단어의 주제 맵핑
const { data: existingWordThemesData, error: ee } = await supabaseInQueryChunk(
needCheckedWordsData.map(({ id }) => id),
async (chunk) => {
const r = await SCM.get().wordsThemesByWordId(chunk);
return { ...r };
},
{
chunkSize: 100,
concurrency: 3,
onProgress(_, __, current, total) {
setCurrentTask(`기존단어의 주제 체크중... ${current}/${total}`);
}
}
)
if (ee) return makeError(ee);
const result: Record<number, { themeId: number; themeCode: string; themeName: string; }[]> = {};
existingWordThemesData.forEach(({ word_id, themes: { id, code, name } }) => {
if (!result[word_id]) { result[word_id] = []; }
result[word_id].push({ themeId: id, themeCode: code, themeName: name });
});
Object.entries(result).forEach(([wordId, themes]) => {
existingWordThemes[Number(wordId)] = (existingWordThemes[Number(wordId)] ?? []).concat(themes.map(({ themeId, themeCode, themeName }) => ({ themeId, themeCode, themeName })));
});

// 기존에는 있는 주제이지만 JSON에는 없는 주제 제거 쿼리 준비
for (const data of jsonData) {
const addThemesSet = new Set(data.themes);
if (existingWordThemes[checkedData[data.word]]) {
for (const existTheme of existingWordThemes[checkedData[data.word]]) {
if (existingWordThemes[data.word]) {
for (const existTheme of existingWordThemes[data.word]) {
if (!addThemesSet.has(existTheme.themeCode)) {
wordThemeDelQuery.push({
word_id: checkedData[data.word],
Expand Down
132 changes: 110 additions & 22 deletions app/admin/request-words/AdminRequestHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { isNoin } from '@/app/lib/lib'
import { addWordQueryType } from '@/app/types/type'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import ThemeSelectModal from './ThemeSelectModal'

// 타입 정의
type Theme = {
Expand Down Expand Up @@ -65,10 +66,24 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W
const [currentPage, setCurrentPage] = useState<number>(1);
const [allSelected, setAllSelected] = useState<boolean>(false);
const [errorModalView, setErrorModalView] = useState<ErrorMessage | null>(null);
const [themeModalOpen, setThemeModalOpen] = useState<boolean>(false);
const [selectedRequestForModal, setSelectedRequestForModal] = useState<WordRequest | null>(null);
const [allThemes, setAllThemes] = useState<{ id: number; name: string; code: string }[]>([]);
const user = useSelector((state: RootState) => state.user);

const PAGE_SIZE = 30;

// 전체 주제 목록 불러오기
useEffect(() => {
const loadAllThemes = async () => {
const { data, error } = await SCM.get().allThemes();
if (!error && data) {
setAllThemes(data);
}
};
loadAllThemes();
}, []);

// 요청 타입별 필터링
const filteredRequests = requestDatas.filter(request => {
if (selectedTab === "all") return true;
Expand Down Expand Up @@ -108,30 +123,32 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W
setSelectedRequests(newSelected);
};

// 주제 선택 토글
const toggleTheme = (requestId: number, themeId: number) => {
const currentThemes = selectedThemes[requestId] || new Set<number>();
// 주제 선택 버튼 클릭 핸들러
const handleThemeSelectClick = (request: WordRequest) => {
setSelectedRequestForModal(request);
setThemeModalOpen(true);
};

// 모달에서 주제 선택 확인 핸들러
const handleThemeModalConfirm = (selectedThemesList: Theme[]) => {
if (!selectedRequestForModal) return;

const newSelectedThemes = { ...selectedThemes };
const themeIds = new Set(selectedThemesList.map(t => t.theme_id));
newSelectedThemes[selectedRequestForModal.id] = themeIds;
setSelectedThemes(newSelectedThemes);

if (currentThemes.has(themeId)) {
currentThemes.delete(themeId);
if (currentThemes.size === 0) {
toggleRequest(requestId)
}
} else {
currentThemes.add(themeId);
// 주제가 선택되면 해당 요청도 자동으로 선택
if (themeIds.size > 0) {
const newSelected = new Set(selectedRequests);
if (!newSelected.has(requestId)) {
newSelected.add(requestId);
if (!newSelected.has(selectedRequestForModal.id)) {
newSelected.add(selectedRequestForModal.id);
if (newSelected.size === currentRequests.length) {
setAllSelected(true);
}
setSelectedRequests(newSelected);
}
}

newSelectedThemes[requestId] = currentThemes;
setSelectedThemes(newSelectedThemes);
};

const makeError = (error: PostgrestError) => {
Expand All @@ -155,11 +172,18 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W
const request = requestDatas.find(r => r.id === reqId);
const selectedThemeIds = selectedThemes[reqId] || new Set<number>();

// allThemes에서 선택된 주제 정보 가져오기
const selectedThemeObjects = allThemes
.filter(theme => selectedThemeIds.has(theme.id))
.map(theme => ({
theme_id: theme.id,
theme_name: theme.name,
theme_code: theme.code
}));

return {
...request,
selectedThemes: request?.wait_themes?.filter(theme =>
selectedThemeIds.has(theme.theme_id)
)
selectedThemes: selectedThemeObjects
};
});

Expand All @@ -173,6 +197,8 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W

// 승인할 목록에서 쿼리에 맞게 배분
for (const req of requestsToApprove) {
const selectedThemeIds = selectedThemes[req.id!] || new Set<number>();

switch (req.request_type) {
case "add":
if (!req.word || !req.selectedThemes || req.selectedThemes.length === 0) continue;
Expand All @@ -188,10 +214,16 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W
continue

case "theme_change":
if (!req.word_id || !req.selectedThemes) continue;
if (!req.word_id) continue;
const addT: { word_id: number, theme_id: number }[] = [];
const delT: { word_id: number, theme_id: number }[] = [];
req.selectedThemes.forEach((theme) => {

// theme_change는 wait_themes를 직접 사용
const themesToProcess = req.wait_themes?.filter(theme =>
selectedThemeIds.has(theme.theme_id)
) || [];

themesToProcess.forEach((theme) => {
if (theme.typez === "add") {
addT.push({ word_id: req.word_id as number, theme_id: theme.theme_id })
}
Expand Down Expand Up @@ -619,14 +651,57 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W
{renderRequestTypeBadge(request.request_type)}
</TableCell>
<TableCell>
{request.wait_themes ? (
{request.request_type === 'add' ? (
<div className="space-y-2">
<Button
variant="outline"
size="sm"
onClick={() => handleThemeSelectClick(request)}
className="w-full"
>
주제 선택 ({selectedThemes[request.id]?.size || 0})
</Button>
{selectedThemes[request.id] && selectedThemes[request.id].size > 0 && (
<div className="flex flex-wrap gap-1">
{allThemes
.filter(theme => selectedThemes[request.id]?.has(theme.id))
.map((theme, index) => (
<Badge key={`badge-${theme.id}-${index}`} variant="secondary" className="text-xs">
{theme.name}
</Badge>
))}
</div>
)}
</div>
) : request.wait_themes ? (
<div className="flex flex-col gap-2">
{request.wait_themes.map((theme, index) => (
<div key={`t-${theme.theme_id}-${request.id}-${index ^ 10110}`} className="flex items-center gap-2">
<Checkbox
id={`theme-${request.id}-${theme.theme_id}`}
checked={selectedThemes[request.id]?.has(theme.theme_id) || false}
onCheckedChange={() => toggleTheme(request.id, theme.theme_id)}
onCheckedChange={() => {
const currentThemes = selectedThemes[request.id] || new Set<number>();
const newSelectedThemes = { ...selectedThemes };
if (currentThemes.has(theme.theme_id)) {
currentThemes.delete(theme.theme_id);
if (currentThemes.size === 0) {
toggleRequest(request.id);
}
} else {
currentThemes.add(theme.theme_id);
const newSelected = new Set(selectedRequests);
if (!newSelected.has(request.id)) {
newSelected.add(request.id);
if (newSelected.size === currentRequests.length) {
setAllSelected(true);
}
setSelectedRequests(newSelected);
}
}
newSelectedThemes[request.id] = currentThemes;
setSelectedThemes(newSelectedThemes);
}}
/>
<label htmlFor={`theme-${request.id}-${theme.theme_id}`} className="text-sm flex items-center text-gray-700 dark:text-gray-200">
{theme.theme_name}
Expand Down Expand Up @@ -709,6 +784,19 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W
</CardFooter>
</Card>
{errorModalView && <ErrorModal error={errorModalView} onClose={() => setErrorModalView(null)} />}
{selectedRequestForModal && (
<ThemeSelectModal
isOpen={themeModalOpen}
onClose={() => {
setThemeModalOpen(false);
setSelectedRequestForModal(null);
}}
word={selectedRequestForModal.word}
initialSelectedThemes={selectedRequestForModal.wait_themes || []}
initialSelectedThemeIds={selectedThemes[selectedRequestForModal.id]}
onConfirm={handleThemeModalConfirm}
/>
)}
</div>
</div>
)
Expand Down
Loading
Loading