feat: 시스템관리 메뉴관리·금칙어관리 — 백엔드 연동
기획 SYS_MNU_001/002_p/003_p, SYS_WRD_001/002. 디자인 시안은 메뉴관리 목록 5432:16942, 메뉴 수정 5431:12392. 메뉴관리는 트리다. 기획대로 페이징 없이 전부 받아 upMenuId로 세우고, 최상위 신규 등록은 두지 않았다(추가는 각 행의 + 로 하위에만). 아이콘은 MODULE_MENU_ICON에 올려 받은 파일 아이디를 저장하고, 지울 때는 빈 문자열을 보낸다 — UPDATE가 null만 건너뛰어 이래야 컬럼이 비워진다. 금칙어는 목록·등록(별도 화면)·삭제·엑셀 일괄등록까지 연결했다. 중복은 백엔드가 판정하고 화면은 기획 문구로 바꿔 보여준다. 백엔드 결함 세 가지를 확인해 우회했다(별도 보고). - menu/benWord DELETE에 @PathVariable이 없어 경로와 쿼리에 같은 값을 함께 보낸다. - benWord 목록 정렬이 이중 역순이라 오래된 것이 먼저 온다 — 한 번에 받아 최신순으로 다시 세운다. - TB_SYS_MENU에 새창여부 컬럼이 없다. 사용자 확정에 따라 칸은 두고 값은 보내지 않는다. Co-Authored-By: Claude Opus 5
@fa3bd2d7a7ecdbe838411cfd23f3cd126f4a3e98
+++ app/(protected)/(basic)/system/banned-words/_actions.ts
... | ... | @@ -0,0 +1,93 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { redirect } from 'next/navigation'; | |
| 5 | +import { verifySession } from '@/lib/auth/dal'; | |
| 6 | +import { | |
| 7 | + createBannedWord, | |
| 8 | + deleteBannedWord, | |
| 9 | + uploadBannedWordExcel, | |
| 10 | +} from '@/lib/data/repositories/banned-word-repository'; | |
| 11 | +import { | |
| 12 | + DUPLICATE_WORD_MESSAGE, | |
| 13 | + validateBannedWord, | |
| 14 | + type BannedWordFormState, | |
| 15 | + type ExcelUploadResult, | |
| 16 | +} from '@/lib/domain/banned-word-form'; | |
| 17 | +import { BANNED_WORDS_PATH } from '@/lib/domain/banned-word-query'; | |
| 18 | + | |
| 19 | +const SAVE_FAILED_MESSAGE = '등록하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 20 | +const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 21 | +const UPLOAD_FAILED_MESSAGE = | |
| 22 | + '엑셀 파일을 등록하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 23 | + | |
| 24 | +function readString(formData: FormData, key: string): string { | |
| 25 | + const value = formData.get(key); | |
| 26 | + return typeof value === 'string' ? value : ''; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** 기획 SYS_WRD_002 ② — 등록이 끝나면 목록으로 돌아간다. */ | |
| 30 | +export async function createBannedWordAction( | |
| 31 | + _prevState: BannedWordFormState, | |
| 32 | + formData: FormData | |
| 33 | +): Promise<BannedWordFormState> { | |
| 34 | + await verifySession(); | |
| 35 | + | |
| 36 | + const validation = validateBannedWord(readString(formData, 'word')); | |
| 37 | + if (!validation.ok) { | |
| 38 | + return { status: 'error', errors: validation.errors }; | |
| 39 | + } | |
| 40 | + | |
| 41 | + let result; | |
| 42 | + try { | |
| 43 | + result = await createBannedWord(validation.value); | |
| 44 | + } catch { | |
| 45 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 46 | + } | |
| 47 | + | |
| 48 | + if (result.status === 'duplicate') { | |
| 49 | + return { status: 'error', errors: { word: DUPLICATE_WORD_MESSAGE } }; | |
| 50 | + } | |
| 51 | + | |
| 52 | + revalidatePath(BANNED_WORDS_PATH); | |
| 53 | + redirect(BANNED_WORDS_PATH); | |
| 54 | +} | |
| 55 | + | |
| 56 | +export async function deleteBannedWordAction( | |
| 57 | + id: number | |
| 58 | +): Promise<{ status: 'success' } | { status: 'error'; message: string }> { | |
| 59 | + await verifySession(); | |
| 60 | + | |
| 61 | + try { | |
| 62 | + await deleteBannedWord(id); | |
| 63 | + } catch { | |
| 64 | + return { status: 'error', message: DELETE_FAILED_MESSAGE }; | |
| 65 | + } | |
| 66 | + | |
| 67 | + revalidatePath(BANNED_WORDS_PATH); | |
| 68 | + return { status: 'success' }; | |
| 69 | +} | |
| 70 | + | |
| 71 | +export async function uploadBannedWordExcelAction( | |
| 72 | + formData: FormData | |
| 73 | +): Promise< | |
| 74 | + | { status: 'success'; result: ExcelUploadResult } | |
| 75 | + | { status: 'error'; message: string } | |
| 76 | +> { | |
| 77 | + await verifySession(); | |
| 78 | + | |
| 79 | + const file = formData.get('file'); | |
| 80 | + if (!(file instanceof File) || file.size === 0) { | |
| 81 | + return { status: 'error', message: '엑셀 파일을 첨부해 주세요.' }; | |
| 82 | + } | |
| 83 | + | |
| 84 | + let result: ExcelUploadResult; | |
| 85 | + try { | |
| 86 | + result = await uploadBannedWordExcel(file); | |
| 87 | + } catch { | |
| 88 | + return { status: 'error', message: UPLOAD_FAILED_MESSAGE }; | |
| 89 | + } | |
| 90 | + | |
| 91 | + revalidatePath(BANNED_WORDS_PATH); | |
| 92 | + return { status: 'success', result }; | |
| 93 | +} |
+++ app/(protected)/(basic)/system/banned-words/_components/banned-word-excel-modal.tsx
... | ... | @@ -0,0 +1,110 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRef, useState, useTransition } from 'react'; | |
| 4 | +import { FoxAlert } from '@fox/core/components/fox-alert'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { FoxFileUpload } from '@fox/core/components/fox-file-upload'; | |
| 7 | +import { FoxLinkButton } from '@fox/core/components/fox-link-button'; | |
| 8 | +import { FoxModal } from '@fox/core/components/fox-modal'; | |
| 9 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 10 | +import { BANNED_WORD_EXCEL_TEMPLATE_PATH } from '@/lib/domain/banned-word-query'; | |
| 11 | +import { uploadBannedWordExcelAction } from '../_actions'; | |
| 12 | + | |
| 13 | +interface BannedWordExcelModalProps { | |
| 14 | + onClose: () => void; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export function BannedWordExcelModal({ onClose }: BannedWordExcelModalProps) { | |
| 18 | + const { showToast } = useFeedback(); | |
| 19 | + const formRef = useRef<HTMLFormElement>(null); | |
| 20 | + const [errors, setErrors] = useState<string[]>([]); | |
| 21 | + const [isPending, startUpload] = useTransition(); | |
| 22 | + | |
| 23 | + function submit() { | |
| 24 | + const form = formRef.current; | |
| 25 | + if (!form) { | |
| 26 | + return; | |
| 27 | + } | |
| 28 | + | |
| 29 | + setErrors([]); | |
| 30 | + startUpload(async () => { | |
| 31 | + const result = await uploadBannedWordExcelAction(new FormData(form)); | |
| 32 | + | |
| 33 | + if (result.status === 'error') { | |
| 34 | + showToast({ variant: 'danger', message: result.message }); | |
| 35 | + return; | |
| 36 | + } | |
| 37 | + | |
| 38 | + if (result.result.errors.length > 0) { | |
| 39 | + setErrors(result.result.errors); | |
| 40 | + return; | |
| 41 | + } | |
| 42 | + | |
| 43 | + showToast({ | |
| 44 | + variant: 'success', | |
| 45 | + message: `금칙어 ${result.result.successCount}건을 등록했습니다.`, | |
| 46 | + }); | |
| 47 | + onClose(); | |
| 48 | + }); | |
| 49 | + } | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <FoxModal | |
| 53 | + open | |
| 54 | + size="sm" | |
| 55 | + title="엑셀등록" | |
| 56 | + onClose={onClose} | |
| 57 | + actions={ | |
| 58 | + <> | |
| 59 | + <FoxButton type="default" size="md" label="취소" onAction={onClose} /> | |
| 60 | + <FoxButton | |
| 61 | + type="primary" | |
| 62 | + size="md" | |
| 63 | + label={isPending ? '등록 중...' : '등록'} | |
| 64 | + disabled={isPending} | |
| 65 | + onAction={submit} | |
| 66 | + /> | |
| 67 | + </> | |
| 68 | + } | |
| 69 | + > | |
| 70 | + <form ref={formRef}> | |
| 71 | + <FoxAlert | |
| 72 | + type="information" | |
| 73 | + message="양식을 내려받아 금칙어명을 채운 뒤 올려 주세요. 한 줄이라도 형식이 맞지 않으면 전체가 등록되지 않습니다." | |
| 74 | + /> | |
| 75 | + | |
| 76 | + <FoxLinkButton | |
| 77 | + type="secondary" | |
| 78 | + size="sm" | |
| 79 | + href={BANNED_WORD_EXCEL_TEMPLATE_PATH} | |
| 80 | + label="업로드 양식 내려받기" | |
| 81 | + /> | |
| 82 | + | |
| 83 | + <FoxFileUpload | |
| 84 | + mode="default" | |
| 85 | + name="file" | |
| 86 | + label="엑셀 파일" | |
| 87 | + requirement="required" | |
| 88 | + selectLabel="파일선택" | |
| 89 | + placeholder="파일을 선택해 주세요." | |
| 90 | + accept=".xlsx,.xls" | |
| 91 | + /> | |
| 92 | + | |
| 93 | + {errors.length > 0 && ( | |
| 94 | + <FoxAlert | |
| 95 | + type="danger" | |
| 96 | + urgent | |
| 97 | + title="엑셀 검증에 실패해 등록되지 않았습니다." | |
| 98 | + message={ | |
| 99 | + <ul> | |
| 100 | + {errors.map((error) => ( | |
| 101 | + <li key={error}>{error}</li> | |
| 102 | + ))} | |
| 103 | + </ul> | |
| 104 | + } | |
| 105 | + /> | |
| 106 | + )} | |
| 107 | + </form> | |
| 108 | + </FoxModal> | |
| 109 | + ); | |
| 110 | +} |
+++ app/(protected)/(basic)/system/banned-words/_components/banned-word-list.tsx
... | ... | @@ -0,0 +1,183 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState, useTransition } from 'react'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 7 | +import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 8 | +import { | |
| 9 | + FoxListContainer, | |
| 10 | + type FoxListColumn, | |
| 11 | +} from '@fox/core/components/fox-list-container'; | |
| 12 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 13 | +import { | |
| 14 | + FoxPlusIcon, | |
| 15 | + FoxTrashIcon, | |
| 16 | + FoxUploadSimpleIcon, | |
| 17 | +} from '@fox/core/icons'; | |
| 18 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 19 | +import { formatOptionalValue, type BannedWord } from '@/lib/domain/banned-word'; | |
| 20 | +import { | |
| 21 | + BANNED_WORD_NEW_PATH, | |
| 22 | + BANNED_WORD_PAGE_SIZE_OPTIONS, | |
| 23 | + buildBannedWordHref, | |
| 24 | + type BannedWordPageSize, | |
| 25 | + type BannedWordQuery, | |
| 26 | +} from '@/lib/domain/banned-word-query'; | |
| 27 | +import { deleteBannedWordAction } from '../_actions'; | |
| 28 | +import { BannedWordExcelModal } from './banned-word-excel-modal'; | |
| 29 | + | |
| 30 | +interface BannedWordListProps { | |
| 31 | + items: BannedWord[]; | |
| 32 | + query: BannedWordQuery; | |
| 33 | + currentPage: number; | |
| 34 | + totalPages: number; | |
| 35 | + totalCount: number; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function BannedWordList({ | |
| 39 | + items, | |
| 40 | + query, | |
| 41 | + currentPage, | |
| 42 | + totalPages, | |
| 43 | + totalCount, | |
| 44 | +}: BannedWordListProps) { | |
| 45 | + const router = useRouter(); | |
| 46 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 47 | + const [isExcelOpen, setIsExcelOpen] = useState(false); | |
| 48 | + const [isDeleting, startDeleting] = useTransition(); | |
| 49 | + | |
| 50 | + function go(patch: Partial<BannedWordQuery>) { | |
| 51 | + router.replace(buildBannedWordHref(query, { ...patch, page: 1 })); | |
| 52 | + } | |
| 53 | + | |
| 54 | + function runDelete(word: BannedWord) { | |
| 55 | + hideAlert(); | |
| 56 | + startDeleting(async () => { | |
| 57 | + const result = await deleteBannedWordAction(word.id); | |
| 58 | + if (result.status === 'error') { | |
| 59 | + showToast({ variant: 'danger', message: result.message }); | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + showToast({ variant: 'success', message: '금칙어를 삭제했습니다.' }); | |
| 63 | + }); | |
| 64 | + } | |
| 65 | + | |
| 66 | + function confirmDelete(word: BannedWord) { | |
| 67 | + showAlert({ | |
| 68 | + variant: 'danger', | |
| 69 | + title: '금칙어를 삭제하시겠습니까?', | |
| 70 | + message: `"${word.word}"를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 71 | + actions: ( | |
| 72 | + <FoxButtonGroup size="md"> | |
| 73 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 74 | + <FoxButton | |
| 75 | + type="primary" | |
| 76 | + size="md" | |
| 77 | + label="삭제" | |
| 78 | + onAction={() => runDelete(word)} | |
| 79 | + /> | |
| 80 | + </FoxButtonGroup> | |
| 81 | + ), | |
| 82 | + }); | |
| 83 | + } | |
| 84 | + | |
| 85 | + const columns: FoxListColumn<BannedWord>[] = [ | |
| 86 | + { | |
| 87 | + key: 'no', | |
| 88 | + header: '번호', | |
| 89 | + width: 100, | |
| 90 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 91 | + }, | |
| 92 | + { key: 'word', header: '금지 키워드', width: 400, emphasis: true }, | |
| 93 | + { | |
| 94 | + key: 'createdAt', | |
| 95 | + header: '등록일', | |
| 96 | + width: 200, | |
| 97 | + render: (row) => formatOptionalValue(row.createdAt), | |
| 98 | + }, | |
| 99 | + { | |
| 100 | + key: 'actions', | |
| 101 | + header: '관리', | |
| 102 | + width: 100, | |
| 103 | + render: (row) => ( | |
| 104 | + <FoxIconButton | |
| 105 | + type="default" | |
| 106 | + size="sm" | |
| 107 | + icon={<FoxTrashIcon />} | |
| 108 | + label={isDeleting ? '삭제 중' : '삭제'} | |
| 109 | + disabled={isDeleting} | |
| 110 | + onAction={() => confirmDelete(row)} | |
| 111 | + /> | |
| 112 | + ), | |
| 113 | + }, | |
| 114 | + ]; | |
| 115 | + | |
| 116 | + return ( | |
| 117 | + <> | |
| 118 | + <FoxListContainer<BannedWord> | |
| 119 | + title="금칙어관리" | |
| 120 | + breadcrumb={[ | |
| 121 | + { label: '홈', href: '/' }, | |
| 122 | + { label: '시스템관리' }, | |
| 123 | + { label: '금칙어관리' }, | |
| 124 | + ]} | |
| 125 | + caption="금칙어 목록" | |
| 126 | + columns={columns} | |
| 127 | + rows={items} | |
| 128 | + rowKey={(row) => String(row.id)} | |
| 129 | + totalCount={totalCount} | |
| 130 | + emptyMessage="검색결과가 없습니다." | |
| 131 | + sorts={ | |
| 132 | + <FoxSelectText | |
| 133 | + size="sm" | |
| 134 | + ariaLabel="페이지 크기" | |
| 135 | + options={BANNED_WORD_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 136 | + value: String(size), | |
| 137 | + label: `${size}개씩 보기`, | |
| 138 | + }))} | |
| 139 | + value={String(query.pageSize)} | |
| 140 | + onValueChange={(value) => | |
| 141 | + go({ pageSize: Number(value) as BannedWordPageSize }) | |
| 142 | + } | |
| 143 | + /> | |
| 144 | + } | |
| 145 | + searchFields={[]} | |
| 146 | + keyword={query.keyword} | |
| 147 | + searchPlaceholder="금칙어를 입력하세요." | |
| 148 | + toolbarActions={ | |
| 149 | + <> | |
| 150 | + <FoxButton | |
| 151 | + type="secondary" | |
| 152 | + size="md" | |
| 153 | + leadingIcon={<FoxUploadSimpleIcon />} | |
| 154 | + label="엑셀등록" | |
| 155 | + onAction={() => setIsExcelOpen(true)} | |
| 156 | + /> | |
| 157 | + <FoxButton | |
| 158 | + type="primary" | |
| 159 | + size="md" | |
| 160 | + leadingIcon={<FoxPlusIcon />} | |
| 161 | + label="등록" | |
| 162 | + onAction={() => router.push(BANNED_WORD_NEW_PATH)} | |
| 163 | + /> | |
| 164 | + </> | |
| 165 | + } | |
| 166 | + page={currentPage} | |
| 167 | + totalPages={totalPages} | |
| 168 | + buildHref={(patch) => | |
| 169 | + buildBannedWordHref(query, { page: Number(patch.page ?? 1) }) | |
| 170 | + } | |
| 171 | + onQueryChange={(patch) => { | |
| 172 | + if (patch.keyword !== undefined) { | |
| 173 | + go({ keyword: String(patch.keyword ?? '') }); | |
| 174 | + } | |
| 175 | + }} | |
| 176 | + /> | |
| 177 | + | |
| 178 | + {isExcelOpen && ( | |
| 179 | + <BannedWordExcelModal onClose={() => setIsExcelOpen(false)} /> | |
| 180 | + )} | |
| 181 | + </> | |
| 182 | + ); | |
| 183 | +} |
+++ app/(protected)/(basic)/system/banned-words/excel-template/route.ts
... | ... | @@ -0,0 +1,36 @@ |
| 1 | +import { getSessionAccessToken, verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { backendFetchStream } from '@/lib/http/backend-fetch'; | |
| 3 | + | |
| 4 | +const TEMPLATE_PATH = '/api/v1/mngr/benWord/excel/template'; | |
| 5 | +const DOWNLOAD_FAILED_MESSAGE = | |
| 6 | + '양식을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 7 | +const XLSX_CONTENT_TYPE = | |
| 8 | + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; | |
| 9 | + | |
| 10 | +export async function GET() { | |
| 11 | + await verifySession(); | |
| 12 | + | |
| 13 | + const accessToken = await getSessionAccessToken(); | |
| 14 | + const result = await backendFetchStream(TEMPLATE_PATH, { | |
| 15 | + accessToken: accessToken ?? undefined, | |
| 16 | + }); | |
| 17 | + | |
| 18 | + if (!result.ok) { | |
| 19 | + return new Response(DOWNLOAD_FAILED_MESSAGE, { | |
| 20 | + status: 502, | |
| 21 | + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, | |
| 22 | + }); | |
| 23 | + } | |
| 24 | + | |
| 25 | + const upstream = result.data; | |
| 26 | + | |
| 27 | + return new Response(upstream.body, { | |
| 28 | + status: 200, | |
| 29 | + headers: { | |
| 30 | + 'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE, | |
| 31 | + 'Content-Disposition': | |
| 32 | + upstream.headers.get('content-disposition') ?? | |
| 33 | + 'attachment; filename="banned-words-template.xlsx"', | |
| 34 | + }, | |
| 35 | + }); | |
| 36 | +} |
+++ app/(protected)/(basic)/system/banned-words/new/_components/banned-word-form.module.scss
... | ... | @@ -0,0 +1,14 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.page { | |
| 4 | + display: flex; | |
| 5 | + flex-direction: column; | |
| 6 | + gap: fox.gap(6); | |
| 7 | +} | |
| 8 | + | |
| 9 | +.actions { | |
| 10 | + display: flex; | |
| 11 | + justify-content: center; | |
| 12 | + gap: fox.gap(3); | |
| 13 | + padding-block-start: fox.spacing(top-md); | |
| 14 | +} |
+++ app/(protected)/(basic)/system/banned-words/new/_components/banned-word-form.tsx
... | ... | @@ -0,0 +1,89 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useActionState, useRef } from 'react'; | |
| 5 | +import { FoxAlert } from '@fox/core/components/fox-alert'; | |
| 6 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 7 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 8 | +import { FoxPageHeader } from '@fox/core/components/fox-page-header'; | |
| 9 | +import { FoxTable } from '@fox/core/components/fox-table'; | |
| 10 | +import { FoxTableCell } from '@fox/core/components/fox-table-cell'; | |
| 11 | +import { FoxTableRow } from '@fox/core/components/fox-table-row'; | |
| 12 | +import { submitFormAction } from '@/app/_hooks/submit-form-action'; | |
| 13 | +import { | |
| 14 | + INITIAL_BANNED_WORD_FORM_STATE, | |
| 15 | + WORD_MAX_LENGTH, | |
| 16 | +} from '@/lib/domain/banned-word-form'; | |
| 17 | +import { BANNED_WORDS_PATH } from '@/lib/domain/banned-word-query'; | |
| 18 | +import { createBannedWordAction } from '../../_actions'; | |
| 19 | +import styles from './banned-word-form.module.scss'; | |
| 20 | + | |
| 21 | +export function BannedWordForm() { | |
| 22 | + const router = useRouter(); | |
| 23 | + const formRef = useRef<HTMLFormElement>(null); | |
| 24 | + const [state, formAction, isPending] = useActionState( | |
| 25 | + createBannedWordAction, | |
| 26 | + INITIAL_BANNED_WORD_FORM_STATE | |
| 27 | + ); | |
| 28 | + | |
| 29 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 30 | + | |
| 31 | + return ( | |
| 32 | + <div className={styles.page}> | |
| 33 | + <FoxPageHeader | |
| 34 | + title="금칙어 등록" | |
| 35 | + breadcrumb={[ | |
| 36 | + { label: '홈', href: '/' }, | |
| 37 | + { label: '시스템관리' }, | |
| 38 | + { label: '금칙어관리', href: BANNED_WORDS_PATH }, | |
| 39 | + { label: '금칙어 등록' }, | |
| 40 | + ]} | |
| 41 | + /> | |
| 42 | + | |
| 43 | + <FoxAlert | |
| 44 | + type="information" | |
| 45 | + message="키워드를 등록한 이후부터 해당 키워드를 사용하는 경우 바로 적용됩니다." | |
| 46 | + /> | |
| 47 | + | |
| 48 | + <form | |
| 49 | + ref={formRef} | |
| 50 | + onSubmit={(event) => submitFormAction(event, formAction)} | |
| 51 | + > | |
| 52 | + <FoxTable type="form" caption="금칙어 등록"> | |
| 53 | + <FoxTableRow> | |
| 54 | + <FoxTableCell head width={200}> | |
| 55 | + 키워드 * | |
| 56 | + </FoxTableCell> | |
| 57 | + <FoxTableCell> | |
| 58 | + <FoxInput | |
| 59 | + size="md" | |
| 60 | + name="word" | |
| 61 | + aria-label="키워드" | |
| 62 | + placeholder="키워드를 입력하세요." | |
| 63 | + maxLength={WORD_MAX_LENGTH} | |
| 64 | + invalid={Boolean(errors.word)} | |
| 65 | + message={errors.word} | |
| 66 | + /> | |
| 67 | + </FoxTableCell> | |
| 68 | + </FoxTableRow> | |
| 69 | + </FoxTable> | |
| 70 | + | |
| 71 | + <div className={styles.actions}> | |
| 72 | + <FoxButton | |
| 73 | + type="primary" | |
| 74 | + size="md" | |
| 75 | + label={isPending ? '등록 중...' : '등록'} | |
| 76 | + disabled={isPending} | |
| 77 | + onAction={() => formRef.current?.requestSubmit()} | |
| 78 | + /> | |
| 79 | + <FoxButton | |
| 80 | + type="default" | |
| 81 | + size="md" | |
| 82 | + label="목록" | |
| 83 | + onAction={() => router.push(BANNED_WORDS_PATH)} | |
| 84 | + /> | |
| 85 | + </div> | |
| 86 | + </form> | |
| 87 | + </div> | |
| 88 | + ); | |
| 89 | +} |
+++ app/(protected)/(basic)/system/banned-words/new/page.tsx
... | ... | @@ -0,0 +1,10 @@ |
| 1 | +import { verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { BannedWordForm } from './_components/banned-word-form'; | |
| 3 | + | |
| 4 | +export const dynamic = 'force-dynamic'; | |
| 5 | + | |
| 6 | +export default async function Page() { | |
| 7 | + await verifySession(); | |
| 8 | + | |
| 9 | + return <BannedWordForm />; | |
| 10 | +} |
+++ app/(protected)/(basic)/system/banned-words/page.tsx
... | ... | @@ -0,0 +1,30 @@ |
| 1 | +import { verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { fetchBannedWords } from '@/lib/data/repositories/banned-word-repository'; | |
| 3 | +import { parseBannedWordQuery } from '@/lib/domain/banned-word-query'; | |
| 4 | +import { BannedWordList } from './_components/banned-word-list'; | |
| 5 | + | |
| 6 | +export const dynamic = 'force-dynamic'; | |
| 7 | + | |
| 8 | +interface PageProps { | |
| 9 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export default async function Page({ searchParams }: PageProps) { | |
| 13 | + await verifySession(); | |
| 14 | + | |
| 15 | + const query = parseBannedWordQuery(await searchParams); | |
| 16 | + const { items, totalCount } = await fetchBannedWords(query); | |
| 17 | + | |
| 18 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 19 | + const currentPage = Math.min(query.page, totalPages); | |
| 20 | + | |
| 21 | + return ( | |
| 22 | + <BannedWordList | |
| 23 | + items={items} | |
| 24 | + query={query} | |
| 25 | + currentPage={currentPage} | |
| 26 | + totalPages={totalPages} | |
| 27 | + totalCount={totalCount} | |
| 28 | + /> | |
| 29 | + ); | |
| 30 | +} |
+++ lib/data/repositories/banned-word-repository.ts
... | ... | @@ -0,0 +1,192 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { cache } from 'react'; | |
| 3 | +import { getSessionAccessToken } from '@/lib/auth/dal'; | |
| 4 | +import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; | |
| 5 | +import type { BannedWord } from '@/lib/domain/banned-word'; | |
| 6 | +import type { BannedWordQuery } from '@/lib/domain/banned-word-query'; | |
| 7 | +import type { ExcelUploadResult } from '@/lib/domain/banned-word-form'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 금칙어 Repository — `TB_SYS_BEN_WORD`. | |
| 11 | + * | |
| 12 | + * ``` | |
| 13 | + * GET /api/v1/mngr/benWord/pagination searchCondition=1 + searchKeyword로 부분검색 | |
| 14 | + * POST /api/v1/mngr/benWord 어노테이션 없음 → form. 중복이면 실패 응답 | |
| 15 | + * DELETE /api/v1/mngr/benWord/{benWordSn} soft delete(DEL_YN) | |
| 16 | + * POST /api/v1/mngr/benWord/excel multipart, 파트 이름 `file` | |
| 17 | + * ``` | |
| 18 | + * | |
| 19 | + * ⚠️ 백엔드 결함(보고함, 미수정) | |
| 20 | + * 1. 목록 정렬이 기획과 반대다. `ROW_NUMBER() OVER (ORDER BY frst_reg_dt DESC)`로 매긴 rnum을 | |
| 21 | + * 다시 `ORDER BY RNUM DESC`로 뒤집어 **오래된 것이 먼저** 온다. 기획은 최신순이다. 그래서 | |
| 22 | + * 페이징을 백엔드에 맡기지 않고 한 번에 받아 여기서 정렬·절단한다(rnum 1 = 최신). | |
| 23 | + * 2. `DELETE`의 인자에 `@PathVariable`이 없다. 경로만 보내면 benWordSn이 null이라 아무것도 | |
| 24 | + * 지워지지 않는다. 경로와 쿼리에 같은 값을 함께 실어 보낸다 — 어노테이션이 붙어도 그대로 | |
| 25 | + * 동작한다. | |
| 26 | + */ | |
| 27 | + | |
| 28 | +const BEN_WORD_PATH = '/api/v1/mngr/benWord'; | |
| 29 | + | |
| 30 | +// TODO(백엔드): 정렬이 고쳐지면 서버 페이징으로 되돌린다. 그전까지는 이 상한을 넘는 금칙어가 | |
| 31 | +// 생기면 목록이 조용히 잘린다. | |
| 32 | +const FETCH_LIMIT = 1000; | |
| 33 | + | |
| 34 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 35 | + return value !== null && typeof value === 'object'; | |
| 36 | +} | |
| 37 | + | |
| 38 | +function readString(source: Record<string, unknown>, key: string): string | null { | |
| 39 | + const value = source[key]; | |
| 40 | + return typeof value === 'string' && value.length > 0 ? value : null; | |
| 41 | +} | |
| 42 | + | |
| 43 | +function toBannedWord(raw: unknown): BannedWord[] { | |
| 44 | + if (!isRecord(raw)) { | |
| 45 | + return []; | |
| 46 | + } | |
| 47 | + | |
| 48 | + const id = Number(raw.benWordSn); | |
| 49 | + if (!Number.isInteger(id)) { | |
| 50 | + return []; | |
| 51 | + } | |
| 52 | + | |
| 53 | + return [ | |
| 54 | + { | |
| 55 | + id, | |
| 56 | + word: readString(raw, 'benWordNm') ?? '', | |
| 57 | + createdAt: readString(raw, 'frstRegDtStr'), | |
| 58 | + }, | |
| 59 | + ]; | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** rnum 1이 최신이라 오름차순이 곧 최신순이다. */ | |
| 63 | +function byNewestFirst(raw: unknown): number { | |
| 64 | + return isRecord(raw) ? Number(raw.rnum ?? 0) : 0; | |
| 65 | +} | |
| 66 | + | |
| 67 | +const fetchAllBannedWords = cache( | |
| 68 | + async (keyword: string): Promise<BannedWord[]> => { | |
| 69 | + const accessToken = await getSessionAccessToken(); | |
| 70 | + | |
| 71 | + const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/pagination`, { | |
| 72 | + method: 'GET', | |
| 73 | + query: { | |
| 74 | + pageIndex: 1, | |
| 75 | + recordCountPerPage: FETCH_LIMIT, | |
| 76 | + ...(keyword ? { searchCondition: '1', searchKeyword: keyword } : {}), | |
| 77 | + }, | |
| 78 | + accessToken: accessToken ?? undefined, | |
| 79 | + cache: 'no-store', | |
| 80 | + }); | |
| 81 | + | |
| 82 | + if (!result.ok) { | |
| 83 | + throw new BackendRequestError(result); | |
| 84 | + } | |
| 85 | + | |
| 86 | + const data = result.data; | |
| 87 | + if (!isRecord(data) || !Array.isArray(data.list)) { | |
| 88 | + throw new Error('금칙어 목록 응답의 형식이 올바르지 않습니다.'); | |
| 89 | + } | |
| 90 | + | |
| 91 | + return [...data.list] | |
| 92 | + .sort((a, b) => byNewestFirst(a) - byNewestFirst(b)) | |
| 93 | + .flatMap(toBannedWord); | |
| 94 | + } | |
| 95 | +); | |
| 96 | + | |
| 97 | +export async function fetchBannedWords( | |
| 98 | + query: BannedWordQuery | |
| 99 | +): Promise<{ items: BannedWord[]; totalCount: number }> { | |
| 100 | + const all = await fetchAllBannedWords(query.keyword); | |
| 101 | + const start = (query.page - 1) * query.pageSize; | |
| 102 | + | |
| 103 | + return { | |
| 104 | + items: all.slice(start, start + query.pageSize), | |
| 105 | + totalCount: all.length, | |
| 106 | + }; | |
| 107 | +} | |
| 108 | + | |
| 109 | +export type CreateBannedWordResult = | |
| 110 | + | { status: 'success' } | |
| 111 | + | { status: 'duplicate' }; | |
| 112 | + | |
| 113 | +export async function createBannedWord( | |
| 114 | + word: string | |
| 115 | +): Promise<CreateBannedWordResult> { | |
| 116 | + const accessToken = await getSessionAccessToken(); | |
| 117 | + | |
| 118 | + const result = await backendFetch<unknown>(BEN_WORD_PATH, { | |
| 119 | + method: 'POST', | |
| 120 | + form: { benWordNm: word }, | |
| 121 | + accessToken: accessToken ?? undefined, | |
| 122 | + cache: 'no-store', | |
| 123 | + canHaveNullData: true, | |
| 124 | + canHaveEmptyBody: true, | |
| 125 | + }); | |
| 126 | + | |
| 127 | + if (!result.ok) { | |
| 128 | + // 중복은 정상 흐름이라 예외로 올리지 않는다 — 백엔드가 "이미 등록된 단어 입니다"로 답한다. | |
| 129 | + if (result.message.includes('이미 등록된')) { | |
| 130 | + return { status: 'duplicate' }; | |
| 131 | + } | |
| 132 | + throw new BackendRequestError(result); | |
| 133 | + } | |
| 134 | + | |
| 135 | + return { status: 'success' }; | |
| 136 | +} | |
| 137 | + | |
| 138 | +export async function deleteBannedWord(id: number): Promise<void> { | |
| 139 | + const accessToken = await getSessionAccessToken(); | |
| 140 | + | |
| 141 | + const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/${id}`, { | |
| 142 | + method: 'DELETE', | |
| 143 | + query: { benWordSn: id }, | |
| 144 | + accessToken: accessToken ?? undefined, | |
| 145 | + cache: 'no-store', | |
| 146 | + canHaveNullData: true, | |
| 147 | + canHaveEmptyBody: true, | |
| 148 | + }); | |
| 149 | + | |
| 150 | + if (!result.ok) { | |
| 151 | + throw new BackendRequestError(result); | |
| 152 | + } | |
| 153 | +} | |
| 154 | + | |
| 155 | +export async function uploadBannedWordExcel( | |
| 156 | + file: File | |
| 157 | +): Promise<ExcelUploadResult> { | |
| 158 | + const accessToken = await getSessionAccessToken(); | |
| 159 | + | |
| 160 | + const multipart = new FormData(); | |
| 161 | + multipart.set('file', file); | |
| 162 | + | |
| 163 | + const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/excel`, { | |
| 164 | + method: 'POST', | |
| 165 | + multipart, | |
| 166 | + accessToken: accessToken ?? undefined, | |
| 167 | + cache: 'no-store', | |
| 168 | + canHaveNullData: true, | |
| 169 | + }); | |
| 170 | + | |
| 171 | + const data = result.ok ? result.data : null; | |
| 172 | + const errors = | |
| 173 | + isRecord(data) && Array.isArray(data.errors) | |
| 174 | + ? data.errors.filter((entry): entry is string => typeof entry === 'string') | |
| 175 | + : []; | |
| 176 | + | |
| 177 | + if (!result.ok) { | |
| 178 | + // 검증 실패는 fail(300) + data.errors로 온다. 통신 오류와 구분해 화면에 그대로 보여준다. | |
| 179 | + if (errors.length > 0) { | |
| 180 | + return { successCount: 0, errors }; | |
| 181 | + } | |
| 182 | + throw new BackendRequestError(result); | |
| 183 | + } | |
| 184 | + | |
| 185 | + return { | |
| 186 | + successCount: | |
| 187 | + isRecord(data) && Number.isInteger(Number(data.successCount)) | |
| 188 | + ? Number(data.successCount) | |
| 189 | + : 0, | |
| 190 | + errors, | |
| 191 | + }; | |
| 192 | +} |
+++ lib/domain/banned-word-form.ts
... | ... | @@ -0,0 +1,41 @@ |
| 1 | +export const WORD_MAX_LENGTH = 100; | |
| 2 | + | |
| 3 | +export type BannedWordFormErrors = Partial<Record<'word', string>>; | |
| 4 | + | |
| 5 | +export type BannedWordFormState = | |
| 6 | + | { status: 'idle' } | |
| 7 | + | { status: 'error'; message?: string; errors?: BannedWordFormErrors } | |
| 8 | + | { status: 'success' }; | |
| 9 | + | |
| 10 | +export const INITIAL_BANNED_WORD_FORM_STATE: BannedWordFormState = { | |
| 11 | + status: 'idle', | |
| 12 | +}; | |
| 13 | + | |
| 14 | +/** 기획 SYS_WRD_002 ② — 중복은 백엔드가 판정하고 이 문구로 알린다. */ | |
| 15 | +export const DUPLICATE_WORD_MESSAGE = '중복된 키워드입니다.'; | |
| 16 | +export const REQUIRED_MESSAGE = '필수입력사항을 입력하세요.'; | |
| 17 | + | |
| 18 | +type ValidationResult = | |
| 19 | + | { ok: true; value: string } | |
| 20 | + | { ok: false; errors: BannedWordFormErrors }; | |
| 21 | + | |
| 22 | +export function validateBannedWord(raw: string): ValidationResult { | |
| 23 | + const value = raw.trim(); | |
| 24 | + | |
| 25 | + if (!value) { | |
| 26 | + return { ok: false, errors: { word: REQUIRED_MESSAGE } }; | |
| 27 | + } | |
| 28 | + if (value.length > WORD_MAX_LENGTH) { | |
| 29 | + return { | |
| 30 | + ok: false, | |
| 31 | + errors: { word: `키워드는 ${WORD_MAX_LENGTH}자 이내로 입력해 주세요.` }, | |
| 32 | + }; | |
| 33 | + } | |
| 34 | + | |
| 35 | + return { ok: true, value }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export type ExcelUploadResult = { | |
| 39 | + successCount: number; | |
| 40 | + errors: string[]; | |
| 41 | +}; |
+++ lib/domain/banned-word-query.ts
... | ... | @@ -0,0 +1,66 @@ |
| 1 | +export const BANNED_WORDS_PATH = '/system/banned-words'; | |
| 2 | +export const BANNED_WORD_NEW_PATH = `${BANNED_WORDS_PATH}/new`; | |
| 3 | +/** 엑셀 업로드 양식 다운로드 — 파일 응답이라 라우트 핸들러가 받는다. */ | |
| 4 | +export const BANNED_WORD_EXCEL_TEMPLATE_PATH = `${BANNED_WORDS_PATH}/excel-template`; | |
| 5 | + | |
| 6 | +export const BANNED_WORD_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 7 | +export type BannedWordPageSize = | |
| 8 | + (typeof BANNED_WORD_PAGE_SIZE_OPTIONS)[number]; | |
| 9 | + | |
| 10 | +export const DEFAULT_BANNED_WORD_PAGE_SIZE: BannedWordPageSize = 10; | |
| 11 | +const DEFAULT_PAGE = 1; | |
| 12 | +const MAX_KEYWORD_LENGTH = 100; | |
| 13 | + | |
| 14 | +export type BannedWordQuery = { | |
| 15 | + keyword: string; | |
| 16 | + page: number; | |
| 17 | + pageSize: BannedWordPageSize; | |
| 18 | +}; | |
| 19 | + | |
| 20 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 21 | + | |
| 22 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 23 | + const value = params[key]; | |
| 24 | + return Array.isArray(value) ? value[0] : value; | |
| 25 | +} | |
| 26 | + | |
| 27 | +function isBannedWordPageSize(value: number): value is BannedWordPageSize { | |
| 28 | + return (BANNED_WORD_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function parseBannedWordQuery( | |
| 32 | + searchParams: RawSearchParams | |
| 33 | +): BannedWordQuery { | |
| 34 | + const keywordRaw = readParam(searchParams, 'keyword'); | |
| 35 | + const pageRaw = Number(readParam(searchParams, 'page')); | |
| 36 | + const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); | |
| 37 | + | |
| 38 | + return { | |
| 39 | + keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 40 | + page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, | |
| 41 | + pageSize: isBannedWordPageSize(pageSizeRaw) | |
| 42 | + ? pageSizeRaw | |
| 43 | + : DEFAULT_BANNED_WORD_PAGE_SIZE, | |
| 44 | + }; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function buildBannedWordHref( | |
| 48 | + query: BannedWordQuery, | |
| 49 | + overrides: Partial<BannedWordQuery> = {} | |
| 50 | +): string { | |
| 51 | + const merged = { ...query, ...overrides }; | |
| 52 | + const params = new URLSearchParams(); | |
| 53 | + | |
| 54 | + if (merged.keyword) { | |
| 55 | + params.set('keyword', merged.keyword); | |
| 56 | + } | |
| 57 | + if (merged.pageSize !== DEFAULT_BANNED_WORD_PAGE_SIZE) { | |
| 58 | + params.set('pageSize', String(merged.pageSize)); | |
| 59 | + } | |
| 60 | + if (merged.page !== DEFAULT_PAGE) { | |
| 61 | + params.set('page', String(merged.page)); | |
| 62 | + } | |
| 63 | + | |
| 64 | + const queryString = params.toString(); | |
| 65 | + return queryString ? `${BANNED_WORDS_PATH}?${queryString}` : BANNED_WORDS_PATH; | |
| 66 | +} |
+++ lib/domain/banned-word.ts
... | ... | @@ -0,0 +1,15 @@ |
| 1 | +export interface BannedWord { | |
| 2 | + id: number; | |
| 3 | + word: string; | |
| 4 | + createdAt: string | null; | |
| 5 | +} | |
| 6 | + | |
| 7 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 8 | + | |
| 9 | +export function formatOptionalValue( | |
| 10 | + value: string | number | null | undefined | |
| 11 | +): string { | |
| 12 | + return value === null || value === undefined || value === '' | |
| 13 | + ? EMPTY_FIELD_PLACEHOLDER | |
| 14 | + : String(value); | |
| 15 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?