File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 게시판(공지사항·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<BoardType, string> = {
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}`;
}