/** * 게시판(공지사항·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'; /** * 백엔드 `stngId`(게시판 설정 ID) — **임시값이다.** * * 백엔드에 게시판 설정 목록 API도 테이블도 없고 소스에는 예시값 하나 * (`5e9ae040df1b41c1b03ce11d1bafcc60`)만 있어 세 게시판 중 무엇인지 알 수 없다. 사용자 확인 결과 * **"추후 백엔드에 추가 예정, 지금은 임의값으로 개발"** 이라 아래 값을 쓴다. * * 실제 값이 나오면 이 표만 고치면 된다 — 나머지 코드는 `BoardType`만 다루고 이 값을 직접 알지 * 못한다. 그때까지 목록 조회는 빈 결과가 정상이다(존재하지 않는 stngId라 매칭되는 행이 없다). */ export const BOARD_SETTING_IDS: Record = { notice: 'TEMP_STNG_ID_NOTICE', inquiry: 'TEMP_STNG_ID_INQUIRY', faq: 'TEMP_STNG_ID_FAQ', }; /** * 파일 업로드 API(`POST /api/v1/common/file/upload/{moduleId}`)의 moduleId — **임시값이다.** * 백엔드에서 이 값은 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리키며, 설정에 없는 값이면 * `UNKNOWN` 경로로 떨어진다(EgovFileMngUtil). 실제 설정 ID가 정해지면 여기만 고친다. */ export const BOARD_FILE_MODULE_ID = 'MODULE_BBS'; export type BoardPost = { /** 백엔드 `bbsId`. 행의 key이자 단건 조회·수정·삭제의 입력값이다. */ id: string; /** 제목 — 백엔드 `bbsNm`. */ title: string; /** 본문 — 백엔드 `bbsCn`. */ content: string; /** 구분 코드 — 백엔드 `bbsCd`. */ categoryCode: string; /** * 유형 코드(FAQ의 유형 / 1:1문의의 질문유형). * **백엔드에 아직 저장 필드가 없다** — `BOARD_TYPE_CODE_PARAM` 주석 참조. 조회 시 항상 null이다. */ typeCode: string | null; /** 첨부파일 식별자 — 백엔드 `atchFileId`. 파일명·용량 조회 API가 없어 존재 여부만 알 수 있다. */ attachmentId: string | null; /** 상단고정 — 백엔드 `hghrkYn`. */ isPinned: boolean | null; /** 사용여부(노출여부) — 백엔드 `rlsYn`. */ isVisible: boolean | null; /** 노출 시작일. 백엔드 조회 응답에 없어 현재는 항상 null이다(파일 상단 주석). */ startDate: string | null; /** 노출 종료일. 위와 같다. */ endDate: string | 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); } /** * 「유형」을 실어 보낼 요청 파라미터 이름 — **임시 이름이다.** * * 시안은 구분과 유형을 별도 항목으로 요구하지만 백엔드에는 `BBS_CD` 하나뿐이고, 등록/수정 요청 * VO(`MngrBbsRequestVo`)에는 그마저도 없다. 사용자 확인 결과 **"백엔드가 필드를 추가할 예정이니 * 프론트는 미리 구현"** 이라, 구분은 `bbsCd`로 유형은 이 이름으로 보낸다. * * 백엔드가 실제 이름을 확정하면 이 상수 한 줄만 고치면 된다. 그전까지 백엔드는 이 파라미터를 * 무시하므로 **유형은 저장되지 않는다**(화면 입력과 전송은 정상 동작한다). */ export const BOARD_TYPE_CODE_PARAM = 'bbsTypeCd'; type CodeOption = { value: string; label: string }; /** * 「구분」 코드 표 — **임시값이다.** 백엔드에 코드 상수도 코드테이블도 없어 시안의 라벨을 기준으로 * 잠정 코드를 붙였다. 실제 코드가 확정되면 이 표만 교체한다. * * 시안의 공지사항 구분은 "공통 / 메뉴명1, 메뉴명2…"이고 FAQ는 "공통 / FOX PAY"다. 확정되지 않은 * "메뉴명N"은 임의로 지어내지 않고 두 화면이 공통으로 쓰는 값만 둔다. */ export const BOARD_CATEGORY_OPTIONS: readonly CodeOption[] = [ { value: 'COMMON', label: '공통' }, { value: 'FOXPAY', label: 'FOX PAY' }, ]; /** FAQ의 「유형」 — 시안 A_BOA_015_p ②. 시안에도 "추후 재정의 필요"로 적혀 있어 잠정값이다. */ export const FAQ_TYPE_OPTIONS: readonly CodeOption[] = [ { value: 'SIGNUP', label: '회원가입&로그인' }, { value: 'PAYMENT', label: '결제관련' }, { value: 'STUDENT_CARD', label: '학생증' }, { value: 'DECO_ITEM', label: '꾸미기아이템' }, { value: 'MERCHANT', label: '가맹점관련' }, { value: 'ETC', label: '기타' }, ]; /** 1:1문의의 「질문유형」 — 시안 A_BOA_012. 잠정값이다. */ export const INQUIRY_TYPE_OPTIONS: readonly CodeOption[] = [ { value: 'SIGNUP', label: '회원가입/로그인' }, { value: 'ERROR', label: '오류신고' }, { value: 'ETC', label: '기타' }, ]; /** * 1:1문의 「진행상태」(`ansSttsCd`) — **임시값이다.** 백엔드에 코드 정의가 없어 사용자 확인 후 * 잠정 코드를 한 곳에 모아 두는 방침으로 정했다. 실제 코드가 나오면 이 표만 교체한다. */ export const ANSWER_STATUS_OPTIONS: readonly CodeOption[] = [ { value: 'WAIT', label: '답변대기' }, { value: 'ING', label: '처리중' }, { value: 'DONE', label: '답변완료' }, ]; /** 답변완료 상태 코드 — "답변일 표시"·"답변 완료 시 푸쉬" 판단이 이 값을 참조한다. */ export const ANSWER_STATUS_DONE = 'DONE'; /** 코드 → 라벨. 표에 없는 코드는 지어내지 않고 코드 그대로 노출한다(잘못된 라벨보다 낫다). */ 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); } export function formatFaqTypeLabel(code: string | null): string { return formatCode(FAQ_TYPE_OPTIONS, code); } export function formatInquiryTypeLabel(code: string | null): string { return formatCode(INQUIRY_TYPE_OPTIONS, code); } export function formatAnswerStatusLabel(code: string | null): string { return formatCode(ANSWER_STATUS_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}`; }