+++ @fox/core/components/fox-heading-group/fox-heading-group.tsx
... | ... | @@ -0,0 +1,64 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxHeadingGroupProps { | |
| 5 | + /** 구역 제목. 화면 제목(`FoxPageHeader`)이 아니라 그 아래 구역의 이름이다. */ | |
| 6 | + title: ReactNode; | |
| 7 | + /** | |
| 8 | + * 제목을 그릴 태그. 한 화면에 여러 구역이 서면 문서 구조가 어긋나지 않게 단계를 고른다. | |
| 9 | + * 화면 제목은 `FoxPageHeader`가 `<h1>`으로 그리므로 여기 기본은 `h2`다. | |
| 10 | + */ | |
| 11 | + as?: "h2" | "h3" | "h4"; | |
| 12 | + /** 제목 아래 한 줄 설명. 넘기지 않으면 영역을 그리지 않는다. */ | |
| 13 | + description?: ReactNode; | |
| 14 | + /** 제목 오른쪽 자리. 보통 버튼이다. */ | |
| 15 | + actions?: ReactNode; | |
| 16 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 17 | + hidden?: boolean; | |
| 18 | + /** 배치 조정용. */ | |
| 19 | + className?: string; | |
| 20 | + ref?: Ref<HTMLDivElement>; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * @fox 구역 머리말 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) heading-group (3002:7177) | |
| 25 | + * | |
| 26 | + * 한 화면 안에서 목록·표 같은 구역을 이름 짓는다. `FoxPageHeader`와 역할이 다르다 — 그쪽은 | |
| 27 | + * 화면에 하나뿐인 제목(`<h1>`)과 현재 위치를 갖고, 이쪽은 화면 안에 여럿 설 수 있다. | |
| 28 | + * 그래서 제목 태그를 `as`로 고를 수 있고 breadcrumb이 없다. | |
| 29 | + * | |
| 30 | + * 아래 여백은 이 조각이 갖는다(시안 spacing/bottom/md) — 뒤따르는 도구 줄·표가 간격을 따로 | |
| 31 | + * 두지 않아도 되게 하려는 것이고, `FoxPageHeader`와 같은 규칙이다. | |
| 32 | + * | |
| 33 | + * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다. | |
| 34 | + * | |
| 35 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 36 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 37 | + */ | |
| 38 | +export function FoxHeadingGroup({ | |
| 39 | + title, | |
| 40 | + as: Tag = "h2", | |
| 41 | + description, | |
| 42 | + actions, | |
| 43 | + hidden = false, | |
| 44 | + className, | |
| 45 | + ref, | |
| 46 | +}: FoxHeadingGroupProps) { | |
| 47 | + if (hidden) { | |
| 48 | + return null; | |
| 49 | + } | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <div ref={ref} className={cx("fox-heading-group", className)}> | |
| 53 | + <div className="fox-heading-group__text"> | |
| 54 | + <div className="fox-heading-group__heading"> | |
| 55 | + <Tag className="fox-heading-group__title">{title}</Tag> | |
| 56 | + </div> | |
| 57 | + {description && ( | |
| 58 | + <p className="fox-heading-group__description">{description}</p> | |
| 59 | + )} | |
| 60 | + </div> | |
| 61 | + {actions && <div className="fox-heading-group__actions">{actions}</div>} | |
| 62 | + </div> | |
| 63 | + ); | |
| 64 | +} |
+++ @fox/core/components/fox-heading-group/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxHeadingGroup, type FoxHeadingGroupProps } from "./fox-heading-group"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -26,6 +26,7 @@ |
| 26 | 26 |
export * from "./fox-email"; |
| 27 | 27 |
export * from "./fox-file-upload"; |
| 28 | 28 |
export * from "./fox-form-label"; |
| 29 |
+export * from "./fox-heading-group"; |
|
| 29 | 30 |
export * from "./fox-helper-text"; |
| 30 | 31 |
export * from "./fox-icon-button"; |
| 31 | 32 |
export * from "./fox-input"; |
+++ @fox/styles/_fox-heading-group.scss
... | ... | @@ -0,0 +1,70 @@ |
| 1 | +// FoxHeadingGroup — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) heading-group (3002:7177) | |
| 2 | +// | |
| 3 | +// 마크업 계약 (React 밖 소비자용): | |
| 4 | +// <div class="fox-heading-group"> | |
| 5 | +// <div class="fox-heading-group__text"> | |
| 6 | +// <div class="fox-heading-group__heading"><h2 class="fox-heading-group__title">제목</h2></div> | |
| 7 | +// <p class="fox-heading-group__description">설명</p> <!-- 선택 --> | |
| 8 | +// </div> | |
| 9 | +// <div class="fox-heading-group__actions">…</div> <!-- 선택 --> | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 제목 줄이 따로 있는 이유는 시안이 그 안에 아이콘·배지를 함께 세울 자리를 두기 때문이다 | |
| 13 | +// (지금은 제목 하나뿐이지만 8px 간격이 이미 잡혀 있다). | |
| 14 | +// | |
| 15 | +// 토큰이 아닌 값: | |
| 16 | +// - line-height / letter-spacing: 시안 텍스트 스타일 display/sm의 값이나 Figma가 변수로 | |
| 17 | +// export하지 않는다. -0.65px은 26px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은 근거). | |
| 18 | +// - margin 0: 제목 태그의 브라우저 기본값 지우기. | |
| 19 | + | |
| 20 | +@use "abstracts" as fox; | |
| 21 | + | |
| 22 | +.fox-heading-group { | |
| 23 | + display: flex; | |
| 24 | + inline-size: 100%; | |
| 25 | + align-items: center; | |
| 26 | + gap: fox.gap(5); | |
| 27 | + // 뒤따르는 도구 줄·표가 간격을 따로 두지 않게 이 조각이 아래 여백을 갖는다. | |
| 28 | + padding-block-end: fox.spacing(bottom-md); | |
| 29 | + | |
| 30 | + &__text { | |
| 31 | + display: flex; | |
| 32 | + flex: 1 0 0; | |
| 33 | + min-inline-size: 0; | |
| 34 | + flex-direction: column; | |
| 35 | + } | |
| 36 | + | |
| 37 | + &__heading { | |
| 38 | + display: flex; | |
| 39 | + inline-size: 100%; | |
| 40 | + align-items: center; | |
| 41 | + gap: fox.gap(3); | |
| 42 | + } | |
| 43 | + | |
| 44 | + &__title { | |
| 45 | + margin: 0; | |
| 46 | + color: fox.color(font-neutral-strong); | |
| 47 | + font-family: fox.font-family(title); | |
| 48 | + font-size: fox.font-size(display-sm); | |
| 49 | + font-weight: fox.font-weight(bold); | |
| 50 | + line-height: 1.5; | |
| 51 | + letter-spacing: -0.025em; | |
| 52 | + } | |
| 53 | + | |
| 54 | + &__description { | |
| 55 | + margin: 0; | |
| 56 | + color: fox.color(font-neutral-subtle); | |
| 57 | + font-family: fox.font-family(body); | |
| 58 | + font-size: fox.font-size(body-md); | |
| 59 | + font-weight: fox.font-weight(regular); | |
| 60 | + line-height: 1.5; | |
| 61 | + letter-spacing: -0.025em; | |
| 62 | + } | |
| 63 | + | |
| 64 | + &__actions { | |
| 65 | + display: flex; | |
| 66 | + flex-shrink: 0; | |
| 67 | + align-items: center; | |
| 68 | + gap: fox.gap(3); | |
| 69 | + } | |
| 70 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -20,6 +20,7 @@ |
| 20 | 20 |
@use "fox-email"; |
| 21 | 21 |
@use "fox-file-upload"; |
| 22 | 22 |
@use "fox-form-label"; |
| 23 |
+@use "fox-heading-group"; |
|
| 23 | 24 |
@use "fox-helper-text"; |
| 24 | 25 |
@use "fox-input"; |
| 25 | 26 |
@use "fox-phone-number"; |
+++ app/(protected)/(basic)/system/codes/_actions.ts
... | ... | @@ -0,0 +1,217 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + createCodeDetail, | |
| 7 | + createCodeGroup, | |
| 8 | + deleteCodeDetail, | |
| 9 | + deleteCodeGroup, | |
| 10 | + updateCodeDetail, | |
| 11 | + updateCodeGroup, | |
| 12 | +} from '@/lib/data/repositories/common-code-repository'; | |
| 13 | +import { | |
| 14 | + validateCommonCodeDetail, | |
| 15 | + validateCommonCodeGroup, | |
| 16 | + type CommonCodeFormState, | |
| 17 | +} from '@/lib/domain/common-code-form'; | |
| 18 | +import { COMMON_CODES_PATH } from '@/lib/domain/common-code-query'; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * 코드관리 등록/수정/삭제 Server Action(기획 SYS_COD_001). | |
| 22 | + * | |
| 23 | + * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접 | |
| 24 | + * POST될 수 있어 이 확인이 유일한 최종 방어선이다. | |
| 25 | + * | |
| 26 | + * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/common-code-form.ts`의 규칙을 호출) — | |
| 27 | + * 화면의 required 속성과 읽기 전용 표시는 편의일 뿐 신뢰 경계가 아니다. | |
| 28 | + * | |
| 29 | + * **수정 대상(코드ID)은 읽기 전용 입력이 아니라 hidden 필드에서 읽는다** — 읽기 전용 칸은 | |
| 30 | + * 위조될 수 있고, 위조되더라도 대상이 바뀌면 안 되기 때문이다. | |
| 31 | + */ | |
| 32 | + | |
| 33 | +const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; | |
| 34 | +const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 35 | +const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 36 | + | |
| 37 | +function readString(formData: FormData, key: string): string { | |
| 38 | + const value = formData.get(key); | |
| 39 | + return typeof value === 'string' ? value : ''; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 함정을 피하려고 빈 문자열은 | |
| 44 | + * 명시적으로 `NaN`으로 둔다 — 그래야 검증이 "값이 비었음"을 잡아낸다. | |
| 45 | + */ | |
| 46 | +function parseFormNumber(formData: FormData, key: string): number { | |
| 47 | + const trimmed = readString(formData, key).trim(); | |
| 48 | + return trimmed === '' ? NaN : Number(trimmed); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/* ── 공통코드 ─────────────────────────────────────────────────────────────── */ | |
| 52 | + | |
| 53 | +/** 기획 [공통코드 등록] 팝업. */ | |
| 54 | +export async function createCodeGroupAction( | |
| 55 | + _prevState: CommonCodeFormState, | |
| 56 | + formData: FormData | |
| 57 | +): Promise<CommonCodeFormState> { | |
| 58 | + await verifySession(); | |
| 59 | + | |
| 60 | + const validation = validateCommonCodeGroup({ | |
| 61 | + comCd: readString(formData, 'comCd'), | |
| 62 | + name: readString(formData, 'name'), | |
| 63 | + description: readString(formData, 'description'), | |
| 64 | + }); | |
| 65 | + if (!validation.ok) { | |
| 66 | + return { status: 'error', errors: validation.errors }; | |
| 67 | + } | |
| 68 | + | |
| 69 | + try { | |
| 70 | + await createCodeGroup(validation.values); | |
| 71 | + } catch { | |
| 72 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 73 | + } | |
| 74 | + | |
| 75 | + revalidatePath(COMMON_CODES_PATH); | |
| 76 | + return { status: 'success' }; | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** 기획 [공통코드 수정] 팝업 — 코드ID는 대상을 가리키는 값이라 바뀌지 않는다. */ | |
| 80 | +export async function updateCodeGroupAction( | |
| 81 | + _prevState: CommonCodeFormState, | |
| 82 | + formData: FormData | |
| 83 | +): Promise<CommonCodeFormState> { | |
| 84 | + await verifySession(); | |
| 85 | + | |
| 86 | + const targetComCd = readString(formData, 'targetComCd').trim(); | |
| 87 | + if (!targetComCd) { | |
| 88 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 89 | + } | |
| 90 | + | |
| 91 | + const validation = validateCommonCodeGroup({ | |
| 92 | + comCd: targetComCd, | |
| 93 | + name: readString(formData, 'name'), | |
| 94 | + description: readString(formData, 'description'), | |
| 95 | + }); | |
| 96 | + if (!validation.ok) { | |
| 97 | + return { status: 'error', errors: validation.errors }; | |
| 98 | + } | |
| 99 | + | |
| 100 | + try { | |
| 101 | + await updateCodeGroup(targetComCd, validation.values); | |
| 102 | + } catch { | |
| 103 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 104 | + } | |
| 105 | + | |
| 106 | + revalidatePath(COMMON_CODES_PATH); | |
| 107 | + return { status: 'success' }; | |
| 108 | +} | |
| 109 | + | |
| 110 | +/** | |
| 111 | + * 기획 ④ 삭제 — 확인 얼럿은 화면이 띄우고 여기서는 인증과 입력만 확인한다. | |
| 112 | + * | |
| 113 | + * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의 | |
| 114 | + * (prevState, formData) 규약 대신 식별자를 직접 받는다. | |
| 115 | + */ | |
| 116 | +export async function deleteCodeGroupAction( | |
| 117 | + comCd: string | |
| 118 | +): Promise<CommonCodeFormState> { | |
| 119 | + await verifySession(); | |
| 120 | + | |
| 121 | + if (!comCd) { | |
| 122 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 123 | + } | |
| 124 | + | |
| 125 | + try { | |
| 126 | + await deleteCodeGroup(comCd); | |
| 127 | + } catch { | |
| 128 | + return { status: 'error', message: DELETE_FAILED_MESSAGE }; | |
| 129 | + } | |
| 130 | + | |
| 131 | + revalidatePath(COMMON_CODES_PATH); | |
| 132 | + return { status: 'success' }; | |
| 133 | +} | |
| 134 | + | |
| 135 | +/* ── 상세코드 ─────────────────────────────────────────────────────────────── */ | |
| 136 | + | |
| 137 | +/** 기획 [상세코드 등록] 팝업 — 코드ID는 좌측에서 고른 공통코드다. */ | |
| 138 | +export async function createCodeDetailAction( | |
| 139 | + _prevState: CommonCodeFormState, | |
| 140 | + formData: FormData | |
| 141 | +): Promise<CommonCodeFormState> { | |
| 142 | + await verifySession(); | |
| 143 | + | |
| 144 | + const validation = validateCommonCodeDetail({ | |
| 145 | + comCd: readString(formData, 'comCd'), | |
| 146 | + comDtlCd: readString(formData, 'comDtlCd'), | |
| 147 | + name: readString(formData, 'name'), | |
| 148 | + description: readString(formData, 'description'), | |
| 149 | + sortSeq: parseFormNumber(formData, 'sortSeq'), | |
| 150 | + }); | |
| 151 | + if (!validation.ok) { | |
| 152 | + return { status: 'error', errors: validation.errors }; | |
| 153 | + } | |
| 154 | + | |
| 155 | + try { | |
| 156 | + await createCodeDetail(validation.values); | |
| 157 | + } catch { | |
| 158 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 159 | + } | |
| 160 | + | |
| 161 | + revalidatePath(COMMON_CODES_PATH); | |
| 162 | + return { status: 'success' }; | |
| 163 | +} | |
| 164 | + | |
| 165 | +/** 기획 [상세코드 수정] 팝업 — 코드ID·코드는 대상을 가리키는 값이라 바뀌지 않는다. */ | |
| 166 | +export async function updateCodeDetailAction( | |
| 167 | + _prevState: CommonCodeFormState, | |
| 168 | + formData: FormData | |
| 169 | +): Promise<CommonCodeFormState> { | |
| 170 | + await verifySession(); | |
| 171 | + | |
| 172 | + const targetComCd = readString(formData, 'targetComCd').trim(); | |
| 173 | + const targetComDtlCd = readString(formData, 'targetComDtlCd').trim(); | |
| 174 | + if (!targetComCd || !targetComDtlCd) { | |
| 175 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 176 | + } | |
| 177 | + | |
| 178 | + const validation = validateCommonCodeDetail({ | |
| 179 | + comCd: targetComCd, | |
| 180 | + comDtlCd: readString(formData, 'comDtlCd'), | |
| 181 | + name: readString(formData, 'name'), | |
| 182 | + description: readString(formData, 'description'), | |
| 183 | + sortSeq: parseFormNumber(formData, 'sortSeq'), | |
| 184 | + }); | |
| 185 | + if (!validation.ok) { | |
| 186 | + return { status: 'error', errors: validation.errors }; | |
| 187 | + } | |
| 188 | + | |
| 189 | + try { | |
| 190 | + await updateCodeDetail(targetComCd, targetComDtlCd, validation.values); | |
| 191 | + } catch { | |
| 192 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 193 | + } | |
| 194 | + | |
| 195 | + revalidatePath(COMMON_CODES_PATH); | |
| 196 | + return { status: 'success' }; | |
| 197 | +} | |
| 198 | + | |
| 199 | +export async function deleteCodeDetailAction( | |
| 200 | + comCd: string, | |
| 201 | + comDtlCd: string | |
| 202 | +): Promise<CommonCodeFormState> { | |
| 203 | + await verifySession(); | |
| 204 | + | |
| 205 | + if (!comCd || !comDtlCd) { | |
| 206 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 207 | + } | |
| 208 | + | |
| 209 | + try { | |
| 210 | + await deleteCodeDetail(comCd, comDtlCd); | |
| 211 | + } catch { | |
| 212 | + return { status: 'error', message: DELETE_FAILED_MESSAGE }; | |
| 213 | + } | |
| 214 | + | |
| 215 | + revalidatePath(COMMON_CODES_PATH); | |
| 216 | + return { status: 'success' }; | |
| 217 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-detail-modal.tsx
... | ... | @@ -0,0 +1,162 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect, useRef } from 'react'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { FoxHelperText } from '@fox/core/components/fox-helper-text'; | |
| 6 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 7 | +import { FoxModal } from '@fox/core/components/fox-modal'; | |
| 8 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 9 | +import type { CommonCodeDetail } from '@/lib/domain/common-code'; | |
| 10 | +import { INITIAL_COMMON_CODE_FORM_STATE } from '@/lib/domain/common-code-form'; | |
| 11 | +import { createCodeDetailAction, updateCodeDetailAction } from '../_actions'; | |
| 12 | +import styles from './code-form.module.scss'; | |
| 13 | + | |
| 14 | +interface CodeDetailModalProps { | |
| 15 | + /** 소속 공통코드 — 좌측에서 고른 값이라 팝업에서는 바꿀 수 없다. */ | |
| 16 | + comCd: string; | |
| 17 | + /** 수정 대상. 없으면 등록 팝업이다. */ | |
| 18 | + detail?: CommonCodeDetail; | |
| 19 | + /** 등록 시 채워 둘 정렬번호(기존 개수 + 1). */ | |
| 20 | + nextSortSeq: number; | |
| 21 | + onClose: () => void; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * 상세코드 등록/수정 팝업 — 기획 1842:16179 / 1842:16262. | |
| 26 | + * | |
| 27 | + * 코드ID는 좌측에서 고른 공통코드라 **항상 읽기 전용**이다(기획 ①: "상위코드 고정된 경우 비활성 | |
| 28 | + * 처리"). 수정에서는 코드(`comDtlCd`)도 대상을 가리키는 값이라 바꿀 수 없다 — 백엔드가 두 코드로 | |
| 29 | + * 행을 찾기 때문에 바꾸면 다른 행을 수정하게 된다. | |
| 30 | + * | |
| 31 | + * ⚠️ **등록 시 코드설명은 저장되지 않는다** — 백엔드 INSERT에 `DTL_CD_EXPLN`이 빠져 있다. | |
| 32 | + * 시안대로 칸은 두고 백엔드에 수정을 요청했다(Repository 주석 참조). 수정에서는 정상 저장된다. | |
| 33 | + */ | |
| 34 | +export function CodeDetailModal({ | |
| 35 | + comCd, | |
| 36 | + detail, | |
| 37 | + nextSortSeq, | |
| 38 | + onClose, | |
| 39 | +}: CodeDetailModalProps) { | |
| 40 | + const isEdit = detail !== undefined; | |
| 41 | + const { showToast } = useFeedback(); | |
| 42 | + const formRef = useRef<HTMLFormElement>(null); | |
| 43 | + const [state, formAction, isPending] = useActionState( | |
| 44 | + isEdit ? updateCodeDetailAction : createCodeDetailAction, | |
| 45 | + INITIAL_COMMON_CODE_FORM_STATE | |
| 46 | + ); | |
| 47 | + | |
| 48 | + useEffect(() => { | |
| 49 | + if (state.status === 'success') { | |
| 50 | + showToast({ | |
| 51 | + variant: 'success', | |
| 52 | + message: isEdit ? '상세코드를 수정했습니다.' : '상세코드를 등록했습니다.', | |
| 53 | + }); | |
| 54 | + onClose(); | |
| 55 | + } | |
| 56 | + }, [state, showToast, onClose, isEdit]); | |
| 57 | + | |
| 58 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 59 | + | |
| 60 | + return ( | |
| 61 | + <FoxModal | |
| 62 | + open | |
| 63 | + size="sm" | |
| 64 | + title={isEdit ? '상세코드 수정' : '상세코드 등록'} | |
| 65 | + onClose={onClose} | |
| 66 | + actions={ | |
| 67 | + <> | |
| 68 | + <FoxButton type="default" size="md" label="취소" onAction={onClose} /> | |
| 69 | + <FoxButton | |
| 70 | + type="primary" | |
| 71 | + size="md" | |
| 72 | + label={isPending ? '저장 중...' : '저장'} | |
| 73 | + disabled={isPending} | |
| 74 | + onAction={() => formRef.current?.requestSubmit()} | |
| 75 | + /> | |
| 76 | + </> | |
| 77 | + } | |
| 78 | + > | |
| 79 | + <form ref={formRef} action={formAction} className={styles.fields}> | |
| 80 | + <input type="hidden" name={isEdit ? 'targetComCd' : 'comCd'} value={comCd} /> | |
| 81 | + {isEdit && ( | |
| 82 | + <input | |
| 83 | + type="hidden" | |
| 84 | + name="targetComDtlCd" | |
| 85 | + value={detail.comDtlCd} | |
| 86 | + /> | |
| 87 | + )} | |
| 88 | + | |
| 89 | + <div className={styles.field}> | |
| 90 | + <FoxInput | |
| 91 | + size="md" | |
| 92 | + label="코드ID" | |
| 93 | + value={comCd} | |
| 94 | + readOnly | |
| 95 | + message="상위코드가 고정된 경우 비활성 처리됩니다." | |
| 96 | + /> | |
| 97 | + </div> | |
| 98 | + | |
| 99 | + <div className={styles.field}> | |
| 100 | + <FoxInput | |
| 101 | + size="md" | |
| 102 | + name="comDtlCd" | |
| 103 | + label="코드" | |
| 104 | + requirement="required" | |
| 105 | + defaultValue={detail?.comDtlCd ?? ''} | |
| 106 | + readOnly={isEdit} | |
| 107 | + placeholder="코드를 입력하세요." | |
| 108 | + invalid={Boolean(errors.comDtlCd)} | |
| 109 | + message={errors.comDtlCd} | |
| 110 | + maxLength={50} | |
| 111 | + /> | |
| 112 | + </div> | |
| 113 | + | |
| 114 | + <div className={styles.field}> | |
| 115 | + <FoxInput | |
| 116 | + size="md" | |
| 117 | + name="name" | |
| 118 | + label="코드값의미" | |
| 119 | + requirement="required" | |
| 120 | + defaultValue={detail?.name ?? ''} | |
| 121 | + placeholder="코드값의미를 입력하세요." | |
| 122 | + invalid={Boolean(errors.name)} | |
| 123 | + message={errors.name} | |
| 124 | + maxLength={100} | |
| 125 | + /> | |
| 126 | + </div> | |
| 127 | + | |
| 128 | + <div className={styles.field}> | |
| 129 | + <FoxInput | |
| 130 | + size="md" | |
| 131 | + type="number" | |
| 132 | + name="sortSeq" | |
| 133 | + label="정렬번호" | |
| 134 | + requirement="required" | |
| 135 | + min={1} | |
| 136 | + step={1} | |
| 137 | + defaultValue={detail?.sortSeq ?? nextSortSeq} | |
| 138 | + invalid={Boolean(errors.sortSeq)} | |
| 139 | + message={errors.sortSeq} | |
| 140 | + /> | |
| 141 | + </div> | |
| 142 | + | |
| 143 | + <div className={styles.field}> | |
| 144 | + <FoxInput | |
| 145 | + size="md" | |
| 146 | + name="description" | |
| 147 | + label="코드설명" | |
| 148 | + defaultValue={detail?.description ?? ''} | |
| 149 | + placeholder="코드설명을 입력하세요." | |
| 150 | + invalid={Boolean(errors.description)} | |
| 151 | + message={errors.description} | |
| 152 | + maxLength={500} | |
| 153 | + /> | |
| 154 | + </div> | |
| 155 | + | |
| 156 | + {state.status === 'error' && state.message && ( | |
| 157 | + <FoxHelperText type="danger" message={state.message} /> | |
| 158 | + )} | |
| 159 | + </form> | |
| 160 | + </FoxModal> | |
| 161 | + ); | |
| 162 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-form.module.scss
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +// 등록·수정 팝업의 입력 줄 — 꾸미기 아이템 팝업(ADM_ITM_102_p)과 같은 배치를 쓴다. | |
| 2 | +// 기획 와이어프레임에는 구분선이 없지만 그건 시각 시안이 아니라서, 이 프로젝트에서 확정된 | |
| 3 | +// 팝업 폼 모양을 따른다. | |
| 4 | + | |
| 5 | +@use "@fox/styles/abstracts" as fox; | |
| 6 | + | |
| 7 | +.fields { | |
| 8 | + display: flex; | |
| 9 | + inline-size: 100%; | |
| 10 | + flex-direction: column; | |
| 11 | +} | |
| 12 | + | |
| 13 | +.field { | |
| 14 | + display: flex; | |
| 15 | + inline-size: 100%; | |
| 16 | + flex-direction: column; | |
| 17 | + gap: fox.gap(3); | |
| 18 | + padding-block: fox.padding(6); | |
| 19 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 20 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-group-modal.tsx
... | ... | @@ -0,0 +1,157 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect, useRef, useState } from 'react'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { FoxHelperText } from '@fox/core/components/fox-helper-text'; | |
| 6 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 7 | +import { FoxModal } from '@fox/core/components/fox-modal'; | |
| 8 | +import { FoxSelect } from '@fox/core/components/fox-select'; | |
| 9 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 10 | +import { | |
| 11 | + CODE_ID_PREFIXES, | |
| 12 | + DEFAULT_CODE_ID_PREFIX, | |
| 13 | + applyCodeIdPrefix, | |
| 14 | + readCodeIdPrefix, | |
| 15 | + type CommonCodeGroup, | |
| 16 | +} from '@/lib/domain/common-code'; | |
| 17 | +import { INITIAL_COMMON_CODE_FORM_STATE } from '@/lib/domain/common-code-form'; | |
| 18 | +import { createCodeGroupAction, updateCodeGroupAction } from '../_actions'; | |
| 19 | +import styles from './code-form.module.scss'; | |
| 20 | + | |
| 21 | +interface CodeGroupModalProps { | |
| 22 | + /** 수정 대상. 없으면 등록 팝업이다. */ | |
| 23 | + group?: CommonCodeGroup; | |
| 24 | + onClose: () => void; | |
| 25 | +} | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * 공통코드 등록/수정 팝업 — 기획 1842:16056 / 1842:16122. | |
| 29 | + * | |
| 30 | + * 두 팝업은 제목·버튼 글자와 **코드ID의 읽기 전용 여부**만 다르고 입력 항목이 같아 한 조각으로 둔다. | |
| 31 | + * | |
| 32 | + * **분류코드는 저장되지 않는다.** `TB_SYS_COM_CD`에 해당 컬럼이 없어(확인함) 이 셀렉트는 코드ID | |
| 33 | + * 접두사를 채워 주는 입력 보조로만 동작한다 — 시안 목록이 전부 `CMS004`처럼 접두사 3자 + 일련번호라 | |
| 34 | + * 그 작명 규칙을 화면이 거드는 것이다(사용자 확정 사항). 저장되는 값은 코드ID 하나다. | |
| 35 | + * | |
| 36 | + * 저장 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — ref로 직접 제출한다(다른 팝업과 같은 방식). | |
| 37 | + */ | |
| 38 | +export function CodeGroupModal({ group, onClose }: CodeGroupModalProps) { | |
| 39 | + const isEdit = group !== undefined; | |
| 40 | + const { showToast } = useFeedback(); | |
| 41 | + const formRef = useRef<HTMLFormElement>(null); | |
| 42 | + const [state, formAction, isPending] = useActionState( | |
| 43 | + isEdit ? updateCodeGroupAction : createCodeGroupAction, | |
| 44 | + INITIAL_COMMON_CODE_FORM_STATE | |
| 45 | + ); | |
| 46 | + | |
| 47 | + const [comCd, setComCd] = useState(group?.comCd ?? ''); | |
| 48 | + const [prefix, setPrefix] = useState( | |
| 49 | + group ? readCodeIdPrefix(group.comCd) : DEFAULT_CODE_ID_PREFIX | |
| 50 | + ); | |
| 51 | + | |
| 52 | + useEffect(() => { | |
| 53 | + if (state.status === 'success') { | |
| 54 | + showToast({ | |
| 55 | + variant: 'success', | |
| 56 | + message: isEdit ? '공통코드를 수정했습니다.' : '공통코드를 등록했습니다.', | |
| 57 | + }); | |
| 58 | + onClose(); | |
| 59 | + } | |
| 60 | + }, [state, showToast, onClose, isEdit]); | |
| 61 | + | |
| 62 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <FoxModal | |
| 66 | + open | |
| 67 | + size="sm" | |
| 68 | + title={isEdit ? '공통코드 수정' : '공통코드 등록'} | |
| 69 | + onClose={onClose} | |
| 70 | + actions={ | |
| 71 | + <> | |
| 72 | + <FoxButton type="default" size="md" label="취소" onAction={onClose} /> | |
| 73 | + <FoxButton | |
| 74 | + type="primary" | |
| 75 | + size="md" | |
| 76 | + label={isPending ? '저장 중...' : '저장'} | |
| 77 | + disabled={isPending} | |
| 78 | + onAction={() => formRef.current?.requestSubmit()} | |
| 79 | + /> | |
| 80 | + </> | |
| 81 | + } | |
| 82 | + > | |
| 83 | + <form ref={formRef} action={formAction} className={styles.fields}> | |
| 84 | + {/* 수정 대상은 읽기 전용 칸이 아니라 이 값으로 정해진다 — 칸이 위조돼도 대상은 안 바뀐다. */} | |
| 85 | + {isEdit && ( | |
| 86 | + <input type="hidden" name="targetComCd" value={group.comCd} /> | |
| 87 | + )} | |
| 88 | + | |
| 89 | + <div className={styles.field}> | |
| 90 | + <FoxInput | |
| 91 | + size="md" | |
| 92 | + name={isEdit ? undefined : 'comCd'} | |
| 93 | + label="코드ID" | |
| 94 | + requirement="required" | |
| 95 | + value={comCd} | |
| 96 | + onChange={setComCd} | |
| 97 | + readOnly={isEdit} | |
| 98 | + placeholder="코드ID를 입력하세요." | |
| 99 | + invalid={Boolean(errors.comCd)} | |
| 100 | + message={errors.comCd} | |
| 101 | + maxLength={50} | |
| 102 | + /> | |
| 103 | + </div> | |
| 104 | + | |
| 105 | + {!isEdit && ( | |
| 106 | + <div className={styles.field}> | |
| 107 | + <FoxSelect | |
| 108 | + size="md" | |
| 109 | + label="분류코드" | |
| 110 | + options={CODE_ID_PREFIXES.map((value) => ({ | |
| 111 | + value, | |
| 112 | + label: value, | |
| 113 | + }))} | |
| 114 | + value={prefix} | |
| 115 | + onValueChange={(next) => { | |
| 116 | + setPrefix(next); | |
| 117 | + setComCd((current) => applyCodeIdPrefix(current, next)); | |
| 118 | + }} | |
| 119 | + hint="코드ID 앞에 붙는 분류입니다. 별도로 저장되지 않습니다." | |
| 120 | + /> | |
| 121 | + </div> | |
| 122 | + )} | |
| 123 | + | |
| 124 | + <div className={styles.field}> | |
| 125 | + <FoxInput | |
| 126 | + size="md" | |
| 127 | + name="name" | |
| 128 | + label="코드ID명" | |
| 129 | + requirement="required" | |
| 130 | + defaultValue={group?.name ?? ''} | |
| 131 | + placeholder="코드ID명을 입력하세요." | |
| 132 | + invalid={Boolean(errors.name)} | |
| 133 | + message={errors.name} | |
| 134 | + maxLength={100} | |
| 135 | + /> | |
| 136 | + </div> | |
| 137 | + | |
| 138 | + <div className={styles.field}> | |
| 139 | + <FoxInput | |
| 140 | + size="md" | |
| 141 | + name="description" | |
| 142 | + label="코드ID설명" | |
| 143 | + defaultValue={group?.description ?? ''} | |
| 144 | + placeholder="코드ID설명을 입력하세요." | |
| 145 | + invalid={Boolean(errors.description)} | |
| 146 | + message={errors.description} | |
| 147 | + maxLength={500} | |
| 148 | + /> | |
| 149 | + </div> | |
| 150 | + | |
| 151 | + {state.status === 'error' && state.message && ( | |
| 152 | + <FoxHelperText type="danger" message={state.message} /> | |
| 153 | + )} | |
| 154 | + </form> | |
| 155 | + </FoxModal> | |
| 156 | + ); | |
| 157 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-management.module.scss
... | ... | @@ -0,0 +1,45 @@ |
| 1 | +// 코드관리 화면 배치 — 시안 5402:13399의 `row`(공통코드 756 + 40 + 상세코드 756 = 1552). | |
| 2 | +// 두 목록이 나란히 서는 것은 이 화면만의 배치라 @fox에 넣지 않고 여기 둔다(값은 @fox 토큰). | |
| 3 | + | |
| 4 | +@use "@fox/styles/abstracts" as fox; | |
| 5 | + | |
| 6 | +.page { | |
| 7 | + display: flex; | |
| 8 | + inline-size: 100%; | |
| 9 | + flex-direction: column; | |
| 10 | +} | |
| 11 | + | |
| 12 | +// 좁은 화면에서는 두 목록이 위아래로 선다 — 나란히 두면 표가 눌려 읽을 수 없다. | |
| 13 | +.columns { | |
| 14 | + display: grid; | |
| 15 | + inline-size: 100%; | |
| 16 | + gap: fox.gap(9); | |
| 17 | + grid-template-columns: 1fr; | |
| 18 | +} | |
| 19 | + | |
| 20 | +@include fox.pc { | |
| 21 | + .columns { | |
| 22 | + grid-template-columns: 1fr 1fr; | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +.panel { | |
| 27 | + display: flex; | |
| 28 | + // 표가 넓어도 칸이 늘어나지 않게 한다 — 늘어나면 옆 칸을 밀어 두 목록의 폭이 어긋난다. | |
| 29 | + min-inline-size: 0; | |
| 30 | + flex-direction: column; | |
| 31 | +} | |
| 32 | + | |
| 33 | +// 설명 열은 한 줄로 줄이고 넘치면 말줄임한다. | |
| 34 | +// | |
| 35 | +// 시안의 셀 컴포넌트에는 말줄임 스타일이 없지만(전문이 들어 있고 word-break만 걸려 있다), | |
| 36 | +// 프레임 높이가 44로 고정이라 Figma가 넘치는 글자를 잘라 보여준다. CSS는 대신 감싸므로 그대로 | |
| 37 | +// 두면 행마다 높이가 달라진다 — 설명은 자유 입력이라 길이를 예측할 수 없다. | |
| 38 | +// 잘린 글자는 title로 남겨 마우스를 올리면 전문이 보인다. | |
| 39 | +.truncate { | |
| 40 | + display: block; | |
| 41 | + inline-size: 100%; | |
| 42 | + overflow: hidden; | |
| 43 | + text-overflow: ellipsis; | |
| 44 | + white-space: nowrap; | |
| 45 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-management.tsx
... | ... | @@ -0,0 +1,343 @@ |
| 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 { FoxHeadingGroup } from '@fox/core/components/fox-heading-group'; | |
| 7 | +import { | |
| 8 | + FoxListContainer, | |
| 9 | + type FoxListColumn, | |
| 10 | +} from '@fox/core/components/fox-list-container'; | |
| 11 | +import { FoxPageHeader } from '@fox/core/components/fox-page-header'; | |
| 12 | +import { FoxPlusIcon } from '@fox/core/icons'; | |
| 13 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 14 | +import { | |
| 15 | + formatOptionalText, | |
| 16 | + type CommonCodeDetail, | |
| 17 | + type CommonCodeGroup, | |
| 18 | +} from '@/lib/domain/common-code'; | |
| 19 | +import { | |
| 20 | + COMMON_CODE_PAGE_SIZE, | |
| 21 | + COMMON_CODE_SEARCH_FIELD_OPTIONS, | |
| 22 | + buildCommonCodeHref, | |
| 23 | + type CommonCodeQuery, | |
| 24 | + type CommonCodeSearchField, | |
| 25 | +} from '@/lib/domain/common-code-query'; | |
| 26 | +import { deleteCodeDetailAction, deleteCodeGroupAction } from '../_actions'; | |
| 27 | +import { CodeDetailModal } from './code-detail-modal'; | |
| 28 | +import { CodeGroupModal } from './code-group-modal'; | |
| 29 | +import { CodeRowActions } from './code-row-actions'; | |
| 30 | +import styles from './code-management.module.scss'; | |
| 31 | + | |
| 32 | +interface CodeManagementProps { | |
| 33 | + query: CommonCodeQuery; | |
| 34 | + groups: CommonCodeGroup[]; | |
| 35 | + groupTotalCount: number; | |
| 36 | + groupPage: number; | |
| 37 | + groupTotalPages: number; | |
| 38 | + /** 화면이 실제로 보여주는 선택 — URL 값이 목록에 없으면 서버가 첫 행으로 바꿔 넘긴다. */ | |
| 39 | + selectedComCd: string | null; | |
| 40 | + details: CommonCodeDetail[]; | |
| 41 | + detailTotalCount: number; | |
| 42 | + detailPage: number; | |
| 43 | + detailTotalPages: number; | |
| 44 | + nextSortSeq: number; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * 설명 칸 — 한 줄로 줄이고 넘치면 말줄임한다(스타일 주석 참조). 잘린 글자를 읽을 방법이 | |
| 49 | + * 있어야 하므로 전문을 `title`로 남긴다. | |
| 50 | + */ | |
| 51 | +function Truncated({ text }: { text: string | null }) { | |
| 52 | + const value = formatOptionalText(text); | |
| 53 | + return ( | |
| 54 | + <span className={styles.truncate} title={text ?? undefined}> | |
| 55 | + {value} | |
| 56 | + </span> | |
| 57 | + ); | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** 어느 팝업이 열려 있는지. 한 번에 하나만 열린다. */ | |
| 61 | +type OpenModal = | |
| 62 | + | { kind: 'group-create' } | |
| 63 | + | { kind: 'group-edit'; group: CommonCodeGroup } | |
| 64 | + | { kind: 'detail-create' } | |
| 65 | + | { kind: 'detail-edit'; detail: CommonCodeDetail } | |
| 66 | + | null; | |
| 67 | + | |
| 68 | +/** | |
| 69 | + * 코드관리 — 기획 SYS_COD_001, 시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399 | |
| 70 | + * | |
| 71 | + * 목록이 둘이지만 화면은 하나다. 좌측 공통코드를 **행 전체로 눌러** 고르면 우측 상세코드가 그 | |
| 72 | + * 그룹으로 갱신된다(기획 ④) — 선택은 URL에 실리므로 갱신은 서버가 다시 그려 준다. | |
| 73 | + * | |
| 74 | + * 두 목록 모두 `FoxListContainer`를 쓰되 화면 제목은 위에서 한 번만 그린다(`FoxPageHeader`). | |
| 75 | + * 패널 이름은 `FoxHeadingGroup`이다 — 화면에 여럿 설 수 있는 구역 제목이라 `<h2>`로 나간다. | |
| 76 | + * | |
| 77 | + * 우측에는 검색이 없다(시안) — 상세코드는 선택된 그룹에 속한 것이 전부라 걸러 낼 대상이 아니다. | |
| 78 | + */ | |
| 79 | +export function CodeManagement({ | |
| 80 | + query, | |
| 81 | + groups, | |
| 82 | + groupTotalCount, | |
| 83 | + groupPage, | |
| 84 | + groupTotalPages, | |
| 85 | + selectedComCd, | |
| 86 | + details, | |
| 87 | + detailTotalCount, | |
| 88 | + detailPage, | |
| 89 | + detailTotalPages, | |
| 90 | + nextSortSeq, | |
| 91 | +}: CodeManagementProps) { | |
| 92 | + const router = useRouter(); | |
| 93 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 94 | + const [modal, setModal] = useState<OpenModal>(null); | |
| 95 | + const [isDeleting, startDeleting] = useTransition(); | |
| 96 | + | |
| 97 | + function go(patch: Partial<CommonCodeQuery>) { | |
| 98 | + router.replace(buildCommonCodeHref(query, patch)); | |
| 99 | + } | |
| 100 | + | |
| 101 | + /** | |
| 102 | + * 삭제는 되돌릴 수 없어 확인 얼럿을 거친다(기획 ④). 성공 뒤 화면 갱신은 Server Action의 | |
| 103 | + * `revalidatePath`가 맡으므로 여기서는 알림만 띄운다. | |
| 104 | + */ | |
| 105 | + function confirmDelete( | |
| 106 | + title: string, | |
| 107 | + message: string, | |
| 108 | + run: () => Promise<{ status: string; message?: string }> | |
| 109 | + ) { | |
| 110 | + showAlert({ | |
| 111 | + variant: 'danger', | |
| 112 | + title, | |
| 113 | + message, | |
| 114 | + actions: ( | |
| 115 | + <> | |
| 116 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 117 | + <FoxButton | |
| 118 | + type="error" | |
| 119 | + size="md" | |
| 120 | + label="삭제" | |
| 121 | + onAction={() => { | |
| 122 | + hideAlert(); | |
| 123 | + startDeleting(async () => { | |
| 124 | + const result = await run(); | |
| 125 | + if (result.status === 'error') { | |
| 126 | + showToast({ | |
| 127 | + variant: 'danger', | |
| 128 | + message: result.message ?? '삭제하지 못했습니다.', | |
| 129 | + }); | |
| 130 | + return; | |
| 131 | + } | |
| 132 | + showToast({ variant: 'success', message: '삭제했습니다.' }); | |
| 133 | + }); | |
| 134 | + }} | |
| 135 | + /> | |
| 136 | + </> | |
| 137 | + ), | |
| 138 | + }); | |
| 139 | + } | |
| 140 | + | |
| 141 | + const groupColumns: FoxListColumn<CommonCodeGroup>[] = [ | |
| 142 | + { | |
| 143 | + key: 'no', | |
| 144 | + header: '번호', | |
| 145 | + width: 80, | |
| 146 | + render: (_row, index) => (groupPage - 1) * COMMON_CODE_PAGE_SIZE + index + 1, | |
| 147 | + }, | |
| 148 | + { key: 'comCd', header: '코드ID', width: 120 }, | |
| 149 | + { key: 'name', header: '코드명', width: 160, emphasis: true }, | |
| 150 | + { | |
| 151 | + key: 'description', | |
| 152 | + header: '코드설명', | |
| 153 | + width: 156, | |
| 154 | + render: (row) => <Truncated text={row.description} />, | |
| 155 | + }, | |
| 156 | + { | |
| 157 | + key: 'createdAt', | |
| 158 | + header: '생성일', | |
| 159 | + width: 120, | |
| 160 | + render: (row) => formatOptionalText(row.createdAt), | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + key: 'actions', | |
| 164 | + header: '관리', | |
| 165 | + width: 120, | |
| 166 | + render: (row) => ( | |
| 167 | + <CodeRowActions | |
| 168 | + target={row.comCd} | |
| 169 | + deleting={isDeleting} | |
| 170 | + onEdit={() => setModal({ kind: 'group-edit', group: row })} | |
| 171 | + onDelete={() => | |
| 172 | + confirmDelete( | |
| 173 | + '공통코드를 삭제하시겠습니까?', | |
| 174 | + `${row.name}(${row.comCd})을(를) 삭제합니다. 속한 상세코드도 함께 쓸 수 없게 됩니다.`, | |
| 175 | + () => deleteCodeGroupAction(row.comCd) | |
| 176 | + ) | |
| 177 | + } | |
| 178 | + /> | |
| 179 | + ), | |
| 180 | + }, | |
| 181 | + ]; | |
| 182 | + | |
| 183 | + const detailColumns: FoxListColumn<CommonCodeDetail>[] = [ | |
| 184 | + { | |
| 185 | + key: 'no', | |
| 186 | + header: '번호', | |
| 187 | + width: 80, | |
| 188 | + // 기획의 상세코드 목록은 이 자리에 정렬번호를 보여준다(FSC01→1, FSC10→10). | |
| 189 | + render: (row) => row.sortSeq, | |
| 190 | + }, | |
| 191 | + { key: 'comDtlCd', header: '상세코드ID', width: 120 }, | |
| 192 | + { key: 'name', header: '상세코드명', width: 120, emphasis: true }, | |
| 193 | + { | |
| 194 | + key: 'description', | |
| 195 | + header: '상세코드설명', | |
| 196 | + width: 196, | |
| 197 | + render: (row) => <Truncated text={row.description} />, | |
| 198 | + }, | |
| 199 | + { | |
| 200 | + key: 'createdAt', | |
| 201 | + header: '생성일', | |
| 202 | + width: 120, | |
| 203 | + render: (row) => formatOptionalText(row.createdAt), | |
| 204 | + }, | |
| 205 | + { | |
| 206 | + key: 'actions', | |
| 207 | + header: '관리', | |
| 208 | + width: 120, | |
| 209 | + render: (row) => ( | |
| 210 | + <CodeRowActions | |
| 211 | + target={row.comDtlCd} | |
| 212 | + deleting={isDeleting} | |
| 213 | + onEdit={() => setModal({ kind: 'detail-edit', detail: row })} | |
| 214 | + onDelete={() => | |
| 215 | + confirmDelete( | |
| 216 | + '상세코드를 삭제하시겠습니까?', | |
| 217 | + `${row.name}(${row.comDtlCd})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 218 | + () => deleteCodeDetailAction(row.comCd, row.comDtlCd) | |
| 219 | + ) | |
| 220 | + } | |
| 221 | + /> | |
| 222 | + ), | |
| 223 | + }, | |
| 224 | + ]; | |
| 225 | + | |
| 226 | + return ( | |
| 227 | + <div className={styles.page}> | |
| 228 | + <FoxPageHeader | |
| 229 | + title="코드관리" | |
| 230 | + breadcrumb={[ | |
| 231 | + { label: '홈', href: '/' }, | |
| 232 | + { label: '시스템관리' }, | |
| 233 | + { label: '코드관리' }, | |
| 234 | + ]} | |
| 235 | + /> | |
| 236 | + | |
| 237 | + <div className={styles.columns}> | |
| 238 | + <section className={styles.panel}> | |
| 239 | + <FoxHeadingGroup title="공통코드 목록" /> | |
| 240 | + <FoxListContainer<CommonCodeGroup> | |
| 241 | + caption="공통코드 목록" | |
| 242 | + columns={groupColumns} | |
| 243 | + rows={groups} | |
| 244 | + rowKey={(row) => row.comCd} | |
| 245 | + totalCount={groupTotalCount} | |
| 246 | + emptyMessage="조회된 코드가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 247 | + searchFields={COMMON_CODE_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 248 | + value: option.value, | |
| 249 | + label: option.label, | |
| 250 | + }))} | |
| 251 | + searchField={query.searchField} | |
| 252 | + keyword={query.keyword} | |
| 253 | + toolbarActions={ | |
| 254 | + <FoxButton | |
| 255 | + type="primary" | |
| 256 | + size="md" | |
| 257 | + leadingIcon={<FoxPlusIcon />} | |
| 258 | + label="신규 등록" | |
| 259 | + onAction={() => setModal({ kind: 'group-create' })} | |
| 260 | + /> | |
| 261 | + } | |
| 262 | + onRowSelect={(row) => go({ comCd: row.comCd })} | |
| 263 | + page={groupPage} | |
| 264 | + totalPages={groupTotalPages} | |
| 265 | + buildHref={(patch) => | |
| 266 | + buildCommonCodeHref(query, { page: Number(patch.page ?? 1) }) | |
| 267 | + } | |
| 268 | + onQueryChange={(patch) => { | |
| 269 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 270 | + go({ | |
| 271 | + keyword: String(patch.keyword ?? ''), | |
| 272 | + searchField: (patch.searchField ?? | |
| 273 | + query.searchField) as CommonCodeSearchField, | |
| 274 | + // 검색하면 목록 자체가 달라지므로 선택도 첫 행으로 다시 잡게 비운다. | |
| 275 | + comCd: null, | |
| 276 | + page: 1, | |
| 277 | + }); | |
| 278 | + } | |
| 279 | + }} | |
| 280 | + /> | |
| 281 | + </section> | |
| 282 | + | |
| 283 | + <section className={styles.panel}> | |
| 284 | + <FoxHeadingGroup title="상세코드 목록" /> | |
| 285 | + <FoxListContainer<CommonCodeDetail> | |
| 286 | + caption="상세코드 목록" | |
| 287 | + columns={detailColumns} | |
| 288 | + rows={details} | |
| 289 | + rowKey={(row) => `${row.comCd}:${row.comDtlCd}`} | |
| 290 | + totalCount={detailTotalCount} | |
| 291 | + emptyMessage={ | |
| 292 | + selectedComCd | |
| 293 | + ? '등록된 상세코드가 없습니다.' | |
| 294 | + : '왼쪽에서 공통코드를 선택해 주세요.' | |
| 295 | + } | |
| 296 | + searchHidden | |
| 297 | + toolbarActions={ | |
| 298 | + <FoxButton | |
| 299 | + type="primary" | |
| 300 | + size="md" | |
| 301 | + leadingIcon={<FoxPlusIcon />} | |
| 302 | + label="상세코드 등록" | |
| 303 | + // 소속될 공통코드가 없으면 등록할 수 없다. | |
| 304 | + disabled={!selectedComCd} | |
| 305 | + onAction={() => setModal({ kind: 'detail-create' })} | |
| 306 | + /> | |
| 307 | + } | |
| 308 | + page={detailPage} | |
| 309 | + totalPages={detailTotalPages} | |
| 310 | + buildHref={(patch) => | |
| 311 | + buildCommonCodeHref(query, { | |
| 312 | + comCd: selectedComCd, | |
| 313 | + detailPage: Number(patch.page ?? 1), | |
| 314 | + }) | |
| 315 | + } | |
| 316 | + /> | |
| 317 | + </section> | |
| 318 | + </div> | |
| 319 | + | |
| 320 | + {modal?.kind === 'group-create' && ( | |
| 321 | + <CodeGroupModal onClose={() => setModal(null)} /> | |
| 322 | + )} | |
| 323 | + {modal?.kind === 'group-edit' && ( | |
| 324 | + <CodeGroupModal group={modal.group} onClose={() => setModal(null)} /> | |
| 325 | + )} | |
| 326 | + {modal?.kind === 'detail-create' && selectedComCd && ( | |
| 327 | + <CodeDetailModal | |
| 328 | + comCd={selectedComCd} | |
| 329 | + nextSortSeq={nextSortSeq} | |
| 330 | + onClose={() => setModal(null)} | |
| 331 | + /> | |
| 332 | + )} | |
| 333 | + {modal?.kind === 'detail-edit' && ( | |
| 334 | + <CodeDetailModal | |
| 335 | + comCd={modal.detail.comCd} | |
| 336 | + detail={modal.detail} | |
| 337 | + nextSortSeq={nextSortSeq} | |
| 338 | + onClose={() => setModal(null)} | |
| 339 | + /> | |
| 340 | + )} | |
| 341 | + </div> | |
| 342 | + ); | |
| 343 | +} |
+++ app/(protected)/(basic)/system/codes/_components/code-row-actions.tsx
... | ... | @@ -0,0 +1,51 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 4 | +import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 5 | +import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons'; | |
| 6 | + | |
| 7 | +interface CodeRowActionsProps { | |
| 8 | + /** 읽어 줄 이름에 붙는 대상 — "CMS004 수정"처럼 읽힌다. */ | |
| 9 | + target: string; | |
| 10 | + onEdit: () => void; | |
| 11 | + onDelete: () => void; | |
| 12 | + deleting?: boolean; | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 두 목록이 함께 쓰는 "관리" 셀(시안 5402:13455) — 28×28 테두리 버튼 두 개. | |
| 17 | + * | |
| 18 | + * **누름이 위로 새지 않게 막는다** — 공통코드 행은 눌러서 고르는 행이라, 막지 않으면 수정·삭제를 | |
| 19 | + * 누를 때 선택까지 함께 바뀐다. 상세코드 행은 고를 수 없지만 같은 조각을 쓰므로 규칙을 여기 둔다. | |
| 20 | + */ | |
| 21 | +export function CodeRowActions({ | |
| 22 | + target, | |
| 23 | + onEdit, | |
| 24 | + onDelete, | |
| 25 | + deleting = false, | |
| 26 | +}: CodeRowActionsProps) { | |
| 27 | + return ( | |
| 28 | + <span | |
| 29 | + onClick={(event) => event.stopPropagation()} | |
| 30 | + onKeyDown={(event) => event.stopPropagation()} | |
| 31 | + > | |
| 32 | + <FoxButtonGroup size="sm"> | |
| 33 | + <FoxIconButton | |
| 34 | + type="default" | |
| 35 | + size="sm" | |
| 36 | + icon={<FoxPencilSimpleIcon />} | |
| 37 | + label={`${target} 수정`} | |
| 38 | + onAction={onEdit} | |
| 39 | + /> | |
| 40 | + <FoxIconButton | |
| 41 | + type="default" | |
| 42 | + size="sm" | |
| 43 | + icon={<FoxTrashIcon />} | |
| 44 | + label={deleting ? `${target} 삭제 중` : `${target} 삭제`} | |
| 45 | + disabled={deleting} | |
| 46 | + onAction={onDelete} | |
| 47 | + /> | |
| 48 | + </FoxButtonGroup> | |
| 49 | + </span> | |
| 50 | + ); | |
| 51 | +} |
+++ app/(protected)/(basic)/system/codes/page.tsx
... | ... | @@ -0,0 +1,107 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { verifySession } from '@/lib/auth/dal'; | |
| 3 | +import { | |
| 4 | + fetchCodeDetails, | |
| 5 | + fetchCodeGroups, | |
| 6 | +} from '@/lib/data/repositories/common-code-repository'; | |
| 7 | +import type { CommonCodeGroup } from '@/lib/domain/common-code'; | |
| 8 | +import { | |
| 9 | + COMMON_CODE_DETAIL_PAGE_SIZE, | |
| 10 | + COMMON_CODE_PAGE_SIZE, | |
| 11 | + parseCommonCodeQuery, | |
| 12 | + type CommonCodeQuery, | |
| 13 | +} from '@/lib/domain/common-code-query'; | |
| 14 | +import { CodeManagement } from './_components/code-management'; | |
| 15 | + | |
| 16 | +export const metadata: Metadata = { | |
| 17 | + title: '코드관리', | |
| 18 | +}; | |
| 19 | + | |
| 20 | +// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를 | |
| 21 | +// 원천 차단하기 위해 명시적으로 강제한다. | |
| 22 | +export const dynamic = 'force-dynamic'; | |
| 23 | + | |
| 24 | +interface PageProps { | |
| 25 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** 부분일치 검색 — 백엔드가 완전일치만 지원해 여기서 거른다(Repository 주석 참조). */ | |
| 29 | +function filterGroups( | |
| 30 | + groups: CommonCodeGroup[], | |
| 31 | + query: CommonCodeQuery | |
| 32 | +): CommonCodeGroup[] { | |
| 33 | + const keyword = query.keyword.trim().toLowerCase(); | |
| 34 | + if (!keyword) { | |
| 35 | + return groups; | |
| 36 | + } | |
| 37 | + | |
| 38 | + return groups.filter((group) => { | |
| 39 | + const target = query.searchField === 'comCd' ? group.comCd : group.name; | |
| 40 | + return target.toLowerCase().includes(keyword); | |
| 41 | + }); | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** 1-based 페이지를 잘라낸다. 범위를 벗어난 페이지는 마지막 페이지로 맞춘다. */ | |
| 45 | +function paginate<T>(rows: T[], page: number, pageSize: number) { | |
| 46 | + const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); | |
| 47 | + const currentPage = Math.min(Math.max(page, 1), totalPages); | |
| 48 | + const offset = (currentPage - 1) * pageSize; | |
| 49 | + | |
| 50 | + return { | |
| 51 | + rows: rows.slice(offset, offset + pageSize), | |
| 52 | + currentPage, | |
| 53 | + totalPages, | |
| 54 | + totalCount: rows.length, | |
| 55 | + }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * 코드관리 — 기획 SYS_COD_001, 시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399 | |
| 60 | + * | |
| 61 | + * 좌측 공통코드를 고르면 우측 상세코드 목록이 그 그룹으로 갱신된다. 선택·검색·두 목록의 페이지가 | |
| 62 | + * 모두 URL에 실려, 이 서버 컴포넌트가 매 요청 그 조건대로 데이터를 만들어 넘긴다. | |
| 63 | + * | |
| 64 | + * **검색·페이징을 서버에서 처리한다** — 백엔드가 두 목록 모두 전체를 반환하고 검색도 완전일치라 | |
| 65 | + * (Repository 주석 참조), 부분일치 필터와 페이지 자르기를 여기서 한다. 전체를 받으므로 총건수는 | |
| 66 | + * 정확하다(다른 목록 화면의 하한값 보정이 여기에는 필요 없다). | |
| 67 | + * | |
| 68 | + * **선택된 공통코드가 목록에 없으면 첫 행으로 대체한다** — 검색으로 걸러졌거나 삭제된 코드가 | |
| 69 | + * URL에 남아 있으면 우측이 영영 비어 보이기 때문이다. | |
| 70 | + */ | |
| 71 | +export default async function Page({ searchParams }: PageProps) { | |
| 72 | + await verifySession(); | |
| 73 | + | |
| 74 | + const query = parseCommonCodeQuery(await searchParams); | |
| 75 | + | |
| 76 | + const groups = filterGroups(await fetchCodeGroups(), query); | |
| 77 | + const groupPage = paginate(groups, query.page, COMMON_CODE_PAGE_SIZE); | |
| 78 | + | |
| 79 | + const selectedComCd = | |
| 80 | + groups.find((group) => group.comCd === query.comCd)?.comCd ?? | |
| 81 | + groupPage.rows[0]?.comCd ?? | |
| 82 | + null; | |
| 83 | + | |
| 84 | + const details = selectedComCd ? await fetchCodeDetails(selectedComCd) : []; | |
| 85 | + const detailPage = paginate( | |
| 86 | + details, | |
| 87 | + query.detailPage, | |
| 88 | + COMMON_CODE_DETAIL_PAGE_SIZE | |
| 89 | + ); | |
| 90 | + | |
| 91 | + return ( | |
| 92 | + <CodeManagement | |
| 93 | + query={query} | |
| 94 | + groups={groupPage.rows} | |
| 95 | + groupTotalCount={groupPage.totalCount} | |
| 96 | + groupPage={groupPage.currentPage} | |
| 97 | + groupTotalPages={groupPage.totalPages} | |
| 98 | + selectedComCd={selectedComCd} | |
| 99 | + details={detailPage.rows} | |
| 100 | + detailTotalCount={detailPage.totalCount} | |
| 101 | + detailPage={detailPage.currentPage} | |
| 102 | + detailTotalPages={detailPage.totalPages} | |
| 103 | + // 새 상세코드의 기본 정렬번호 — 시안 등록 팝업이 기존 개수 다음 번호를 채워 둔다. | |
| 104 | + nextSortSeq={details.length + 1} | |
| 105 | + /> | |
| 106 | + ); | |
| 107 | +} |
--- lib/data/repositories/common-code-repository.ts
+++ lib/data/repositories/common-code-repository.ts
... | ... | @@ -2,37 +2,66 @@ |
| 2 | 2 |
import { cache } from 'react';
|
| 3 | 3 |
import { getSessionAccessToken } from '@/lib/auth/dal';
|
| 4 | 4 |
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
| 5 |
-import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 5 |
+import type {
|
|
| 6 |
+ CommonCode, |
|
| 7 |
+ CommonCodeDetail, |
|
| 8 |
+ CommonCodeGroup, |
|
| 9 |
+} from '@/lib/domain/common-code'; |
|
| 10 |
+import type {
|
|
| 11 |
+ CommonCodeDetailValues, |
|
| 12 |
+ CommonCodeGroupValues, |
|
| 13 |
+} from '@/lib/domain/common-code-form'; |
|
| 6 | 14 |
|
| 7 | 15 |
/** |
| 8 |
- * 공통코드 Repository. |
|
| 16 |
+ * 공통코드 Repository — 그룹(`TB_SYS_COM_CD`)과 상세(`TB_SYS_COM_CD_DTL`) 두 층을 모두 맡는다. |
|
| 9 | 17 |
* |
| 10 | 18 |
* ``` |
| 11 |
- * GET /api/v1/mngr/code/list/{comCd} 그룹의 상세코드 목록 (ROLE_ADMIN)
|
|
| 19 |
+ * GET /api/v1/mngr/code/list 그룹 목록 (ROLE_ADMIN) |
|
| 20 |
+ * GET /api/v1/mngr/code/list/{comCd} 그룹의 상세코드 목록
|
|
| 21 |
+ * POST /api/v1/mngr/code 그룹 등록 — @RequestBody(JSON) |
|
| 22 |
+ * PUT /api/v1/mngr/code/{comCd} 그룹 수정 — @RequestBody(JSON)
|
|
| 23 |
+ * DELETE /api/v1/mngr/code/{comCd} 그룹 삭제 — soft delete(DEL_YN)
|
|
| 24 |
+ * POST /api/v1/mngr/code/detail 상세 등록 — 어노테이션 없음 → form |
|
| 25 |
+ * PUT /api/v1/mngr/code/{comCd}/{comDtlCd} 상세 수정 — @RequestBody(JSON)
|
|
| 26 |
+ * DELETE /api/v1/mngr/code/{comCd}/{comDtlCd} 상세 삭제 — soft delete
|
|
| 12 | 27 |
* ``` |
| 13 | 28 |
* |
| 14 | 29 |
* 백엔드(edupay-backend, develop)의 MngrCodeApiController / MngrCodeMapper.xml을 읽고 확인한 것: |
| 15 | 30 |
* |
| 16 |
- * - 응답은 `TB_SYS_COM_CD_DTL` 행 배열이고, 쓸 값은 **`comDtlCd`(코드)와 `cdNm`(이름)** 둘이다. |
|
| 17 |
- * - 삭제된 코드(`DEL_YN='Y'`)는 SQL이 이미 걸러 준다. |
|
| 18 |
- * - **정렬은 백엔드가 준 순서를 그대로 쓴다.** SQL이 `ROW_NUMBER() OVER (ORDER BY FRST_REG_DT |
|
| 19 |
- * DESC, SORT_SEQ)`를 다시 `ORDER BY RNUM DESC`로 뒤집어, 결과는 등록일 오름차순 + 정렬순서 |
|
| 20 |
- * 내림차순이다(아이템 목록과 같은 이중 역순 패턴). 화면에서 다시 정렬하면 다른 화면과 순서가 |
|
| 21 |
- * 어긋나므로 손대지 않는다. |
|
| 31 |
+ * - **본문 형식이 엔드포인트마다 다르다.** 상세 등록만 `@ParameterObject`(form)이고 나머지 쓰기는 |
|
| 32 |
+ * 전부 `@RequestBody`(JSON)다 — 한쪽으로 통일해 보내면 반대쪽이 조용히 깨진다. |
|
| 33 |
+ * - **두 목록 모두 페이징이 없다**(전체 반환). 그래서 총건수는 정확하고, 자르는 일은 호출부가 한다. |
|
| 34 |
+ * - **그룹 검색은 완전일치다**(`com_cd = #{}` / `cd_nm = #{}`). 시안은 부분검색이라 검색 파라미터를
|
|
| 35 |
+ * 쓰지 않고 전체를 받아 호출부가 거른다(사용자 확정 사항). |
|
| 36 |
+ * - **정렬은 고정이다** — 두 목록 다 `ROW_NUMBER() OVER (...)`를 `ORDER BY RNUM DESC`로 뒤집는 |
|
| 37 |
+ * 이중 역순이라 결과는 등록일 오름차순이다. 화면에서 다시 정렬하지 않는다. |
|
| 38 |
+ * - **삭제는 soft delete**이고 조회가 `DEL_YN != 'Y'`로 거른다. |
|
| 22 | 39 |
* |
| 23 |
- * `cache()`로 감싼 이유 — 한 요청 안에서 화면(선택지 그리기)과 Server Action(입력값 검증)이 |
|
| 24 |
- * 같은 그룹을 각각 부른다. 요청 단위로 기억해 두면 왕복이 한 번으로 줄고, 그리는 데 쓴 목록과 |
|
| 25 |
- * 검증에 쓴 목록이 반드시 같아진다. |
|
| 40 |
+ * ⚠️ **백엔드 결함(보고함, 미수정)** |
|
| 41 |
+ * 1. 상세 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지 않는다 → 상세코드설명이 늘 비어 온다. |
|
| 42 |
+ * 2. 상세 등록 INSERT에도 `DTL_CD_EXPLN`이 없다 → 등록 시 입력한 설명이 저장되지 않는다. |
|
| 43 |
+ * (수정 UPDATE에는 있어 수정으로는 저장된다 — 다만 위 1 때문에 목록에서는 여전히 안 보인다.) |
|
| 44 |
+ * 사용자 지시로 시안대로 화면을 두고 백엔드에 수정을 요청한다 — 고쳐지면 프론트 수정 없이 동작한다. |
|
| 26 | 45 |
* |
| 27 |
- * 캐시: `no-store` — 코드는 자주 바뀌지 않지만 관리 화면에서 코드를 고치자마자 반영돼야 하고, |
|
| 28 |
- * 위 `cache()`가 이미 요청 안의 중복 호출을 막는다. |
|
| 46 |
+ * 캐시: 조회는 `no-store` — 관리 화면이라 신선도가 우선이다. `cache()`는 한 요청 안의 중복 호출만 |
|
| 47 |
+ * 막는다(화면이 그리기용으로, Server Action이 검증용으로 같은 목록을 부른다). |
|
| 29 | 48 |
*/ |
| 30 |
-export const fetchCommonCodes = cache(async function fetchCommonCodes( |
|
| 31 |
- groupCode: string |
|
| 32 |
-): Promise<CommonCode[]> {
|
|
| 49 |
+ |
|
| 50 |
+const CODE_PATH = '/api/v1/mngr/code'; |
|
| 51 |
+ |
|
| 52 |
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
| 53 |
+ return value !== null && typeof value === 'object'; |
|
| 54 |
+} |
|
| 55 |
+ |
|
| 56 |
+function readString(source: Record<string, unknown>, key: string): string | null {
|
|
| 57 |
+ const value = source[key]; |
|
| 58 |
+ return typeof value === 'string' && value.length > 0 ? value : null; |
|
| 59 |
+} |
|
| 60 |
+ |
|
| 61 |
+async function requestList(path: string): Promise<unknown[]> {
|
|
| 33 | 62 |
const accessToken = await getSessionAccessToken(); |
| 34 | 63 |
|
| 35 |
- const result = await backendFetch<unknown>(`/api/v1/mngr/code/list/${groupCode}`, {
|
|
| 64 |
+ const result = await backendFetch<unknown>(path, {
|
|
| 36 | 65 |
method: 'GET', |
| 37 | 66 |
accessToken: accessToken ?? undefined, |
| 38 | 67 |
cache: 'no-store', |
... | ... | @@ -43,29 +72,176 @@ |
| 43 | 72 |
} |
| 44 | 73 |
|
| 45 | 74 |
if (!Array.isArray(result.data)) {
|
| 46 |
- throw new Error('공통코드 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 75 |
+ throw new Error('공통코드 응답의 형식이 올바르지 않습니다.');
|
|
| 47 | 76 |
} |
| 48 | 77 |
|
| 49 |
- return result.data.flatMap(toCommonCode); |
|
| 78 |
+ return result.data; |
|
| 79 |
+} |
|
| 80 |
+ |
|
| 81 |
+/** |
|
| 82 |
+ * 응답 1건 → 도메인 타입. 코드값이 없는 행은 **예외 대신 건너뛴다** — 코드 한 줄이 깨졌다고 |
|
| 83 |
+ * 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른 판단이다). |
|
| 84 |
+ */ |
|
| 85 |
+function toGroup(raw: unknown): CommonCodeGroup[] {
|
|
| 86 |
+ if (!isRecord(raw)) {
|
|
| 87 |
+ return []; |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ const comCd = readString(raw, 'comCd'); |
|
| 91 |
+ if (!comCd) {
|
|
| 92 |
+ return []; |
|
| 93 |
+ } |
|
| 94 |
+ |
|
| 95 |
+ return [ |
|
| 96 |
+ {
|
|
| 97 |
+ comCd, |
|
| 98 |
+ name: readString(raw, 'cdNm') ?? '', |
|
| 99 |
+ description: readString(raw, 'cdExpln'), |
|
| 100 |
+ createdAt: readString(raw, 'frstRegDtStr'), |
|
| 101 |
+ }, |
|
| 102 |
+ ]; |
|
| 103 |
+} |
|
| 104 |
+ |
|
| 105 |
+function toDetail(raw: unknown): CommonCodeDetail[] {
|
|
| 106 |
+ if (!isRecord(raw)) {
|
|
| 107 |
+ return []; |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ const comDtlCd = readString(raw, 'comDtlCd'); |
|
| 111 |
+ if (!comDtlCd) {
|
|
| 112 |
+ return []; |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ const sortSeq = raw.sortSeq; |
|
| 116 |
+ |
|
| 117 |
+ return [ |
|
| 118 |
+ {
|
|
| 119 |
+ comCd: readString(raw, 'comCd') ?? '', |
|
| 120 |
+ comDtlCd, |
|
| 121 |
+ name: readString(raw, 'cdNm') ?? '', |
|
| 122 |
+ // 지금은 백엔드가 내려 주지 않아 늘 null이다(파일 상단 결함 1). |
|
| 123 |
+ description: readString(raw, 'dtlCdExpln'), |
|
| 124 |
+ sortSeq: typeof sortSeq === 'number' ? sortSeq : 0, |
|
| 125 |
+ createdAt: readString(raw, 'frstRegDtStr'), |
|
| 126 |
+ }, |
|
| 127 |
+ ]; |
|
| 128 |
+} |
|
| 129 |
+ |
|
| 130 |
+/** 공통코드(그룹) 전체. 검색·페이징은 호출부가 한다(파일 상단 주석 참조). */ |
|
| 131 |
+export const fetchCodeGroups = cache(async function fetchCodeGroups(): Promise< |
|
| 132 |
+ CommonCodeGroup[] |
|
| 133 |
+> {
|
|
| 134 |
+ const rows = await requestList(`${CODE_PATH}/list`);
|
|
| 135 |
+ return rows.flatMap(toGroup); |
|
| 136 |
+}); |
|
| 137 |
+ |
|
| 138 |
+/** 한 그룹의 상세코드 전체. */ |
|
| 139 |
+export const fetchCodeDetails = cache(async function fetchCodeDetails( |
|
| 140 |
+ comCd: string |
|
| 141 |
+): Promise<CommonCodeDetail[]> {
|
|
| 142 |
+ const rows = await requestList( |
|
| 143 |
+ `${CODE_PATH}/list/${encodeURIComponent(comCd)}`
|
|
| 144 |
+ ); |
|
| 145 |
+ return rows.flatMap(toDetail); |
|
| 50 | 146 |
}); |
| 51 | 147 |
|
| 52 | 148 |
/** |
| 53 |
- * 응답 1건 → 도메인 타입. 코드나 이름이 없는 행은 선택지로 쓸 수 없으므로 **예외 대신 건너뛴다** |
|
| 54 |
- * — 코드 한 줄이 깨졌다고 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른 |
|
| 55 |
- * 판단이다. 그쪽은 없으면 화면의 존재 이유가 사라진다). |
|
| 149 |
+ * 다른 화면이 선택지로 쓰는 최소 표현. 상세코드 조회를 그대로 쓰되 화면이 알 필요 없는 것을 |
|
| 150 |
+ * 덜어 낸다 — 같은 `cache()`를 타므로 한 요청 안에서 왕복이 늘지 않는다. |
|
| 56 | 151 |
*/ |
| 57 |
-function toCommonCode(raw: unknown): CommonCode[] {
|
|
| 58 |
- if (raw === null || typeof raw !== 'object') {
|
|
| 59 |
- return []; |
|
| 152 |
+export async function fetchCommonCodes(groupCode: string): Promise<CommonCode[]> {
|
|
| 153 |
+ const details = await fetchCodeDetails(groupCode); |
|
| 154 |
+ return details.map((detail) => ({ code: detail.comDtlCd, label: detail.name }));
|
|
| 155 |
+} |
|
| 156 |
+ |
|
| 157 |
+/* |
|
| 158 |
+ * ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
|
| 159 |
+ * 상세 등록만 form이고 나머지는 JSON이다(파일 상단 주석 참조). |
|
| 160 |
+ * 성공 응답은 모두 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다. |
|
| 161 |
+ */ |
|
| 162 |
+ |
|
| 163 |
+async function sendWrite( |
|
| 164 |
+ path: string, |
|
| 165 |
+ method: 'POST' | 'PUT' | 'DELETE', |
|
| 166 |
+ payload?: { form: Record<string, string | number | undefined> } | { body: unknown }
|
|
| 167 |
+): Promise<void> {
|
|
| 168 |
+ const accessToken = await getSessionAccessToken(); |
|
| 169 |
+ |
|
| 170 |
+ const result = await backendFetch<null>(path, {
|
|
| 171 |
+ method, |
|
| 172 |
+ ...payload, |
|
| 173 |
+ accessToken: accessToken ?? undefined, |
|
| 174 |
+ cache: 'no-store', |
|
| 175 |
+ canHaveNullData: true, |
|
| 176 |
+ }); |
|
| 177 |
+ |
|
| 178 |
+ if (!result.ok) {
|
|
| 179 |
+ throw new BackendRequestError(result); |
|
| 60 | 180 |
} |
| 181 |
+} |
|
| 61 | 182 |
|
| 62 |
- const source = raw as Record<string, unknown>; |
|
| 63 |
- const code = source.comDtlCd; |
|
| 64 |
- const label = source.cdNm; |
|
| 183 |
+export async function createCodeGroup( |
|
| 184 |
+ values: CommonCodeGroupValues |
|
| 185 |
+): Promise<void> {
|
|
| 186 |
+ await sendWrite(CODE_PATH, 'POST', {
|
|
| 187 |
+ body: { comCd: values.comCd, cdNm: values.name, cdExpln: values.description },
|
|
| 188 |
+ }); |
|
| 189 |
+} |
|
| 65 | 190 |
|
| 66 |
- if (typeof code !== 'string' || code.length === 0) {
|
|
| 67 |
- return []; |
|
| 68 |
- } |
|
| 191 |
+/** 코드ID는 경로로만 간다 — 수정 대상이 아니라 대상을 가리키는 값이다. */ |
|
| 192 |
+export async function updateCodeGroup( |
|
| 193 |
+ comCd: string, |
|
| 194 |
+ values: CommonCodeGroupValues |
|
| 195 |
+): Promise<void> {
|
|
| 196 |
+ await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'PUT', {
|
|
| 197 |
+ body: { cdNm: values.name, cdExpln: values.description },
|
|
| 198 |
+ }); |
|
| 199 |
+} |
|
| 69 | 200 |
|
| 70 |
- return [{ code, label: typeof label === 'string' && label ? label : code }];
|
|
| 201 |
+export async function deleteCodeGroup(comCd: string): Promise<void> {
|
|
| 202 |
+ await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'DELETE');
|
|
| 203 |
+} |
|
| 204 |
+ |
|
| 205 |
+/** 상세 등록만 form이다 — 컨트롤러가 `@RequestBody` 없이 받는다. */ |
|
| 206 |
+export async function createCodeDetail( |
|
| 207 |
+ values: CommonCodeDetailValues |
|
| 208 |
+): Promise<void> {
|
|
| 209 |
+ await sendWrite(`${CODE_PATH}/detail`, 'POST', {
|
|
| 210 |
+ form: {
|
|
| 211 |
+ comCd: values.comCd, |
|
| 212 |
+ comDtlCd: values.comDtlCd, |
|
| 213 |
+ cdNm: values.name, |
|
| 214 |
+ // 백엔드 INSERT가 이 값을 쓰지 않는다(파일 상단 결함 2). 고쳐지면 그대로 저장된다. |
|
| 215 |
+ dtlCdExpln: values.description, |
|
| 216 |
+ sortSeq: values.sortSeq, |
|
| 217 |
+ }, |
|
| 218 |
+ }); |
|
| 219 |
+} |
|
| 220 |
+ |
|
| 221 |
+export async function updateCodeDetail( |
|
| 222 |
+ comCd: string, |
|
| 223 |
+ comDtlCd: string, |
|
| 224 |
+ values: CommonCodeDetailValues |
|
| 225 |
+): Promise<void> {
|
|
| 226 |
+ await sendWrite( |
|
| 227 |
+ `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`,
|
|
| 228 |
+ 'PUT', |
|
| 229 |
+ {
|
|
| 230 |
+ body: {
|
|
| 231 |
+ cdNm: values.name, |
|
| 232 |
+ dtlCdExpln: values.description, |
|
| 233 |
+ sortSeq: values.sortSeq, |
|
| 234 |
+ }, |
|
| 235 |
+ } |
|
| 236 |
+ ); |
|
| 237 |
+} |
|
| 238 |
+ |
|
| 239 |
+export async function deleteCodeDetail( |
|
| 240 |
+ comCd: string, |
|
| 241 |
+ comDtlCd: string |
|
| 242 |
+): Promise<void> {
|
|
| 243 |
+ await sendWrite( |
|
| 244 |
+ `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`,
|
|
| 245 |
+ 'DELETE' |
|
| 246 |
+ ); |
|
| 71 | 247 |
} |
+++ lib/domain/common-code-form.ts
... | ... | @@ -0,0 +1,191 @@ |
| 1 | +/** | |
| 2 | + * 코드관리 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음). | |
| 3 | + * | |
| 4 | + * 시안 네 팝업(공통코드 등록/수정, 상세코드 등록/수정)이 이 파일의 규칙을 공유한다. | |
| 5 | + * **이 파일이 검증의 단일 진실원천이다** — Server Action이 저장 직전에 여기를 거친다. | |
| 6 | + * | |
| 7 | + * 시안의 필수(*) 표시는 상세코드 팝업에만 있지만(코드·코드값의미·정렬번호), 공통코드도 | |
| 8 | + * 코드ID·코드명 없이는 저장할 수 없으므로 같은 강도로 검증한다 — 화면 표시와 무관하게 | |
| 9 | + * 백엔드가 PK로 쓰는 값이다. | |
| 10 | + * | |
| 11 | + * `CommonCodeFormState`가 이 파일에 있는 이유는 Next.js가 `'use server'` 파일에서 함수가 아닌 | |
| 12 | + * 값을 export하는 것을 런타임에 거부하기 때문이다(꾸미기 아이템 폼과 같은 사정). | |
| 13 | + */ | |
| 14 | + | |
| 15 | +const CODE_MAX_LENGTH = 50; | |
| 16 | +const NAME_MAX_LENGTH = 100; | |
| 17 | +const DESCRIPTION_MAX_LENGTH = 500; | |
| 18 | + | |
| 19 | +/** 코드값은 영문 대문자·숫자·언더스코어만 받는다 — 코드테이블 키라 공백·한글이 섞이면 곤란하다. */ | |
| 20 | +const CODE_PATTERN = /^[A-Z0-9_]+$/; | |
| 21 | + | |
| 22 | +/** 공통코드(그룹) 등록·수정이 실제로 바꿀 수 있는 항목. */ | |
| 23 | +export type CommonCodeGroupValues = { | |
| 24 | + comCd: string; | |
| 25 | + name: string; | |
| 26 | + description: string; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +/** 상세코드 등록·수정이 실제로 바꿀 수 있는 항목. */ | |
| 30 | +export type CommonCodeDetailValues = { | |
| 31 | + /** 소속 그룹. 화면에서는 읽기 전용이지만 저장 대상이라 값으로 다룬다. */ | |
| 32 | + comCd: string; | |
| 33 | + comDtlCd: string; | |
| 34 | + /** 시안의 "코드값의미" — 백엔드 `cdNm`이다. */ | |
| 35 | + name: string; | |
| 36 | + description: string; | |
| 37 | + sortSeq: number; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +export type CommonCodeFormErrors = Partial< | |
| 41 | + Record< | |
| 42 | + keyof CommonCodeGroupValues | keyof CommonCodeDetailValues, | |
| 43 | + string | |
| 44 | + > | |
| 45 | +>; | |
| 46 | + | |
| 47 | +export type ValidationResult<T> = | |
| 48 | + | { ok: true; values: T } | |
| 49 | + | { ok: false; errors: CommonCodeFormErrors }; | |
| 50 | + | |
| 51 | +export type CommonCodeFormState = | |
| 52 | + | { status: 'idle' } | |
| 53 | + | { status: 'error'; message?: string; errors?: CommonCodeFormErrors } | |
| 54 | + | { status: 'success' }; | |
| 55 | + | |
| 56 | +export const INITIAL_COMMON_CODE_FORM_STATE: CommonCodeFormState = { | |
| 57 | + status: 'idle', | |
| 58 | +}; | |
| 59 | + | |
| 60 | +/** 코드값 공통 검증 — 그룹의 `comCd`와 상세의 `comDtlCd`가 같은 규칙을 쓴다. */ | |
| 61 | +function validateCode( | |
| 62 | + raw: string, | |
| 63 | + label: string | |
| 64 | +): { value: string; error?: string } { | |
| 65 | + // 코드는 대문자로 정규화한다 — 소문자로 저장되면 조회 조건과 어긋난다. | |
| 66 | + const value = raw.trim().toUpperCase(); | |
| 67 | + | |
| 68 | + if (!value) { | |
| 69 | + return { value, error: `${label}를 입력해 주세요.` }; | |
| 70 | + } | |
| 71 | + if (value.length > CODE_MAX_LENGTH) { | |
| 72 | + return { | |
| 73 | + value, | |
| 74 | + error: `${label}는 ${CODE_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 75 | + }; | |
| 76 | + } | |
| 77 | + if (!CODE_PATTERN.test(value)) { | |
| 78 | + return { value, error: `${label}는 영문 대문자·숫자·_만 사용할 수 있습니다.` }; | |
| 79 | + } | |
| 80 | + return { value }; | |
| 81 | +} | |
| 82 | + | |
| 83 | +function validateName(raw: string, label: string): { value: string; error?: string } { | |
| 84 | + const value = raw.trim(); | |
| 85 | + | |
| 86 | + if (!value) { | |
| 87 | + return { value, error: `${label}을 입력해 주세요.` }; | |
| 88 | + } | |
| 89 | + if (value.length > NAME_MAX_LENGTH) { | |
| 90 | + return { | |
| 91 | + value, | |
| 92 | + error: `${label}은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 93 | + }; | |
| 94 | + } | |
| 95 | + return { value }; | |
| 96 | +} | |
| 97 | + | |
| 98 | +function validateDescription(raw: string): { value: string; error?: string } { | |
| 99 | + const value = raw.trim(); | |
| 100 | + | |
| 101 | + if (value.length > DESCRIPTION_MAX_LENGTH) { | |
| 102 | + return { | |
| 103 | + value, | |
| 104 | + error: `설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 105 | + }; | |
| 106 | + } | |
| 107 | + return { value }; | |
| 108 | +} | |
| 109 | + | |
| 110 | +/** 시안 공통코드 등록/수정 — 수정은 코드ID가 읽기 전용이라 값이 폼이 아니라 경로에서 온다. */ | |
| 111 | +export function validateCommonCodeGroup( | |
| 112 | + values: CommonCodeGroupValues | |
| 113 | +): ValidationResult<CommonCodeGroupValues> { | |
| 114 | + const errors: CommonCodeFormErrors = {}; | |
| 115 | + | |
| 116 | + const code = validateCode(values.comCd, '코드ID'); | |
| 117 | + if (code.error) { | |
| 118 | + errors.comCd = code.error; | |
| 119 | + } | |
| 120 | + | |
| 121 | + const name = validateName(values.name, '코드ID명'); | |
| 122 | + if (name.error) { | |
| 123 | + errors.name = name.error; | |
| 124 | + } | |
| 125 | + | |
| 126 | + const description = validateDescription(values.description); | |
| 127 | + if (description.error) { | |
| 128 | + errors.description = description.error; | |
| 129 | + } | |
| 130 | + | |
| 131 | + if (Object.keys(errors).length > 0) { | |
| 132 | + return { ok: false, errors }; | |
| 133 | + } | |
| 134 | + | |
| 135 | + return { | |
| 136 | + ok: true, | |
| 137 | + values: { | |
| 138 | + comCd: code.value, | |
| 139 | + name: name.value, | |
| 140 | + description: description.value, | |
| 141 | + }, | |
| 142 | + }; | |
| 143 | +} | |
| 144 | + | |
| 145 | +/** 시안 상세코드 등록/수정. */ | |
| 146 | +export function validateCommonCodeDetail( | |
| 147 | + values: CommonCodeDetailValues | |
| 148 | +): ValidationResult<CommonCodeDetailValues> { | |
| 149 | + const errors: CommonCodeFormErrors = {}; | |
| 150 | + | |
| 151 | + const group = validateCode(values.comCd, '코드ID'); | |
| 152 | + if (group.error) { | |
| 153 | + // 상세코드 팝업의 코드ID는 좌측 선택에서 오는 읽기 전용 값이라, 여기가 비었다는 것은 | |
| 154 | + // 공통코드를 고르지 않고 저장이 시도됐다는 뜻이다. | |
| 155 | + errors.comCd = '공통코드를 먼저 선택해 주세요.'; | |
| 156 | + } | |
| 157 | + | |
| 158 | + const code = validateCode(values.comDtlCd, '코드'); | |
| 159 | + if (code.error) { | |
| 160 | + errors.comDtlCd = code.error; | |
| 161 | + } | |
| 162 | + | |
| 163 | + const name = validateName(values.name, '코드값의미'); | |
| 164 | + if (name.error) { | |
| 165 | + errors.name = name.error; | |
| 166 | + } | |
| 167 | + | |
| 168 | + const description = validateDescription(values.description); | |
| 169 | + if (description.error) { | |
| 170 | + errors.description = description.error; | |
| 171 | + } | |
| 172 | + | |
| 173 | + if (!Number.isInteger(values.sortSeq) || values.sortSeq < 1) { | |
| 174 | + errors.sortSeq = '정렬번호는 1 이상의 숫자로 입력해 주세요.'; | |
| 175 | + } | |
| 176 | + | |
| 177 | + if (Object.keys(errors).length > 0) { | |
| 178 | + return { ok: false, errors }; | |
| 179 | + } | |
| 180 | + | |
| 181 | + return { | |
| 182 | + ok: true, | |
| 183 | + values: { | |
| 184 | + comCd: group.value, | |
| 185 | + comDtlCd: code.value, | |
| 186 | + name: name.value, | |
| 187 | + description: description.value, | |
| 188 | + sortSeq: values.sortSeq, | |
| 189 | + }, | |
| 190 | + }; | |
| 191 | +} |
+++ lib/domain/common-code-query.ts
... | ... | @@ -0,0 +1,137 @@ |
| 1 | +/** | |
| 2 | + * 코드관리 화면(SYS_COD_001)의 URL 조건 — 순수 규칙만 담는다(next/react 의존 없음). | |
| 3 | + * | |
| 4 | + * 이 화면은 목록이 둘이라 상태가 셋이다. **선택된 공통코드(`comCd`)**, 공통코드 목록의 검색· | |
| 5 | + * 페이지, 상세코드 목록의 페이지. 셋 다 URL이 소유한다 — 다른 목록 화면과 같은 규칙이고, | |
| 6 | + * 새로고침·뒤로가기·링크 공유가 그대로 동작한다. | |
| 7 | + * | |
| 8 | + * **선택이 바뀌면 상세 페이지는 1로 돌아간다** — 다른 그룹의 3페이지는 의미가 없다. | |
| 9 | + * 그 규칙은 `buildCommonCodeHref`가 강제한다(호출부가 잊어도 어긋나지 않게). | |
| 10 | + * | |
| 11 | + * 페이징·검색을 URL에 두면서도 백엔드에는 넘기지 않는다 — 백엔드가 두 목록 모두 전체를 | |
| 12 | + * 반환하고 검색도 완전일치라, 자르고 거르는 일은 서버 컴포넌트가 한다(Repository 주석 참조). | |
| 13 | + */ | |
| 14 | + | |
| 15 | +/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ | |
| 16 | +export const COMMON_CODES_PATH = '/system/codes'; | |
| 17 | + | |
| 18 | +/** 검색 대상 — 시안(SYS_COD_001 ①) "코드명 / 코드ID". */ | |
| 19 | +export type CommonCodeSearchField = 'name' | 'comCd'; | |
| 20 | + | |
| 21 | +export const COMMON_CODE_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ | |
| 22 | + value: CommonCodeSearchField; | |
| 23 | + label: string; | |
| 24 | +}> = [ | |
| 25 | + { value: 'name', label: '코드명' }, | |
| 26 | + { value: 'comCd', label: '코드ID' }, | |
| 27 | +]; | |
| 28 | + | |
| 29 | +export const DEFAULT_COMMON_CODE_SEARCH_FIELD: CommonCodeSearchField = 'name'; | |
| 30 | + | |
| 31 | +/** 시안의 두 목록은 한 화면에 나란히 서므로 페이지 크기를 고르는 자리가 없다 — 고정값이다. */ | |
| 32 | +export const COMMON_CODE_PAGE_SIZE = 10; | |
| 33 | +export const COMMON_CODE_DETAIL_PAGE_SIZE = 10; | |
| 34 | + | |
| 35 | +const DEFAULT_PAGE = 1; | |
| 36 | +const MAX_KEYWORD_LENGTH = 100; | |
| 37 | + | |
| 38 | +export type CommonCodeQuery = { | |
| 39 | + /** 선택된 공통코드. 아직 고르지 않았으면 null이고, 화면이 첫 행으로 채운다. */ | |
| 40 | + comCd: string | null; | |
| 41 | + searchField: CommonCodeSearchField; | |
| 42 | + keyword: string; | |
| 43 | + /** 공통코드 목록의 페이지. */ | |
| 44 | + page: number; | |
| 45 | + /** 상세코드 목록의 페이지. */ | |
| 46 | + detailPage: number; | |
| 47 | +}; | |
| 48 | + | |
| 49 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 50 | + | |
| 51 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 52 | + const value = params[key]; | |
| 53 | + return Array.isArray(value) ? value[0] : value; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function readPage(params: RawSearchParams, key: string): number { | |
| 57 | + const value = Number(readParam(params, key)); | |
| 58 | + return Number.isInteger(value) && value > 0 ? value : DEFAULT_PAGE; | |
| 59 | +} | |
| 60 | + | |
| 61 | +function isSearchField( | |
| 62 | + value: string | undefined | |
| 63 | +): value is CommonCodeSearchField { | |
| 64 | + return ( | |
| 65 | + value !== undefined && | |
| 66 | + COMMON_CODE_SEARCH_FIELD_OPTIONS.some((option) => option.value === value) | |
| 67 | + ); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * URL의 searchParams를 검증된 `CommonCodeQuery`로 정규화한다. searchParams는 사용자가 임의로 | |
| 72 | + * 조작할 수 있는 값이라 신뢰하지 않는다 — 허용 목록을 벗어나면 기본값으로 떨어진다. | |
| 73 | + * | |
| 74 | + * `comCd`만은 허용 목록을 여기서 확인할 수 없다(코드 목록이 서버에 있다) — 존재 여부는 화면이 | |
| 75 | + * 조회 결과와 맞춰 보고 없으면 첫 행으로 대체한다. | |
| 76 | + */ | |
| 77 | +export function parseCommonCodeQuery( | |
| 78 | + searchParams: RawSearchParams | |
| 79 | +): CommonCodeQuery { | |
| 80 | + const searchFieldRaw = readParam(searchParams, 'searchField'); | |
| 81 | + const comCd = readParam(searchParams, 'comCd')?.trim(); | |
| 82 | + | |
| 83 | + return { | |
| 84 | + comCd: comCd ? comCd.slice(0, 50) : null, | |
| 85 | + searchField: isSearchField(searchFieldRaw) | |
| 86 | + ? searchFieldRaw | |
| 87 | + : DEFAULT_COMMON_CODE_SEARCH_FIELD, | |
| 88 | + keyword: (readParam(searchParams, 'keyword') ?? '') | |
| 89 | + .trim() | |
| 90 | + .slice(0, MAX_KEYWORD_LENGTH), | |
| 91 | + page: readPage(searchParams, 'page'), | |
| 92 | + detailPage: readPage(searchParams, 'detailPage'), | |
| 93 | + }; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** | |
| 97 | + * `CommonCodeQuery`(+ 부분 override)를 링크로 직렬화한다. `parseCommonCodeQuery`의 역연산이며 | |
| 98 | + * 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다. | |
| 99 | + * | |
| 100 | + * **공통코드 선택이 바뀌면 상세 페이지를 1로 되돌린다** — 호출부가 잊어도 어긋나지 않도록 | |
| 101 | + * 여기서 강제한다(override로 detailPage를 함께 준 경우는 그 값을 존중한다). | |
| 102 | + */ | |
| 103 | +export function buildCommonCodeHref( | |
| 104 | + query: CommonCodeQuery, | |
| 105 | + overrides: Partial<CommonCodeQuery> = {} | |
| 106 | +): string { | |
| 107 | + const merged = { ...query, ...overrides }; | |
| 108 | + | |
| 109 | + if ( | |
| 110 | + overrides.comCd !== undefined && | |
| 111 | + overrides.comCd !== query.comCd && | |
| 112 | + overrides.detailPage === undefined | |
| 113 | + ) { | |
| 114 | + merged.detailPage = DEFAULT_PAGE; | |
| 115 | + } | |
| 116 | + | |
| 117 | + const params = new URLSearchParams(); | |
| 118 | + | |
| 119 | + if (merged.comCd) { | |
| 120 | + params.set('comCd', merged.comCd); | |
| 121 | + } | |
| 122 | + if (merged.searchField !== DEFAULT_COMMON_CODE_SEARCH_FIELD) { | |
| 123 | + params.set('searchField', merged.searchField); | |
| 124 | + } | |
| 125 | + if (merged.keyword) { | |
| 126 | + params.set('keyword', merged.keyword); | |
| 127 | + } | |
| 128 | + if (merged.page !== DEFAULT_PAGE) { | |
| 129 | + params.set('page', String(merged.page)); | |
| 130 | + } | |
| 131 | + if (merged.detailPage !== DEFAULT_PAGE) { | |
| 132 | + params.set('detailPage', String(merged.detailPage)); | |
| 133 | + } | |
| 134 | + | |
| 135 | + const queryString = params.toString(); | |
| 136 | + return queryString ? `${COMMON_CODES_PATH}?${queryString}` : COMMON_CODES_PATH; | |
| 137 | +} |
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
... | ... | @@ -1,10 +1,15 @@ |
| 1 | 1 |
/** |
| 2 | 2 |
* 공통코드 도메인 — 순수 데이터 표현, 외부 의존 없음. |
| 3 | 3 |
* |
| 4 |
- * 백엔드 `TB_SYS_COM_CD_DTL`의 한 그룹(`COM_CD`)에 속한 상세코드 목록이며, |
|
| 5 |
- * `GET /api/v1/mngr/code/list/{comCd}`(ROLE_ADMIN)로 가져온다.
|
|
| 4 |
+ * 백엔드 `TB_SYS_COM_CD`(그룹)와 `TB_SYS_COM_CD_DTL`(상세)의 두 층이며, 둘 다 |
|
| 5 |
+ * `/api/v1/mngr/code/**`(ROLE_ADMIN)로 읽고 쓴다. |
|
| 6 |
+ * |
|
| 7 |
+ * 이 파일은 두 종류의 소비자를 함께 섬긴다. |
|
| 8 |
+ * - 코드관리 화면(SYS_COD_001): 그룹·상세를 편집한다 → `CommonCodeGroup`·`CommonCodeDetail` |
|
| 9 |
+ * - 다른 화면의 선택지: 상세코드를 `{code,label}`로만 쓴다 → `CommonCode`
|
|
| 6 | 10 |
*/ |
| 7 | 11 |
|
| 12 |
+/** 선택지로 쓸 때의 최소 표현. 화면이 코드 편집에 관심이 없을 때 쓴다. */ |
|
| 8 | 13 |
export type CommonCode = {
|
| 9 | 14 |
/** 백엔드 `comDtlCd` — 저장·전송에 쓰는 코드값. */ |
| 10 | 15 |
code: string; |
... | ... | @@ -18,6 +23,84 @@ |
| 18 | 23 |
decorationItemCategory: 'ITEM_CATE_CD', |
| 19 | 24 |
} as const; |
| 20 | 25 |
|
| 26 |
+/** 값이 없는 항목의 화면 표기. */ |
|
| 27 |
+export const EMPTY_FIELD_PLACEHOLDER = '-'; |
|
| 28 |
+ |
|
| 29 |
+/** |
|
| 30 |
+ * 공통코드(그룹) — 시안의 "공통코드 목록" 한 줄. |
|
| 31 |
+ * |
|
| 32 |
+ * `TB_SYS_COM_CD`의 컬럼은 이 셋이 전부다(+감사 컬럼). 시안 등록 팝업의 "분류코드"에 |
|
| 33 |
+ * 해당하는 컬럼은 **없다** — 그 셀렉트는 코드ID 접두사를 채워 주는 입력 보조일 뿐이고 |
|
| 34 |
+ * 저장되는 값은 `comCd` 하나다(사용자 확정 사항). |
|
| 35 |
+ */ |
|
| 36 |
+export type CommonCodeGroup = {
|
|
| 37 |
+ /** 백엔드 `comCd` — PK이자 화면의 "코드ID". 상세코드를 묶는 키다. */ |
|
| 38 |
+ comCd: string; |
|
| 39 |
+ /** 백엔드 `cdNm` — 화면의 "코드명". */ |
|
| 40 |
+ name: string; |
|
| 41 |
+ /** 백엔드 `cdExpln` — 화면의 "코드설명". */ |
|
| 42 |
+ description: string | null; |
|
| 43 |
+ /** 백엔드 `frstRegDtStr` — `YYYY-MM-DD`. */ |
|
| 44 |
+ createdAt: string | null; |
|
| 45 |
+}; |
|
| 46 |
+ |
|
| 47 |
+/** |
|
| 48 |
+ * 상세코드 — 시안의 "상세코드 목록" 한 줄. |
|
| 49 |
+ * |
|
| 50 |
+ * ⚠️ `description`은 지금 **항상 null이다** — 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지 |
|
| 51 |
+ * 않는다. 등록 INSERT에서도 빠져 있어 수정으로만 저장된다(Repository 주석 참조). |
|
| 52 |
+ */ |
|
| 53 |
+export type CommonCodeDetail = {
|
|
| 54 |
+ /** 백엔드 `comCd` — 이 상세코드가 속한 그룹. */ |
|
| 55 |
+ comCd: string; |
|
| 56 |
+ /** 백엔드 `comDtlCd` — 그룹 안에서의 코드값. 화면의 "상세코드ID". */ |
|
| 57 |
+ comDtlCd: string; |
|
| 58 |
+ /** 백엔드 `cdNm` — 화면의 "상세코드명"이자 등록 팝업의 "코드값의미". */ |
|
| 59 |
+ name: string; |
|
| 60 |
+ /** 백엔드 `dtlCdExpln`. */ |
|
| 61 |
+ description: string | null; |
|
| 62 |
+ /** 백엔드 `sortSeq` — 등록 팝업의 "정렬번호"이자 목록의 "번호". */ |
|
| 63 |
+ sortSeq: number; |
|
| 64 |
+ /** 백엔드 `frstRegDtStr` — `YYYY-MM-DD`. */ |
|
| 65 |
+ createdAt: string | null; |
|
| 66 |
+}; |
|
| 67 |
+ |
|
| 68 |
+/** |
|
| 69 |
+ * 코드ID 접두사(시안의 "분류코드") — 저장되는 값이 아니라 코드ID를 지을 때의 작명 규칙이다. |
|
| 70 |
+ * 시안 목록이 전부 `CMS004`처럼 접두사 3자 + 일련번호라 그 규칙을 화면이 거들게 한다. |
|
| 71 |
+ * |
|
| 72 |
+ * 백엔드에 분류 컬럼이 생기면 이 상수 대신 그 코드 목록을 쓰면 된다. |
|
| 73 |
+ */ |
|
| 74 |
+export const CODE_ID_PREFIXES: readonly string[] = ['CMS', 'SYS', 'FSC', 'CST']; |
|
| 75 |
+ |
|
| 76 |
+export const DEFAULT_CODE_ID_PREFIX = CODE_ID_PREFIXES[0]; |
|
| 77 |
+ |
|
| 78 |
+/** |
|
| 79 |
+ * 코드ID에서 접두사를 읽는다 — 앞 3자가 아는 접두사면 그것을, 아니면 기본값을 돌려준다. |
|
| 80 |
+ * 수정 팝업이 기존 코드ID로 셀렉트의 초기값을 정할 때 쓴다. |
|
| 81 |
+ */ |
|
| 82 |
+export function readCodeIdPrefix(comCd: string): string {
|
|
| 83 |
+ const head = comCd.slice(0, 3).toUpperCase(); |
|
| 84 |
+ return CODE_ID_PREFIXES.includes(head) ? head : DEFAULT_CODE_ID_PREFIX; |
|
| 85 |
+} |
|
| 86 |
+ |
|
| 87 |
+/** |
|
| 88 |
+ * 접두사를 바꿔 끼운 코드ID를 만든다. 기존 값이 아는 접두사로 시작하면 그 자리를 갈아 끼우고, |
|
| 89 |
+ * 아니면 앞에 덧붙인다 — 사용자가 이미 적어 둔 일련번호를 지우지 않기 위해서다. |
|
| 90 |
+ */ |
|
| 91 |
+export function applyCodeIdPrefix(comCd: string, prefix: string): string {
|
|
| 92 |
+ const rest = CODE_ID_PREFIXES.includes(comCd.slice(0, 3).toUpperCase()) |
|
| 93 |
+ ? comCd.slice(3) |
|
| 94 |
+ : comCd; |
|
| 95 |
+ return `${prefix}${rest}`;
|
|
| 96 |
+} |
|
| 97 |
+ |
|
| 98 |
+export function formatOptionalText(value: string | null | undefined): string {
|
|
| 99 |
+ return value === null || value === undefined || value === '' |
|
| 100 |
+ ? EMPTY_FIELD_PLACEHOLDER |
|
| 101 |
+ : value; |
|
| 102 |
+} |
|
| 103 |
+ |
|
| 21 | 104 |
/** 코드값 → 이름. 목록에 없는 코드는 코드값 자체를 보여준다(이름을 지어내지 않는다). */ |
| 22 | 105 |
export function formatCommonCode( |
| 23 | 106 |
codes: readonly CommonCode[], |
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?