/** * 게시판(공지사항·1:1문의·FAQ) 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. * * 세 화면은 **백엔드에서 같은 테이블(TB_COM_BBS)·같은 API**를 쓰고 `stngId`(게시판 설정 ID)로만 * 갈린다. 그래서 타입과 코드 표는 여기 한 곳에 모으고, 화면별로 다른 것(검색 조건·정렬·컬럼)만 * 각자의 query 파일에서 다룬다. * * 데이터 출처: `GET /api/v1/mngr/bbs/pagination` (edupay-backend develop 924db37) * * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 노출기간·앱푸쉬는 등록/수정으로 저장은 * 되지만 조회 SQL의 select 목록에 빠져 있어 다시 읽으면 항상 비어 있다(사용자 확인 후 "입력은 * 만들고 전송하되 조회는 빈 값" 방침). 백엔드가 select에 컬럼을 추가하면 Repository 매핑만 * 늘리면 값이 그대로 채워진다. */ /** 세 화면을 구분하는 값 — 라우트·API 파라미터·코드 표의 키를 겸한다. */ export type BoardType = 'notice' | 'inquiry' | 'faq'; /** * TODO: 추후 메뉴 데이터에 실려 오면 이 상수를 지우고 그 값을 쓴다. * `GET /api/v1/common/bbs/stng/list`의 실제 값(typeSe는 셋 다 'NOT'이라 구분에 쓸 수 없다). */ export const BOARD_SETTING_IDS: Record = { notice: '5e9ae040df1b41c1b03ce11d1bafcc60', inquiry: '4538fa2b88cc458da5cae972f39a1ebd', faq: '9f3d4c7b2a3e4b7d8c1f6a9e2b5c7d10', }; export type BoardPost = { /** 백엔드 `bbsId`. 행의 key이자 단건 조회·수정·삭제의 입력값이다. */ id: string; /** 제목 — 백엔드 `bbsNm`. */ title: string; /** 본문 — 백엔드 `bbsCn`. */ content: string; /** 구분 코드. 백엔드에 해당 값이 아직 없다(협의 중) — 조회 시 항상 null이다. */ categoryCode: string | null; /** 유형 코드(FAQ의 유형 / 1:1문의의 질문유형) — 백엔드 `bbsCd`(BBS_FAQ_CD 코드). */ typeCode: string | null; /** 유형 이름 — 백엔드 `bbsFaqNm`(코드관리 BBS_FAQ_CD를 서버가 조인해 준다). */ typeName: string | null; /** 첨부파일 식별자 — 백엔드 `atchFileId`. 파일명·용량 조회 API가 없어 존재 여부만 알 수 있다. */ attachmentId: string | null; /** 상단고정 — 백엔드 `hghrkYn`. */ isPinned: boolean | null; /** 사용여부(노출여부) — 백엔드 `rlsYn`. */ isVisible: boolean | null; /** 노출 시작일(YYYY-MM-DD) — 백엔드 `startDt`. */ startDate: string | null; /** 노출 종료일 — 백엔드 `endDt`. */ endDate: string | null; /** 앱푸쉬 발송 여부. 조회 SQL에 PUSH_YN이 없어 아직 항상 null이다. */ isPushEnabled: boolean | null; /** 작성자 표기 — 백엔드 `regNm`(이름(로그인ID) 형태로 이미 조합돼 온다). */ authorName: string | null; /** 작성일(YYYY-MM-DD) — 백엔드 `regDt`. */ createdAt: string | null; /** 조회수 — 백엔드 `inqCnt`. */ viewCount: number | null; /* ── 1:1문의 전용 ── */ /** 질문자 연락처 — 백엔드 `telNo`. */ authorPhoneNumber: string | null; /** 질문자 이메일 — 백엔드 `emlAddr`. */ authorEmail: string | null; /** 답변 내용 — 백엔드 `ansCn`. */ answerContent: string | null; /** 답변일 — 백엔드 `ansDt`. */ answeredAt: string | null; /** 답변자 표기 — 백엔드 `answrNm`. */ answererName: string | null; /** 진행상태 코드 — 백엔드 `ansSttsCd`. */ answerStatusCode: string | null; }; /** 값이 없는 항목의 화면 표기. 표·팝업이 같은 문자를 쓰도록 여기 한 곳에 둔다. */ export const EMPTY_FIELD_PLACEHOLDER = '-'; export function formatOptionalValue(value: string | number | null): string { return value === null || value === '' ? EMPTY_FIELD_PLACEHOLDER : String(value); } type CodeOption = { value: string; label: string }; /** * 「구분」 코드 표 — **임시값이다.** 백엔드에 코드 상수도 코드테이블도 없어 시안의 라벨을 기준으로 * 잠정 코드를 붙였다. 실제 코드가 확정되면 이 표만 교체한다. * * 시안의 공지사항 구분은 "공통 / 메뉴명1, 메뉴명2…"이고 FAQ는 "공통 / FOX PAY"다. 확정되지 않은 * "메뉴명N"은 임의로 지어내지 않고 두 화면이 공통으로 쓰는 값만 둔다. */ /** * 「구분」을 화면에 낼지. **지금은 백엔드에 담을 자리가 없다** — 등록·수정 요청 VO에 필드가 * 없고, 응답의 코드 필드(`bbsCd`)는 구분이 아니라 「유형」이다. 그래서 골라도 저장되지 않고 * 조회하면 항상 비어 있으며, 검색에 쓰면 목록이 통째로 빈다. * * 백엔드에 필드가 생기면 **이 한 줄만 `true`로 바꾸면** 등록 팝업의 칸·목록의 열·검색 대상이 * 함께 돌아온다. */ export const IS_BOARD_CATEGORY_ENABLED = false; export const BOARD_CATEGORY_OPTIONS: readonly CodeOption[] = [ { value: 'COMMON', label: '공통' }, { value: 'FOXPAY', label: 'FOX PAY' }, ]; /** 1:1문의의 「질문유형」 — 시안 A_BOA_012. 잠정값이다. */ export const INQUIRY_TYPE_OPTIONS: readonly CodeOption[] = [ { value: 'SIGNUP', label: '회원가입/로그인' }, { value: 'ERROR', label: '오류신고' }, { value: 'ETC', label: '기타' }, ]; /** * 1:1문의 「진행상태」(`ansSttsCd`) — **임시값이다.** 백엔드에 코드 정의가 없어 사용자 확인 후 * 잠정 코드를 한 곳에 모아 두는 방침으로 정했다. 실제 코드가 나오면 이 표만 교체한다. */ // TODO: 답변완료를 가리키는 코드값. BBS_ANS_CD의 실제 값이 확인되면 교체한다. export const ANSWER_STATUS_DONE = 'DONE'; /** 목록 한 칸·확인 문구에 넣을 본문 발췌 — 줄바꿈을 접고 길면 말줄임한다. */ export function formatContentExcerpt(content: string, maxLength = 60): string { const flat = content.replace(/\s+/g, ' ').trim(); if (flat === '') { return EMPTY_FIELD_PLACEHOLDER; } return flat.length > maxLength ? `${flat.slice(0, maxLength)}…` : flat; } /** 코드 → 라벨. 표에 없는 코드는 지어내지 않고 코드 그대로 노출한다(잘못된 라벨보다 낫다). */ function formatCode(options: readonly CodeOption[], code: string | null): string { if (code === null || code === '') { return EMPTY_FIELD_PLACEHOLDER; } return options.find((option) => option.value === code)?.label ?? code; } export function formatBoardCategoryLabel(code: string | null): string { return formatCode(BOARD_CATEGORY_OPTIONS, code); } /** FAQ의 「유형」 — 공통코드 `BBS_FAQ_CD`. 코드표는 호출부가 조회해 넘긴다. */ export function formatFaqTypeLabel( code: string | null, options: readonly CodeOption[] ): string { return formatCode(options, code); } export function formatInquiryTypeLabel(code: string | null): string { return formatCode(INQUIRY_TYPE_OPTIONS, code); } export function formatAnswerStatusLabel( code: string | null, options: readonly CodeOption[] ): string { return formatCode(options, code); } /** 사용여부 표기 — 시안의 「노출 / 미노출」. */ export function formatVisibilityLabel(isVisible: boolean | null): string { if (isVisible === null) { return EMPTY_FIELD_PLACEHOLDER; } return isVisible ? '노출' : '미노출'; } /** * 1:1문의 목록의 전화번호 마스킹(시안 A_BOA_012 — `010-12**-**78`). * * 사용자 확인 후 **적용**하기로 한 항목이다(학생 회원 목록은 반대로 "마스킹 없음"이 지시였다 — * 화면마다 방침이 다르므로 이 함수를 1:1문의에서만 쓴다). * * 가운데 자리는 앞 2자리만, 끝자리는 뒤 2자리만 남긴다. 형식이 `nnn-nnnn-nnnn`이 아니면 마스킹 * 규칙을 적용할 자리를 알 수 없으므로 **전부 가린다** — 규칙 밖의 값을 원본 그대로 흘리는 것이 * 마스킹의 목적에 어긋나기 때문이다. */ export function maskPhoneNumber(phoneNumber: string | null): string { if (phoneNumber === null || phoneNumber === '') { return EMPTY_FIELD_PLACEHOLDER; } const parts = phoneNumber.split('-'); if (parts.length !== 3) { return '***'; } const [prefix, middle, last] = parts; const maskedMiddle = middle.slice(0, 2).padEnd(middle.length, '*'); const maskedLast = '*'.repeat(Math.max(0, last.length - 2)) + last.slice(-2); return `${prefix}-${maskedMiddle}-${maskedLast}`; }