임동욱 임동욱 08-19
merge: 시스템관리 코드관리
@525db88af0c4a08ff18aea3616778ce7945473da
 
@fox/core/components/fox-heading-group/fox-heading-group.tsx (added)
+++ @fox/core/components/fox-heading-group/fox-heading-group.tsx
@@ -0,0 +1,64 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxHeadingGroupProps {
+  /** 구역 제목. 화면 제목(`FoxPageHeader`)이 아니라 그 아래 구역의 이름이다. */
+  title: ReactNode;
+  /**
+   * 제목을 그릴 태그. 한 화면에 여러 구역이 서면 문서 구조가 어긋나지 않게 단계를 고른다.
+   * 화면 제목은 `FoxPageHeader`가 `<h1>`으로 그리므로 여기 기본은 `h2`다.
+   */
+  as?: "h2" | "h3" | "h4";
+  /** 제목 아래 한 줄 설명. 넘기지 않으면 영역을 그리지 않는다. */
+  description?: ReactNode;
+  /** 제목 오른쪽 자리. 보통 버튼이다. */
+  actions?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 구역 머리말 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) heading-group (3002:7177)
+ *
+ * 한 화면 안에서 목록·표 같은 구역을 이름 짓는다. `FoxPageHeader`와 역할이 다르다 — 그쪽은
+ * 화면에 하나뿐인 제목(`<h1>`)과 현재 위치를 갖고, 이쪽은 화면 안에 여럿 설 수 있다.
+ * 그래서 제목 태그를 `as`로 고를 수 있고 breadcrumb이 없다.
+ *
+ * 아래 여백은 이 조각이 갖는다(시안 spacing/bottom/md) — 뒤따르는 도구 줄·표가 간격을 따로
+ * 두지 않아도 되게 하려는 것이고, `FoxPageHeader`와 같은 규칙이다.
+ *
+ * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxHeadingGroup({
+  title,
+  as: Tag = "h2",
+  description,
+  actions,
+  hidden = false,
+  className,
+  ref,
+}: FoxHeadingGroupProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div ref={ref} className={cx("fox-heading-group", className)}>
+      <div className="fox-heading-group__text">
+        <div className="fox-heading-group__heading">
+          <Tag className="fox-heading-group__title">{title}</Tag>
+        </div>
+        {description && (
+          <p className="fox-heading-group__description">{description}</p>
+        )}
+      </div>
+      {actions && <div className="fox-heading-group__actions">{actions}</div>}
+    </div>
+  );
+}
 
@fox/core/components/fox-heading-group/index.ts (added)
+++ @fox/core/components/fox-heading-group/index.ts
@@ -0,0 +1,1 @@
+export { FoxHeadingGroup, type FoxHeadingGroupProps } from "./fox-heading-group";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -26,6 +26,7 @@
 export * from "./fox-email";
 export * from "./fox-file-upload";
 export * from "./fox-form-label";
+export * from "./fox-heading-group";
 export * from "./fox-helper-text";
 export * from "./fox-icon-button";
 export * from "./fox-input";
 
@fox/styles/_fox-heading-group.scss (added)
+++ @fox/styles/_fox-heading-group.scss
@@ -0,0 +1,70 @@
+// FoxHeadingGroup — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) heading-group (3002:7177)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-heading-group">
+//     <div class="fox-heading-group__text">
+//       <div class="fox-heading-group__heading"><h2 class="fox-heading-group__title">제목</h2></div>
+//       <p class="fox-heading-group__description">설명</p>          <!-- 선택 -->
+//     </div>
+//     <div class="fox-heading-group__actions">…</div>               <!-- 선택 -->
+//   </div>
+//
+// 제목 줄이 따로 있는 이유는 시안이 그 안에 아이콘·배지를 함께 세울 자리를 두기 때문이다
+// (지금은 제목 하나뿐이지만 8px 간격이 이미 잡혀 있다).
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일 display/sm의 값이나 Figma가 변수로
+//     export하지 않는다. -0.65px은 26px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은 근거).
+//   - margin 0: 제목 태그의 브라우저 기본값 지우기.
+
+@use "abstracts" as fox;
+
+.fox-heading-group {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  gap: fox.gap(5);
+  // 뒤따르는 도구 줄·표가 간격을 따로 두지 않게 이 조각이 아래 여백을 갖는다.
+  padding-block-end: fox.spacing(bottom-md);
+
+  &__text {
+    display: flex;
+    flex: 1 0 0;
+    min-inline-size: 0;
+    flex-direction: column;
+  }
+
+  &__heading {
+    display: flex;
+    inline-size: 100%;
+    align-items: center;
+    gap: fox.gap(3);
+  }
+
+  &__title {
+    margin: 0;
+    color: fox.color(font-neutral-strong);
+    font-family: fox.font-family(title);
+    font-size: fox.font-size(display-sm);
+    font-weight: fox.font-weight(bold);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__description {
+    margin: 0;
+    color: fox.color(font-neutral-subtle);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__actions {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    gap: fox.gap(3);
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -20,6 +20,7 @@
 @use "fox-email";
 @use "fox-file-upload";
 @use "fox-form-label";
+@use "fox-heading-group";
 @use "fox-helper-text";
 @use "fox-input";
 @use "fox-phone-number";
app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
--- app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
+++ app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
@@ -13,6 +13,7 @@
   FoxSquaresFourIcon,
   FoxUserSquareIcon,
   FoxUsersThreeIcon,
+  FoxWrenchIcon,
   type FoxIconProps,
 } from '@fox/core/icons';
 
@@ -38,6 +39,7 @@
   'shield-check': FoxShieldCheckIcon,
   'phone-call': FoxPhoneCallIcon,
   'chart-bar': FoxChartBarIcon,
+  wrench: FoxWrenchIcon,
 };
 
 /** 모르는 이름이 왔을 때. 메뉴가 통째로 사라지는 것보다 낫다. */
 
app/(protected)/(basic)/system/codes/_actions.ts (added)
+++ app/(protected)/(basic)/system/codes/_actions.ts
@@ -0,0 +1,217 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  createCodeDetail,
+  createCodeGroup,
+  deleteCodeDetail,
+  deleteCodeGroup,
+  updateCodeDetail,
+  updateCodeGroup,
+} from '@/lib/data/repositories/common-code-repository';
+import {
+  validateCommonCodeDetail,
+  validateCommonCodeGroup,
+  type CommonCodeFormState,
+} from '@/lib/domain/common-code-form';
+import { COMMON_CODES_PATH } from '@/lib/domain/common-code-query';
+
+/**
+ * 코드관리 등록/수정/삭제 Server Action(기획 SYS_COD_001).
+ *
+ * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접
+ * POST될 수 있어 이 확인이 유일한 최종 방어선이다.
+ *
+ * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/common-code-form.ts`의 규칙을 호출) —
+ * 화면의 required 속성과 읽기 전용 표시는 편의일 뿐 신뢰 경계가 아니다.
+ *
+ * **수정 대상(코드ID)은 읽기 전용 입력이 아니라 hidden 필드에서 읽는다** — 읽기 전용 칸은
+ * 위조될 수 있고, 위조되더라도 대상이 바뀌면 안 되기 때문이다.
+ */
+
+const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+/**
+ * 폼 숫자 입력을 파싱한다. `Number('')`이 조용히 `0`이 되는 함정을 피하려고 빈 문자열은
+ * 명시적으로 `NaN`으로 둔다 — 그래야 검증이 "값이 비었음"을 잡아낸다.
+ */
+function parseFormNumber(formData: FormData, key: string): number {
+  const trimmed = readString(formData, key).trim();
+  return trimmed === '' ? NaN : Number(trimmed);
+}
+
+/* ── 공통코드 ─────────────────────────────────────────────────────────────── */
+
+/** 기획 [공통코드 등록] 팝업. */
+export async function createCodeGroupAction(
+  _prevState: CommonCodeFormState,
+  formData: FormData
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  const validation = validateCommonCodeGroup({
+    comCd: readString(formData, 'comCd'),
+    name: readString(formData, 'name'),
+    description: readString(formData, 'description'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await createCodeGroup(validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+/** 기획 [공통코드 수정] 팝업 — 코드ID는 대상을 가리키는 값이라 바뀌지 않는다. */
+export async function updateCodeGroupAction(
+  _prevState: CommonCodeFormState,
+  formData: FormData
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  const targetComCd = readString(formData, 'targetComCd').trim();
+  if (!targetComCd) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  const validation = validateCommonCodeGroup({
+    comCd: targetComCd,
+    name: readString(formData, 'name'),
+    description: readString(formData, 'description'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await updateCodeGroup(targetComCd, validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+/**
+ * 기획 ④ 삭제 — 확인 얼럿은 화면이 띄우고 여기서는 인증과 입력만 확인한다.
+ *
+ * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
+ * (prevState, formData) 규약 대신 식별자를 직접 받는다.
+ */
+export async function deleteCodeGroupAction(
+  comCd: string
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  if (!comCd) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    await deleteCodeGroup(comCd);
+  } catch {
+    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+/* ── 상세코드 ─────────────────────────────────────────────────────────────── */
+
+/** 기획 [상세코드 등록] 팝업 — 코드ID는 좌측에서 고른 공통코드다. */
+export async function createCodeDetailAction(
+  _prevState: CommonCodeFormState,
+  formData: FormData
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  const validation = validateCommonCodeDetail({
+    comCd: readString(formData, 'comCd'),
+    comDtlCd: readString(formData, 'comDtlCd'),
+    name: readString(formData, 'name'),
+    description: readString(formData, 'description'),
+    sortSeq: parseFormNumber(formData, 'sortSeq'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await createCodeDetail(validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+/** 기획 [상세코드 수정] 팝업 — 코드ID·코드는 대상을 가리키는 값이라 바뀌지 않는다. */
+export async function updateCodeDetailAction(
+  _prevState: CommonCodeFormState,
+  formData: FormData
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  const targetComCd = readString(formData, 'targetComCd').trim();
+  const targetComDtlCd = readString(formData, 'targetComDtlCd').trim();
+  if (!targetComCd || !targetComDtlCd) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  const validation = validateCommonCodeDetail({
+    comCd: targetComCd,
+    comDtlCd: readString(formData, 'comDtlCd'),
+    name: readString(formData, 'name'),
+    description: readString(formData, 'description'),
+    sortSeq: parseFormNumber(formData, 'sortSeq'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await updateCodeDetail(targetComCd, targetComDtlCd, validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
+
+export async function deleteCodeDetailAction(
+  comCd: string,
+  comDtlCd: string
+): Promise<CommonCodeFormState> {
+  await verifySession();
+
+  if (!comCd || !comDtlCd) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    await deleteCodeDetail(comCd, comDtlCd);
+  } catch {
+    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(COMMON_CODES_PATH);
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/system/codes/_components/code-detail-modal.tsx (added)
+++ app/(protected)/(basic)/system/codes/_components/code-detail-modal.tsx
@@ -0,0 +1,162 @@
+'use client';
+
+import { useActionState, useEffect, useRef } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCodeDetail } from '@/lib/domain/common-code';
+import { INITIAL_COMMON_CODE_FORM_STATE } from '@/lib/domain/common-code-form';
+import { createCodeDetailAction, updateCodeDetailAction } from '../_actions';
+import styles from './code-form.module.scss';
+
+interface CodeDetailModalProps {
+  /** 소속 공통코드 — 좌측에서 고른 값이라 팝업에서는 바꿀 수 없다. */
+  comCd: string;
+  /** 수정 대상. 없으면 등록 팝업이다. */
+  detail?: CommonCodeDetail;
+  /** 등록 시 채워 둘 정렬번호(기존 개수 + 1). */
+  nextSortSeq: number;
+  onClose: () => void;
+}
+
+/**
+ * 상세코드 등록/수정 팝업 — 기획 1842:16179 / 1842:16262.
+ *
+ * 코드ID는 좌측에서 고른 공통코드라 **항상 읽기 전용**이다(기획 ①: "상위코드 고정된 경우 비활성
+ * 처리"). 수정에서는 코드(`comDtlCd`)도 대상을 가리키는 값이라 바꿀 수 없다 — 백엔드가 두 코드로
+ * 행을 찾기 때문에 바꾸면 다른 행을 수정하게 된다.
+ *
+ * ⚠️ **등록 시 코드설명은 저장되지 않는다** — 백엔드 INSERT에 `DTL_CD_EXPLN`이 빠져 있다.
+ * 시안대로 칸은 두고 백엔드에 수정을 요청했다(Repository 주석 참조). 수정에서는 정상 저장된다.
+ */
+export function CodeDetailModal({
+  comCd,
+  detail,
+  nextSortSeq,
+  onClose,
+}: CodeDetailModalProps) {
+  const isEdit = detail !== undefined;
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    isEdit ? updateCodeDetailAction : createCodeDetailAction,
+    INITIAL_COMMON_CODE_FORM_STATE
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({
+        variant: 'success',
+        message: isEdit ? '상세코드를 수정했습니다.' : '상세코드를 등록했습니다.',
+      });
+      onClose();
+    }
+  }, [state, showToast, onClose, isEdit]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <FoxModal
+      open
+      size="sm"
+      title={isEdit ? '상세코드 수정' : '상세코드 등록'}
+      onClose={onClose}
+      actions={
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '저장 중...' : '저장'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </>
+      }
+    >
+      <form ref={formRef} action={formAction} className={styles.fields}>
+        <input type="hidden" name={isEdit ? 'targetComCd' : 'comCd'} value={comCd} />
+        {isEdit && (
+          <input
+            type="hidden"
+            name="targetComDtlCd"
+            value={detail.comDtlCd}
+          />
+        )}
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            label="코드ID"
+            value={comCd}
+            readOnly
+            message="상위코드가 고정된 경우 비활성 처리됩니다."
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="comDtlCd"
+            label="코드"
+            requirement="required"
+            defaultValue={detail?.comDtlCd ?? ''}
+            readOnly={isEdit}
+            placeholder="코드를 입력하세요."
+            invalid={Boolean(errors.comDtlCd)}
+            message={errors.comDtlCd}
+            maxLength={50}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="name"
+            label="코드값의미"
+            requirement="required"
+            defaultValue={detail?.name ?? ''}
+            placeholder="코드값의미를 입력하세요."
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+            maxLength={100}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            type="number"
+            name="sortSeq"
+            label="정렬번호"
+            requirement="required"
+            min={1}
+            step={1}
+            defaultValue={detail?.sortSeq ?? nextSortSeq}
+            invalid={Boolean(errors.sortSeq)}
+            message={errors.sortSeq}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="description"
+            label="코드설명"
+            defaultValue={detail?.description ?? ''}
+            placeholder="코드설명을 입력하세요."
+            invalid={Boolean(errors.description)}
+            message={errors.description}
+            maxLength={500}
+          />
+        </div>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/system/codes/_components/code-form.module.scss (added)
+++ app/(protected)/(basic)/system/codes/_components/code-form.module.scss
@@ -0,0 +1,20 @@
+// 등록·수정 팝업의 입력 줄 — 꾸미기 아이템 팝업(ADM_ITM_102_p)과 같은 배치를 쓴다.
+// 기획 와이어프레임에는 구분선이 없지만 그건 시각 시안이 아니라서, 이 프로젝트에서 확정된
+// 팝업 폼 모양을 따른다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fields {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+}
+
+.field {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(3);
+  padding-block: fox.padding(6);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+}
 
app/(protected)/(basic)/system/codes/_components/code-group-modal.tsx (added)
+++ app/(protected)/(basic)/system/codes/_components/code-group-modal.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  CODE_ID_PREFIXES,
+  DEFAULT_CODE_ID_PREFIX,
+  applyCodeIdPrefix,
+  readCodeIdPrefix,
+  type CommonCodeGroup,
+} from '@/lib/domain/common-code';
+import { INITIAL_COMMON_CODE_FORM_STATE } from '@/lib/domain/common-code-form';
+import { createCodeGroupAction, updateCodeGroupAction } from '../_actions';
+import styles from './code-form.module.scss';
+
+interface CodeGroupModalProps {
+  /** 수정 대상. 없으면 등록 팝업이다. */
+  group?: CommonCodeGroup;
+  onClose: () => void;
+}
+
+/**
+ * 공통코드 등록/수정 팝업 — 기획 1842:16056 / 1842:16122.
+ *
+ * 두 팝업은 제목·버튼 글자와 **코드ID의 읽기 전용 여부**만 다르고 입력 항목이 같아 한 조각으로 둔다.
+ *
+ * **분류코드는 저장되지 않는다.** `TB_SYS_COM_CD`에 해당 컬럼이 없어(확인함) 이 셀렉트는 코드ID
+ * 접두사를 채워 주는 입력 보조로만 동작한다 — 시안 목록이 전부 `CMS004`처럼 접두사 3자 + 일련번호라
+ * 그 작명 규칙을 화면이 거드는 것이다(사용자 확정 사항). 저장되는 값은 코드ID 하나다.
+ *
+ * 저장 버튼은 foot 슬롯에 그려져 폼의 자손이 아니다 — ref로 직접 제출한다(다른 팝업과 같은 방식).
+ */
+export function CodeGroupModal({ group, onClose }: CodeGroupModalProps) {
+  const isEdit = group !== undefined;
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    isEdit ? updateCodeGroupAction : createCodeGroupAction,
+    INITIAL_COMMON_CODE_FORM_STATE
+  );
+
+  const [comCd, setComCd] = useState(group?.comCd ?? '');
+  const [prefix, setPrefix] = useState(
+    group ? readCodeIdPrefix(group.comCd) : DEFAULT_CODE_ID_PREFIX
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({
+        variant: 'success',
+        message: isEdit ? '공통코드를 수정했습니다.' : '공통코드를 등록했습니다.',
+      });
+      onClose();
+    }
+  }, [state, showToast, onClose, isEdit]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <FoxModal
+      open
+      size="sm"
+      title={isEdit ? '공통코드 수정' : '공통코드 등록'}
+      onClose={onClose}
+      actions={
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '저장 중...' : '저장'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </>
+      }
+    >
+      <form ref={formRef} action={formAction} className={styles.fields}>
+        {/* 수정 대상은 읽기 전용 칸이 아니라 이 값으로 정해진다 — 칸이 위조돼도 대상은 안 바뀐다. */}
+        {isEdit && (
+          <input type="hidden" name="targetComCd" value={group.comCd} />
+        )}
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name={isEdit ? undefined : 'comCd'}
+            label="코드ID"
+            requirement="required"
+            value={comCd}
+            onChange={setComCd}
+            readOnly={isEdit}
+            placeholder="코드ID를 입력하세요."
+            invalid={Boolean(errors.comCd)}
+            message={errors.comCd}
+            maxLength={50}
+          />
+        </div>
+
+        {!isEdit && (
+          <div className={styles.field}>
+            <FoxSelect
+              size="md"
+              label="분류코드"
+              options={CODE_ID_PREFIXES.map((value) => ({
+                value,
+                label: value,
+              }))}
+              value={prefix}
+              onValueChange={(next) => {
+                setPrefix(next);
+                setComCd((current) => applyCodeIdPrefix(current, next));
+              }}
+              hint="코드ID 앞에 붙는 분류입니다. 별도로 저장되지 않습니다."
+            />
+          </div>
+        )}
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="name"
+            label="코드ID명"
+            requirement="required"
+            defaultValue={group?.name ?? ''}
+            placeholder="코드ID명을 입력하세요."
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+            maxLength={100}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="description"
+            label="코드ID설명"
+            defaultValue={group?.description ?? ''}
+            placeholder="코드ID설명을 입력하세요."
+            invalid={Boolean(errors.description)}
+            message={errors.description}
+            maxLength={500}
+          />
+        </div>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/system/codes/_components/code-management.module.scss (added)
+++ app/(protected)/(basic)/system/codes/_components/code-management.module.scss
@@ -0,0 +1,45 @@
+// 코드관리 화면 배치 — 시안 5402:13399의 `row`(공통코드 756 + 40 + 상세코드 756 = 1552).
+// 두 목록이 나란히 서는 것은 이 화면만의 배치라 @fox에 넣지 않고 여기 둔다(값은 @fox 토큰).
+
+@use "@fox/styles/abstracts" as fox;
+
+.page {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+}
+
+// 좁은 화면에서는 두 목록이 위아래로 선다 — 나란히 두면 표가 눌려 읽을 수 없다.
+.columns {
+  display: grid;
+  inline-size: 100%;
+  gap: fox.gap(9);
+  grid-template-columns: 1fr;
+}
+
+@include fox.pc {
+  .columns {
+    grid-template-columns: 1fr 1fr;
+  }
+}
+
+.panel {
+  display: flex;
+  // 표가 넓어도 칸이 늘어나지 않게 한다 — 늘어나면 옆 칸을 밀어 두 목록의 폭이 어긋난다.
+  min-inline-size: 0;
+  flex-direction: column;
+}
+
+// 설명 열은 한 줄로 줄이고 넘치면 말줄임한다.
+//
+// 시안의 셀 컴포넌트에는 말줄임 스타일이 없지만(전문이 들어 있고 word-break만 걸려 있다),
+// 프레임 높이가 44로 고정이라 Figma가 넘치는 글자를 잘라 보여준다. CSS는 대신 감싸므로 그대로
+// 두면 행마다 높이가 달라진다 — 설명은 자유 입력이라 길이를 예측할 수 없다.
+// 잘린 글자는 title로 남겨 마우스를 올리면 전문이 보인다.
+.truncate {
+  display: block;
+  inline-size: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
 
app/(protected)/(basic)/system/codes/_components/code-management.tsx (added)
+++ app/(protected)/(basic)/system/codes/_components/code-management.tsx
@@ -0,0 +1,343 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxHeadingGroup } from '@fox/core/components/fox-heading-group';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxPageHeader } from '@fox/core/components/fox-page-header';
+import { FoxPlusIcon } from '@fox/core/icons';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  formatOptionalText,
+  type CommonCodeDetail,
+  type CommonCodeGroup,
+} from '@/lib/domain/common-code';
+import {
+  COMMON_CODE_PAGE_SIZE,
+  COMMON_CODE_SEARCH_FIELD_OPTIONS,
+  buildCommonCodeHref,
+  type CommonCodeQuery,
+  type CommonCodeSearchField,
+} from '@/lib/domain/common-code-query';
+import { deleteCodeDetailAction, deleteCodeGroupAction } from '../_actions';
+import { CodeDetailModal } from './code-detail-modal';
+import { CodeGroupModal } from './code-group-modal';
+import { CodeRowActions } from './code-row-actions';
+import styles from './code-management.module.scss';
+
+interface CodeManagementProps {
+  query: CommonCodeQuery;
+  groups: CommonCodeGroup[];
+  groupTotalCount: number;
+  groupPage: number;
+  groupTotalPages: number;
+  /** 화면이 실제로 보여주는 선택 — URL 값이 목록에 없으면 서버가 첫 행으로 바꿔 넘긴다. */
+  selectedComCd: string | null;
+  details: CommonCodeDetail[];
+  detailTotalCount: number;
+  detailPage: number;
+  detailTotalPages: number;
+  nextSortSeq: number;
+}
+
+/**
+ * 설명 칸 — 한 줄로 줄이고 넘치면 말줄임한다(스타일 주석 참조). 잘린 글자를 읽을 방법이
+ * 있어야 하므로 전문을 `title`로 남긴다.
+ */
+function Truncated({ text }: { text: string | null }) {
+  const value = formatOptionalText(text);
+  return (
+    <span className={styles.truncate} title={text ?? undefined}>
+      {value}
+    </span>
+  );
+}
+
+/** 어느 팝업이 열려 있는지. 한 번에 하나만 열린다. */
+type OpenModal =
+  | { kind: 'group-create' }
+  | { kind: 'group-edit'; group: CommonCodeGroup }
+  | { kind: 'detail-create' }
+  | { kind: 'detail-edit'; detail: CommonCodeDetail }
+  | null;
+
+/**
+ * 코드관리 — 기획 SYS_COD_001, 시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399
+ *
+ * 목록이 둘이지만 화면은 하나다. 좌측 공통코드를 **행 전체로 눌러** 고르면 우측 상세코드가 그
+ * 그룹으로 갱신된다(기획 ④) — 선택은 URL에 실리므로 갱신은 서버가 다시 그려 준다.
+ *
+ * 두 목록 모두 `FoxListContainer`를 쓰되 화면 제목은 위에서 한 번만 그린다(`FoxPageHeader`).
+ * 패널 이름은 `FoxHeadingGroup`이다 — 화면에 여럿 설 수 있는 구역 제목이라 `<h2>`로 나간다.
+ *
+ * 우측에는 검색이 없다(시안) — 상세코드는 선택된 그룹에 속한 것이 전부라 걸러 낼 대상이 아니다.
+ */
+export function CodeManagement({
+  query,
+  groups,
+  groupTotalCount,
+  groupPage,
+  groupTotalPages,
+  selectedComCd,
+  details,
+  detailTotalCount,
+  detailPage,
+  detailTotalPages,
+  nextSortSeq,
+}: CodeManagementProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [modal, setModal] = useState<OpenModal>(null);
+  const [isDeleting, startDeleting] = useTransition();
+
+  function go(patch: Partial<CommonCodeQuery>) {
+    router.replace(buildCommonCodeHref(query, patch));
+  }
+
+  /**
+   * 삭제는 되돌릴 수 없어 확인 얼럿을 거친다(기획 ④). 성공 뒤 화면 갱신은 Server Action의
+   * `revalidatePath`가 맡으므로 여기서는 알림만 띄운다.
+   */
+  function confirmDelete(
+    title: string,
+    message: string,
+    run: () => Promise<{ status: string; message?: string }>
+  ) {
+    showAlert({
+      variant: 'danger',
+      title,
+      message,
+      actions: (
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton
+            type="error"
+            size="md"
+            label="삭제"
+            onAction={() => {
+              hideAlert();
+              startDeleting(async () => {
+                const result = await run();
+                if (result.status === 'error') {
+                  showToast({
+                    variant: 'danger',
+                    message: result.message ?? '삭제하지 못했습니다.',
+                  });
+                  return;
+                }
+                showToast({ variant: 'success', message: '삭제했습니다.' });
+              });
+            }}
+          />
+        </>
+      ),
+    });
+  }
+
+  const groupColumns: FoxListColumn<CommonCodeGroup>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      render: (_row, index) => (groupPage - 1) * COMMON_CODE_PAGE_SIZE + index + 1,
+    },
+    { key: 'comCd', header: '코드ID', width: 120 },
+    { key: 'name', header: '코드명', width: 160, emphasis: true },
+    {
+      key: 'description',
+      header: '코드설명',
+      width: 156,
+      render: (row) => <Truncated text={row.description} />,
+    },
+    {
+      key: 'createdAt',
+      header: '생성일',
+      width: 120,
+      render: (row) => formatOptionalText(row.createdAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => (
+        <CodeRowActions
+          target={row.comCd}
+          deleting={isDeleting}
+          onEdit={() => setModal({ kind: 'group-edit', group: row })}
+          onDelete={() =>
+            confirmDelete(
+              '공통코드를 삭제하시겠습니까?',
+              `${row.name}(${row.comCd})을(를) 삭제합니다. 속한 상세코드도 함께 쓸 수 없게 됩니다.`,
+              () => deleteCodeGroupAction(row.comCd)
+            )
+          }
+        />
+      ),
+    },
+  ];
+
+  const detailColumns: FoxListColumn<CommonCodeDetail>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      // 기획의 상세코드 목록은 이 자리에 정렬번호를 보여준다(FSC01→1, FSC10→10).
+      render: (row) => row.sortSeq,
+    },
+    { key: 'comDtlCd', header: '상세코드ID', width: 120 },
+    { key: 'name', header: '상세코드명', width: 120, emphasis: true },
+    {
+      key: 'description',
+      header: '상세코드설명',
+      width: 196,
+      render: (row) => <Truncated text={row.description} />,
+    },
+    {
+      key: 'createdAt',
+      header: '생성일',
+      width: 120,
+      render: (row) => formatOptionalText(row.createdAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => (
+        <CodeRowActions
+          target={row.comDtlCd}
+          deleting={isDeleting}
+          onEdit={() => setModal({ kind: 'detail-edit', detail: row })}
+          onDelete={() =>
+            confirmDelete(
+              '상세코드를 삭제하시겠습니까?',
+              `${row.name}(${row.comDtlCd})을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+              () => deleteCodeDetailAction(row.comCd, row.comDtlCd)
+            )
+          }
+        />
+      ),
+    },
+  ];
+
+  return (
+    <div className={styles.page}>
+      <FoxPageHeader
+        title="코드관리"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '시스템관리' },
+          { label: '코드관리' },
+        ]}
+      />
+
+      <div className={styles.columns}>
+        <section className={styles.panel}>
+          <FoxHeadingGroup title="공통코드 목록" />
+          <FoxListContainer<CommonCodeGroup>
+            caption="공통코드 목록"
+            columns={groupColumns}
+            rows={groups}
+            rowKey={(row) => row.comCd}
+            totalCount={groupTotalCount}
+            emptyMessage="조회된 코드가 없습니다. 다른 검색어로 다시 시도해 주세요."
+            searchFields={COMMON_CODE_SEARCH_FIELD_OPTIONS.map((option) => ({
+              value: option.value,
+              label: option.label,
+            }))}
+            searchField={query.searchField}
+            keyword={query.keyword}
+            toolbarActions={
+              <FoxButton
+                type="primary"
+                size="md"
+                leadingIcon={<FoxPlusIcon />}
+                label="신규 등록"
+                onAction={() => setModal({ kind: 'group-create' })}
+              />
+            }
+            onRowSelect={(row) => go({ comCd: row.comCd })}
+            page={groupPage}
+            totalPages={groupTotalPages}
+            buildHref={(patch) =>
+              buildCommonCodeHref(query, { page: Number(patch.page ?? 1) })
+            }
+            onQueryChange={(patch) => {
+              if (patch.keyword !== undefined || patch.searchField !== undefined) {
+                go({
+                  keyword: String(patch.keyword ?? ''),
+                  searchField: (patch.searchField ??
+                    query.searchField) as CommonCodeSearchField,
+                  // 검색하면 목록 자체가 달라지므로 선택도 첫 행으로 다시 잡게 비운다.
+                  comCd: null,
+                  page: 1,
+                });
+              }
+            }}
+          />
+        </section>
+
+        <section className={styles.panel}>
+          <FoxHeadingGroup title="상세코드 목록" />
+          <FoxListContainer<CommonCodeDetail>
+            caption="상세코드 목록"
+            columns={detailColumns}
+            rows={details}
+            rowKey={(row) => `${row.comCd}:${row.comDtlCd}`}
+            totalCount={detailTotalCount}
+            emptyMessage={
+              selectedComCd
+                ? '등록된 상세코드가 없습니다.'
+                : '왼쪽에서 공통코드를 선택해 주세요.'
+            }
+            searchHidden
+            toolbarActions={
+              <FoxButton
+                type="primary"
+                size="md"
+                leadingIcon={<FoxPlusIcon />}
+                label="상세코드 등록"
+                // 소속될 공통코드가 없으면 등록할 수 없다.
+                disabled={!selectedComCd}
+                onAction={() => setModal({ kind: 'detail-create' })}
+              />
+            }
+            page={detailPage}
+            totalPages={detailTotalPages}
+            buildHref={(patch) =>
+              buildCommonCodeHref(query, {
+                comCd: selectedComCd,
+                detailPage: Number(patch.page ?? 1),
+              })
+            }
+          />
+        </section>
+      </div>
+
+      {modal?.kind === 'group-create' && (
+        <CodeGroupModal onClose={() => setModal(null)} />
+      )}
+      {modal?.kind === 'group-edit' && (
+        <CodeGroupModal group={modal.group} onClose={() => setModal(null)} />
+      )}
+      {modal?.kind === 'detail-create' && selectedComCd && (
+        <CodeDetailModal
+          comCd={selectedComCd}
+          nextSortSeq={nextSortSeq}
+          onClose={() => setModal(null)}
+        />
+      )}
+      {modal?.kind === 'detail-edit' && (
+        <CodeDetailModal
+          comCd={modal.detail.comCd}
+          detail={modal.detail}
+          nextSortSeq={nextSortSeq}
+          onClose={() => setModal(null)}
+        />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/system/codes/_components/code-row-actions.tsx (added)
+++ app/(protected)/(basic)/system/codes/_components/code-row-actions.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
+
+interface CodeRowActionsProps {
+  /** 읽어 줄 이름에 붙는 대상 — "CMS004 수정"처럼 읽힌다. */
+  target: string;
+  onEdit: () => void;
+  onDelete: () => void;
+  deleting?: boolean;
+}
+
+/**
+ * 두 목록이 함께 쓰는 "관리" 셀(시안 5402:13455) — 28×28 테두리 버튼 두 개.
+ *
+ * **누름이 위로 새지 않게 막는다** — 공통코드 행은 눌러서 고르는 행이라, 막지 않으면 수정·삭제를
+ * 누를 때 선택까지 함께 바뀐다. 상세코드 행은 고를 수 없지만 같은 조각을 쓰므로 규칙을 여기 둔다.
+ */
+export function CodeRowActions({
+  target,
+  onEdit,
+  onDelete,
+  deleting = false,
+}: CodeRowActionsProps) {
+  return (
+    <span
+      onClick={(event) => event.stopPropagation()}
+      onKeyDown={(event) => event.stopPropagation()}
+    >
+      <FoxButtonGroup size="sm">
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxPencilSimpleIcon />}
+          label={`${target} 수정`}
+          onAction={onEdit}
+        />
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxTrashIcon />}
+          label={deleting ? `${target} 삭제 중` : `${target} 삭제`}
+          disabled={deleting}
+          onAction={onDelete}
+        />
+      </FoxButtonGroup>
+    </span>
+  );
+}
 
app/(protected)/(basic)/system/codes/page.tsx (added)
+++ app/(protected)/(basic)/system/codes/page.tsx
@@ -0,0 +1,107 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  fetchCodeDetails,
+  fetchCodeGroups,
+} from '@/lib/data/repositories/common-code-repository';
+import type { CommonCodeGroup } from '@/lib/domain/common-code';
+import {
+  COMMON_CODE_DETAIL_PAGE_SIZE,
+  COMMON_CODE_PAGE_SIZE,
+  parseCommonCodeQuery,
+  type CommonCodeQuery,
+} from '@/lib/domain/common-code-query';
+import { CodeManagement } from './_components/code-management';
+
+export const metadata: Metadata = {
+  title: '코드관리',
+};
+
+// cookies()로 이미 동적이지만, 정적 프리렌더로 데이터가 빌드 산출물에 박히는 경로를
+// 원천 차단하기 위해 명시적으로 강제한다.
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+/** 부분일치 검색 — 백엔드가 완전일치만 지원해 여기서 거른다(Repository 주석 참조). */
+function filterGroups(
+  groups: CommonCodeGroup[],
+  query: CommonCodeQuery
+): CommonCodeGroup[] {
+  const keyword = query.keyword.trim().toLowerCase();
+  if (!keyword) {
+    return groups;
+  }
+
+  return groups.filter((group) => {
+    const target = query.searchField === 'comCd' ? group.comCd : group.name;
+    return target.toLowerCase().includes(keyword);
+  });
+}
+
+/** 1-based 페이지를 잘라낸다. 범위를 벗어난 페이지는 마지막 페이지로 맞춘다. */
+function paginate<T>(rows: T[], page: number, pageSize: number) {
+  const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
+  const currentPage = Math.min(Math.max(page, 1), totalPages);
+  const offset = (currentPage - 1) * pageSize;
+
+  return {
+    rows: rows.slice(offset, offset + pageSize),
+    currentPage,
+    totalPages,
+    totalCount: rows.length,
+  };
+}
+
+/**
+ * 코드관리 — 기획 SYS_COD_001, 시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399
+ *
+ * 좌측 공통코드를 고르면 우측 상세코드 목록이 그 그룹으로 갱신된다. 선택·검색·두 목록의 페이지가
+ * 모두 URL에 실려, 이 서버 컴포넌트가 매 요청 그 조건대로 데이터를 만들어 넘긴다.
+ *
+ * **검색·페이징을 서버에서 처리한다** — 백엔드가 두 목록 모두 전체를 반환하고 검색도 완전일치라
+ * (Repository 주석 참조), 부분일치 필터와 페이지 자르기를 여기서 한다. 전체를 받으므로 총건수는
+ * 정확하다(다른 목록 화면의 하한값 보정이 여기에는 필요 없다).
+ *
+ * **선택된 공통코드가 목록에 없으면 첫 행으로 대체한다** — 검색으로 걸러졌거나 삭제된 코드가
+ * URL에 남아 있으면 우측이 영영 비어 보이기 때문이다.
+ */
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseCommonCodeQuery(await searchParams);
+
+  const groups = filterGroups(await fetchCodeGroups(), query);
+  const groupPage = paginate(groups, query.page, COMMON_CODE_PAGE_SIZE);
+
+  const selectedComCd =
+    groups.find((group) => group.comCd === query.comCd)?.comCd ??
+    groupPage.rows[0]?.comCd ??
+    null;
+
+  const details = selectedComCd ? await fetchCodeDetails(selectedComCd) : [];
+  const detailPage = paginate(
+    details,
+    query.detailPage,
+    COMMON_CODE_DETAIL_PAGE_SIZE
+  );
+
+  return (
+    <CodeManagement
+      query={query}
+      groups={groupPage.rows}
+      groupTotalCount={groupPage.totalCount}
+      groupPage={groupPage.currentPage}
+      groupTotalPages={groupPage.totalPages}
+      selectedComCd={selectedComCd}
+      details={detailPage.rows}
+      detailTotalCount={detailPage.totalCount}
+      detailPage={detailPage.currentPage}
+      detailTotalPages={detailPage.totalPages}
+      // 새 상세코드의 기본 정렬번호 — 시안 등록 팝업이 기존 개수 다음 번호를 채워 둔다.
+      nextSortSeq={details.length + 1}
+    />
+  );
+}
lib/data/repositories/common-code-repository.ts
--- lib/data/repositories/common-code-repository.ts
+++ lib/data/repositories/common-code-repository.ts
@@ -2,37 +2,66 @@
 import { cache } from 'react';
 import { getSessionAccessToken } from '@/lib/auth/dal';
 import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
-import type { CommonCode } from '@/lib/domain/common-code';
+import type {
+  CommonCode,
+  CommonCodeDetail,
+  CommonCodeGroup,
+} from '@/lib/domain/common-code';
+import type {
+  CommonCodeDetailValues,
+  CommonCodeGroupValues,
+} from '@/lib/domain/common-code-form';
 
 /**
- * 공통코드 Repository.
+ * 공통코드 Repository — 그룹(`TB_SYS_COM_CD`)과 상세(`TB_SYS_COM_CD_DTL`) 두 층을 모두 맡는다.
  *
  * ```
- * GET /api/v1/mngr/code/list/{comCd}   그룹의 상세코드 목록 (ROLE_ADMIN)
+ * GET    /api/v1/mngr/code/list                  그룹 목록 (ROLE_ADMIN)
+ * GET    /api/v1/mngr/code/list/{comCd}          그룹의 상세코드 목록
+ * POST   /api/v1/mngr/code                       그룹 등록 — @RequestBody(JSON)
+ * PUT    /api/v1/mngr/code/{comCd}               그룹 수정 — @RequestBody(JSON)
+ * DELETE /api/v1/mngr/code/{comCd}               그룹 삭제 — soft delete(DEL_YN)
+ * POST   /api/v1/mngr/code/detail                상세 등록 — 어노테이션 없음 → form
+ * PUT    /api/v1/mngr/code/{comCd}/{comDtlCd}    상세 수정 — @RequestBody(JSON)
+ * DELETE /api/v1/mngr/code/{comCd}/{comDtlCd}    상세 삭제 — soft delete
  * ```
  *
  * 백엔드(edupay-backend, develop)의 MngrCodeApiController / MngrCodeMapper.xml을 읽고 확인한 것:
  *
- * - 응답은 `TB_SYS_COM_CD_DTL` 행 배열이고, 쓸 값은 **`comDtlCd`(코드)와 `cdNm`(이름)** 둘이다.
- * - 삭제된 코드(`DEL_YN='Y'`)는 SQL이 이미 걸러 준다.
- * - **정렬은 백엔드가 준 순서를 그대로 쓴다.** SQL이 `ROW_NUMBER() OVER (ORDER BY FRST_REG_DT
- *   DESC, SORT_SEQ)`를 다시 `ORDER BY RNUM DESC`로 뒤집어, 결과는 등록일 오름차순 + 정렬순서
- *   내림차순이다(아이템 목록과 같은 이중 역순 패턴). 화면에서 다시 정렬하면 다른 화면과 순서가
- *   어긋나므로 손대지 않는다.
+ * - **본문 형식이 엔드포인트마다 다르다.** 상세 등록만 `@ParameterObject`(form)이고 나머지 쓰기는
+ *   전부 `@RequestBody`(JSON)다 — 한쪽으로 통일해 보내면 반대쪽이 조용히 깨진다.
+ * - **두 목록 모두 페이징이 없다**(전체 반환). 그래서 총건수는 정확하고, 자르는 일은 호출부가 한다.
+ * - **그룹 검색은 완전일치다**(`com_cd = #{}` / `cd_nm = #{}`). 시안은 부분검색이라 검색 파라미터를
+ *   쓰지 않고 전체를 받아 호출부가 거른다(사용자 확정 사항).
+ * - **정렬은 고정이다** — 두 목록 다 `ROW_NUMBER() OVER (...)`를 `ORDER BY RNUM DESC`로 뒤집는
+ *   이중 역순이라 결과는 등록일 오름차순이다. 화면에서 다시 정렬하지 않는다.
+ * - **삭제는 soft delete**이고 조회가 `DEL_YN != 'Y'`로 거른다.
  *
- * `cache()`로 감싼 이유 — 한 요청 안에서 화면(선택지 그리기)과 Server Action(입력값 검증)이
- * 같은 그룹을 각각 부른다. 요청 단위로 기억해 두면 왕복이 한 번으로 줄고, 그리는 데 쓴 목록과
- * 검증에 쓴 목록이 반드시 같아진다.
+ * ⚠️ **백엔드 결함(보고함, 미수정)**
+ * 1. 상세 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지 않는다 → 상세코드설명이 늘 비어 온다.
+ * 2. 상세 등록 INSERT에도 `DTL_CD_EXPLN`이 없다 → 등록 시 입력한 설명이 저장되지 않는다.
+ *    (수정 UPDATE에는 있어 수정으로는 저장된다 — 다만 위 1 때문에 목록에서는 여전히 안 보인다.)
+ * 사용자 지시로 시안대로 화면을 두고 백엔드에 수정을 요청한다 — 고쳐지면 프론트 수정 없이 동작한다.
  *
- * 캐시: `no-store` — 코드는 자주 바뀌지 않지만 관리 화면에서 코드를 고치자마자 반영돼야 하고,
- * 위 `cache()`가 이미 요청 안의 중복 호출을 막는다.
+ * 캐시: 조회는 `no-store` — 관리 화면이라 신선도가 우선이다. `cache()`는 한 요청 안의 중복 호출만
+ * 막는다(화면이 그리기용으로, Server Action이 검증용으로 같은 목록을 부른다).
  */
-export const fetchCommonCodes = cache(async function fetchCommonCodes(
-  groupCode: string
-): Promise<CommonCode[]> {
+
+const CODE_PATH = '/api/v1/mngr/code';
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+async function requestList(path: string): Promise<unknown[]> {
   const accessToken = await getSessionAccessToken();
 
-  const result = await backendFetch<unknown>(`/api/v1/mngr/code/list/${groupCode}`, {
+  const result = await backendFetch<unknown>(path, {
     method: 'GET',
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
@@ -43,29 +72,176 @@
   }
 
   if (!Array.isArray(result.data)) {
-    throw new Error('공통코드 목록 응답의 형식이 올바르지 않습니다.');
+    throw new Error('공통코드 응답의 형식이 올바르지 않습니다.');
   }
 
-  return result.data.flatMap(toCommonCode);
+  return result.data;
+}
+
+/**
+ * 응답 1건 → 도메인 타입. 코드값이 없는 행은 **예외 대신 건너뛴다** — 코드 한 줄이 깨졌다고
+ * 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른 판단이다).
+ */
+function toGroup(raw: unknown): CommonCodeGroup[] {
+  if (!isRecord(raw)) {
+    return [];
+  }
+
+  const comCd = readString(raw, 'comCd');
+  if (!comCd) {
+    return [];
+  }
+
+  return [
+    {
+      comCd,
+      name: readString(raw, 'cdNm') ?? '',
+      description: readString(raw, 'cdExpln'),
+      createdAt: readString(raw, 'frstRegDtStr'),
+    },
+  ];
+}
+
+function toDetail(raw: unknown): CommonCodeDetail[] {
+  if (!isRecord(raw)) {
+    return [];
+  }
+
+  const comDtlCd = readString(raw, 'comDtlCd');
+  if (!comDtlCd) {
+    return [];
+  }
+
+  const sortSeq = raw.sortSeq;
+
+  return [
+    {
+      comCd: readString(raw, 'comCd') ?? '',
+      comDtlCd,
+      name: readString(raw, 'cdNm') ?? '',
+      // 지금은 백엔드가 내려 주지 않아 늘 null이다(파일 상단 결함 1).
+      description: readString(raw, 'dtlCdExpln'),
+      sortSeq: typeof sortSeq === 'number' ? sortSeq : 0,
+      createdAt: readString(raw, 'frstRegDtStr'),
+    },
+  ];
+}
+
+/** 공통코드(그룹) 전체. 검색·페이징은 호출부가 한다(파일 상단 주석 참조). */
+export const fetchCodeGroups = cache(async function fetchCodeGroups(): Promise<
+  CommonCodeGroup[]
+> {
+  const rows = await requestList(`${CODE_PATH}/list`);
+  return rows.flatMap(toGroup);
+});
+
+/** 한 그룹의 상세코드 전체. */
+export const fetchCodeDetails = cache(async function fetchCodeDetails(
+  comCd: string
+): Promise<CommonCodeDetail[]> {
+  const rows = await requestList(
+    `${CODE_PATH}/list/${encodeURIComponent(comCd)}`
+  );
+  return rows.flatMap(toDetail);
 });
 
 /**
- * 응답 1건 → 도메인 타입. 코드나 이름이 없는 행은 선택지로 쓸 수 없으므로 **예외 대신 건너뛴다**
- * — 코드 한 줄이 깨졌다고 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른
- * 판단이다. 그쪽은 없으면 화면의 존재 이유가 사라진다).
+ * 다른 화면이 선택지로 쓰는 최소 표현. 상세코드 조회를 그대로 쓰되 화면이 알 필요 없는 것을
+ * 덜어 낸다 — 같은 `cache()`를 타므로 한 요청 안에서 왕복이 늘지 않는다.
  */
-function toCommonCode(raw: unknown): CommonCode[] {
-  if (raw === null || typeof raw !== 'object') {
-    return [];
+export async function fetchCommonCodes(groupCode: string): Promise<CommonCode[]> {
+  const details = await fetchCodeDetails(groupCode);
+  return details.map((detail) => ({ code: detail.comDtlCd, label: detail.name }));
+}
+
+/*
+ * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
+ * 상세 등록만 form이고 나머지는 JSON이다(파일 상단 주석 참조).
+ * 성공 응답은 모두 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다.
+ */
+
+async function sendWrite(
+  path: string,
+  method: 'POST' | 'PUT' | 'DELETE',
+  payload?: { form: Record<string, string | number | undefined> } | { body: unknown }
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<null>(path, {
+    method,
+    ...payload,
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
   }
+}
 
-  const source = raw as Record<string, unknown>;
-  const code = source.comDtlCd;
-  const label = source.cdNm;
+export async function createCodeGroup(
+  values: CommonCodeGroupValues
+): Promise<void> {
+  await sendWrite(CODE_PATH, 'POST', {
+    body: { comCd: values.comCd, cdNm: values.name, cdExpln: values.description },
+  });
+}
 
-  if (typeof code !== 'string' || code.length === 0) {
-    return [];
-  }
+/** 코드ID는 경로로만 간다 — 수정 대상이 아니라 대상을 가리키는 값이다. */
+export async function updateCodeGroup(
+  comCd: string,
+  values: CommonCodeGroupValues
+): Promise<void> {
+  await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'PUT', {
+    body: { cdNm: values.name, cdExpln: values.description },
+  });
+}
 
-  return [{ code, label: typeof label === 'string' && label ? label : code }];
+export async function deleteCodeGroup(comCd: string): Promise<void> {
+  await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'DELETE');
+}
+
+/** 상세 등록만 form이다 — 컨트롤러가 `@RequestBody` 없이 받는다. */
+export async function createCodeDetail(
+  values: CommonCodeDetailValues
+): Promise<void> {
+  await sendWrite(`${CODE_PATH}/detail`, 'POST', {
+    form: {
+      comCd: values.comCd,
+      comDtlCd: values.comDtlCd,
+      cdNm: values.name,
+      // 백엔드 INSERT가 이 값을 쓰지 않는다(파일 상단 결함 2). 고쳐지면 그대로 저장된다.
+      dtlCdExpln: values.description,
+      sortSeq: values.sortSeq,
+    },
+  });
+}
+
+export async function updateCodeDetail(
+  comCd: string,
+  comDtlCd: string,
+  values: CommonCodeDetailValues
+): Promise<void> {
+  await sendWrite(
+    `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`,
+    'PUT',
+    {
+      body: {
+        cdNm: values.name,
+        dtlCdExpln: values.description,
+        sortSeq: values.sortSeq,
+      },
+    }
+  );
+}
+
+export async function deleteCodeDetail(
+  comCd: string,
+  comDtlCd: string
+): Promise<void> {
+  await sendWrite(
+    `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`,
+    'DELETE'
+  );
 }
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -70,6 +70,12 @@
             { id: 'faqs', label: 'FAQ', href: '/boards/faqs' },
           ],
         },
+        {
+          id: 'system',
+          label: '시스템관리',
+          icon: 'wrench',
+          children: [{ id: 'codes', label: '코드관리', href: '/system/codes' }],
+        },
       ],
     },
   ],
 
lib/domain/common-code-form.ts (added)
+++ lib/domain/common-code-form.ts
@@ -0,0 +1,191 @@
+/**
+ * 코드관리 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
+ *
+ * 시안 네 팝업(공통코드 등록/수정, 상세코드 등록/수정)이 이 파일의 규칙을 공유한다.
+ * **이 파일이 검증의 단일 진실원천이다** — Server Action이 저장 직전에 여기를 거친다.
+ *
+ * 시안의 필수(*) 표시는 상세코드 팝업에만 있지만(코드·코드값의미·정렬번호), 공통코드도
+ * 코드ID·코드명 없이는 저장할 수 없으므로 같은 강도로 검증한다 — 화면 표시와 무관하게
+ * 백엔드가 PK로 쓰는 값이다.
+ *
+ * `CommonCodeFormState`가 이 파일에 있는 이유는 Next.js가 `'use server'` 파일에서 함수가 아닌
+ * 값을 export하는 것을 런타임에 거부하기 때문이다(꾸미기 아이템 폼과 같은 사정).
+ */
+
+const CODE_MAX_LENGTH = 50;
+const NAME_MAX_LENGTH = 100;
+const DESCRIPTION_MAX_LENGTH = 500;
+
+/** 코드값은 영문 대문자·숫자·언더스코어만 받는다 — 코드테이블 키라 공백·한글이 섞이면 곤란하다. */
+const CODE_PATTERN = /^[A-Z0-9_]+$/;
+
+/** 공통코드(그룹) 등록·수정이 실제로 바꿀 수 있는 항목. */
+export type CommonCodeGroupValues = {
+  comCd: string;
+  name: string;
+  description: string;
+};
+
+/** 상세코드 등록·수정이 실제로 바꿀 수 있는 항목. */
+export type CommonCodeDetailValues = {
+  /** 소속 그룹. 화면에서는 읽기 전용이지만 저장 대상이라 값으로 다룬다. */
+  comCd: string;
+  comDtlCd: string;
+  /** 시안의 "코드값의미" — 백엔드 `cdNm`이다. */
+  name: string;
+  description: string;
+  sortSeq: number;
+};
+
+export type CommonCodeFormErrors = Partial<
+  Record<
+    keyof CommonCodeGroupValues | keyof CommonCodeDetailValues,
+    string
+  >
+>;
+
+export type ValidationResult<T> =
+  | { ok: true; values: T }
+  | { ok: false; errors: CommonCodeFormErrors };
+
+export type CommonCodeFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: CommonCodeFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_COMMON_CODE_FORM_STATE: CommonCodeFormState = {
+  status: 'idle',
+};
+
+/** 코드값 공통 검증 — 그룹의 `comCd`와 상세의 `comDtlCd`가 같은 규칙을 쓴다. */
+function validateCode(
+  raw: string,
+  label: string
+): { value: string; error?: string } {
+  // 코드는 대문자로 정규화한다 — 소문자로 저장되면 조회 조건과 어긋난다.
+  const value = raw.trim().toUpperCase();
+
+  if (!value) {
+    return { value, error: `${label}를 입력해 주세요.` };
+  }
+  if (value.length > CODE_MAX_LENGTH) {
+    return {
+      value,
+      error: `${label}는 ${CODE_MAX_LENGTH}자 이내로 입력해 주세요.`,
+    };
+  }
+  if (!CODE_PATTERN.test(value)) {
+    return { value, error: `${label}는 영문 대문자·숫자·_만 사용할 수 있습니다.` };
+  }
+  return { value };
+}
+
+function validateName(raw: string, label: string): { value: string; error?: string } {
+  const value = raw.trim();
+
+  if (!value) {
+    return { value, error: `${label}을 입력해 주세요.` };
+  }
+  if (value.length > NAME_MAX_LENGTH) {
+    return {
+      value,
+      error: `${label}은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`,
+    };
+  }
+  return { value };
+}
+
+function validateDescription(raw: string): { value: string; error?: string } {
+  const value = raw.trim();
+
+  if (value.length > DESCRIPTION_MAX_LENGTH) {
+    return {
+      value,
+      error: `설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`,
+    };
+  }
+  return { value };
+}
+
+/** 시안 공통코드 등록/수정 — 수정은 코드ID가 읽기 전용이라 값이 폼이 아니라 경로에서 온다. */
+export function validateCommonCodeGroup(
+  values: CommonCodeGroupValues
+): ValidationResult<CommonCodeGroupValues> {
+  const errors: CommonCodeFormErrors = {};
+
+  const code = validateCode(values.comCd, '코드ID');
+  if (code.error) {
+    errors.comCd = code.error;
+  }
+
+  const name = validateName(values.name, '코드ID명');
+  if (name.error) {
+    errors.name = name.error;
+  }
+
+  const description = validateDescription(values.description);
+  if (description.error) {
+    errors.description = description.error;
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: {
+      comCd: code.value,
+      name: name.value,
+      description: description.value,
+    },
+  };
+}
+
+/** 시안 상세코드 등록/수정. */
+export function validateCommonCodeDetail(
+  values: CommonCodeDetailValues
+): ValidationResult<CommonCodeDetailValues> {
+  const errors: CommonCodeFormErrors = {};
+
+  const group = validateCode(values.comCd, '코드ID');
+  if (group.error) {
+    // 상세코드 팝업의 코드ID는 좌측 선택에서 오는 읽기 전용 값이라, 여기가 비었다는 것은
+    // 공통코드를 고르지 않고 저장이 시도됐다는 뜻이다.
+    errors.comCd = '공통코드를 먼저 선택해 주세요.';
+  }
+
+  const code = validateCode(values.comDtlCd, '코드');
+  if (code.error) {
+    errors.comDtlCd = code.error;
+  }
+
+  const name = validateName(values.name, '코드값의미');
+  if (name.error) {
+    errors.name = name.error;
+  }
+
+  const description = validateDescription(values.description);
+  if (description.error) {
+    errors.description = description.error;
+  }
+
+  if (!Number.isInteger(values.sortSeq) || values.sortSeq < 1) {
+    errors.sortSeq = '정렬번호는 1 이상의 숫자로 입력해 주세요.';
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: {
+      comCd: group.value,
+      comDtlCd: code.value,
+      name: name.value,
+      description: description.value,
+      sortSeq: values.sortSeq,
+    },
+  };
+}
 
lib/domain/common-code-query.ts (added)
+++ lib/domain/common-code-query.ts
@@ -0,0 +1,137 @@
+/**
+ * 코드관리 화면(SYS_COD_001)의 URL 조건 — 순수 규칙만 담는다(next/react 의존 없음).
+ *
+ * 이 화면은 목록이 둘이라 상태가 셋이다. **선택된 공통코드(`comCd`)**, 공통코드 목록의 검색·
+ * 페이지, 상세코드 목록의 페이지. 셋 다 URL이 소유한다 — 다른 목록 화면과 같은 규칙이고,
+ * 새로고침·뒤로가기·링크 공유가 그대로 동작한다.
+ *
+ * **선택이 바뀌면 상세 페이지는 1로 돌아간다** — 다른 그룹의 3페이지는 의미가 없다.
+ * 그 규칙은 `buildCommonCodeHref`가 강제한다(호출부가 잊어도 어긋나지 않게).
+ *
+ * 페이징·검색을 URL에 두면서도 백엔드에는 넘기지 않는다 — 백엔드가 두 목록 모두 전체를
+ * 반환하고 검색도 완전일치라, 자르고 거르는 일은 서버 컴포넌트가 한다(Repository 주석 참조).
+ */
+
+/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
+export const COMMON_CODES_PATH = '/system/codes';
+
+/** 검색 대상 — 시안(SYS_COD_001 ①) "코드명 / 코드ID". */
+export type CommonCodeSearchField = 'name' | 'comCd';
+
+export const COMMON_CODE_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
+  value: CommonCodeSearchField;
+  label: string;
+}> = [
+  { value: 'name', label: '코드명' },
+  { value: 'comCd', label: '코드ID' },
+];
+
+export const DEFAULT_COMMON_CODE_SEARCH_FIELD: CommonCodeSearchField = 'name';
+
+/** 시안의 두 목록은 한 화면에 나란히 서므로 페이지 크기를 고르는 자리가 없다 — 고정값이다. */
+export const COMMON_CODE_PAGE_SIZE = 10;
+export const COMMON_CODE_DETAIL_PAGE_SIZE = 10;
+
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type CommonCodeQuery = {
+  /** 선택된 공통코드. 아직 고르지 않았으면 null이고, 화면이 첫 행으로 채운다. */
+  comCd: string | null;
+  searchField: CommonCodeSearchField;
+  keyword: string;
+  /** 공통코드 목록의 페이지. */
+  page: number;
+  /** 상세코드 목록의 페이지. */
+  detailPage: number;
+};
+
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string | undefined {
+  const value = params[key];
+  return Array.isArray(value) ? value[0] : value;
+}
+
+function readPage(params: RawSearchParams, key: string): number {
+  const value = Number(readParam(params, key));
+  return Number.isInteger(value) && value > 0 ? value : DEFAULT_PAGE;
+}
+
+function isSearchField(
+  value: string | undefined
+): value is CommonCodeSearchField {
+  return (
+    value !== undefined &&
+    COMMON_CODE_SEARCH_FIELD_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+/**
+ * URL의 searchParams를 검증된 `CommonCodeQuery`로 정규화한다. searchParams는 사용자가 임의로
+ * 조작할 수 있는 값이라 신뢰하지 않는다 — 허용 목록을 벗어나면 기본값으로 떨어진다.
+ *
+ * `comCd`만은 허용 목록을 여기서 확인할 수 없다(코드 목록이 서버에 있다) — 존재 여부는 화면이
+ * 조회 결과와 맞춰 보고 없으면 첫 행으로 대체한다.
+ */
+export function parseCommonCodeQuery(
+  searchParams: RawSearchParams
+): CommonCodeQuery {
+  const searchFieldRaw = readParam(searchParams, 'searchField');
+  const comCd = readParam(searchParams, 'comCd')?.trim();
+
+  return {
+    comCd: comCd ? comCd.slice(0, 50) : null,
+    searchField: isSearchField(searchFieldRaw)
+      ? searchFieldRaw
+      : DEFAULT_COMMON_CODE_SEARCH_FIELD,
+    keyword: (readParam(searchParams, 'keyword') ?? '')
+      .trim()
+      .slice(0, MAX_KEYWORD_LENGTH),
+    page: readPage(searchParams, 'page'),
+    detailPage: readPage(searchParams, 'detailPage'),
+  };
+}
+
+/**
+ * `CommonCodeQuery`(+ 부분 override)를 링크로 직렬화한다. `parseCommonCodeQuery`의 역연산이며
+ * 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다.
+ *
+ * **공통코드 선택이 바뀌면 상세 페이지를 1로 되돌린다** — 호출부가 잊어도 어긋나지 않도록
+ * 여기서 강제한다(override로 detailPage를 함께 준 경우는 그 값을 존중한다).
+ */
+export function buildCommonCodeHref(
+  query: CommonCodeQuery,
+  overrides: Partial<CommonCodeQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+
+  if (
+    overrides.comCd !== undefined &&
+    overrides.comCd !== query.comCd &&
+    overrides.detailPage === undefined
+  ) {
+    merged.detailPage = DEFAULT_PAGE;
+  }
+
+  const params = new URLSearchParams();
+
+  if (merged.comCd) {
+    params.set('comCd', merged.comCd);
+  }
+  if (merged.searchField !== DEFAULT_COMMON_CODE_SEARCH_FIELD) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.page !== DEFAULT_PAGE) {
+    params.set('page', String(merged.page));
+  }
+  if (merged.detailPage !== DEFAULT_PAGE) {
+    params.set('detailPage', String(merged.detailPage));
+  }
+
+  const queryString = params.toString();
+  return queryString ? `${COMMON_CODES_PATH}?${queryString}` : COMMON_CODES_PATH;
+}
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -1,10 +1,15 @@
 /**
  * 공통코드 도메인 — 순수 데이터 표현, 외부 의존 없음.
  *
- * 백엔드 `TB_SYS_COM_CD_DTL`의 한 그룹(`COM_CD`)에 속한 상세코드 목록이며,
- * `GET /api/v1/mngr/code/list/{comCd}`(ROLE_ADMIN)로 가져온다.
+ * 백엔드 `TB_SYS_COM_CD`(그룹)와 `TB_SYS_COM_CD_DTL`(상세)의 두 층이며, 둘 다
+ * `/api/v1/mngr/code/**`(ROLE_ADMIN)로 읽고 쓴다.
+ *
+ * 이 파일은 두 종류의 소비자를 함께 섬긴다.
+ *   - 코드관리 화면(SYS_COD_001): 그룹·상세를 편집한다 → `CommonCodeGroup`·`CommonCodeDetail`
+ *   - 다른 화면의 선택지: 상세코드를 `{code,label}`로만 쓴다 → `CommonCode`
  */
 
+/** 선택지로 쓸 때의 최소 표현. 화면이 코드 편집에 관심이 없을 때 쓴다. */
 export type CommonCode = {
   /** 백엔드 `comDtlCd` — 저장·전송에 쓰는 코드값. */
   code: string;
@@ -18,6 +23,84 @@
   decorationItemCategory: 'ITEM_CATE_CD',
 } as const;
 
+/** 값이 없는 항목의 화면 표기. */
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+/**
+ * 공통코드(그룹) — 시안의 "공통코드 목록" 한 줄.
+ *
+ * `TB_SYS_COM_CD`의 컬럼은 이 셋이 전부다(+감사 컬럼). 시안 등록 팝업의 "분류코드"에
+ * 해당하는 컬럼은 **없다** — 그 셀렉트는 코드ID 접두사를 채워 주는 입력 보조일 뿐이고
+ * 저장되는 값은 `comCd` 하나다(사용자 확정 사항).
+ */
+export type CommonCodeGroup = {
+  /** 백엔드 `comCd` — PK이자 화면의 "코드ID". 상세코드를 묶는 키다. */
+  comCd: string;
+  /** 백엔드 `cdNm` — 화면의 "코드명". */
+  name: string;
+  /** 백엔드 `cdExpln` — 화면의 "코드설명". */
+  description: string | null;
+  /** 백엔드 `frstRegDtStr` — `YYYY-MM-DD`. */
+  createdAt: string | null;
+};
+
+/**
+ * 상세코드 — 시안의 "상세코드 목록" 한 줄.
+ *
+ * ⚠️ `description`은 지금 **항상 null이다** — 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지
+ * 않는다. 등록 INSERT에서도 빠져 있어 수정으로만 저장된다(Repository 주석 참조).
+ */
+export type CommonCodeDetail = {
+  /** 백엔드 `comCd` — 이 상세코드가 속한 그룹. */
+  comCd: string;
+  /** 백엔드 `comDtlCd` — 그룹 안에서의 코드값. 화면의 "상세코드ID". */
+  comDtlCd: string;
+  /** 백엔드 `cdNm` — 화면의 "상세코드명"이자 등록 팝업의 "코드값의미". */
+  name: string;
+  /** 백엔드 `dtlCdExpln`. */
+  description: string | null;
+  /** 백엔드 `sortSeq` — 등록 팝업의 "정렬번호"이자 목록의 "번호". */
+  sortSeq: number;
+  /** 백엔드 `frstRegDtStr` — `YYYY-MM-DD`. */
+  createdAt: string | null;
+};
+
+/**
+ * 코드ID 접두사(시안의 "분류코드") — 저장되는 값이 아니라 코드ID를 지을 때의 작명 규칙이다.
+ * 시안 목록이 전부 `CMS004`처럼 접두사 3자 + 일련번호라 그 규칙을 화면이 거들게 한다.
+ *
+ * 백엔드에 분류 컬럼이 생기면 이 상수 대신 그 코드 목록을 쓰면 된다.
+ */
+export const CODE_ID_PREFIXES: readonly string[] = ['CMS', 'SYS', 'FSC', 'CST'];
+
+export const DEFAULT_CODE_ID_PREFIX = CODE_ID_PREFIXES[0];
+
+/**
+ * 코드ID에서 접두사를 읽는다 — 앞 3자가 아는 접두사면 그것을, 아니면 기본값을 돌려준다.
+ * 수정 팝업이 기존 코드ID로 셀렉트의 초기값을 정할 때 쓴다.
+ */
+export function readCodeIdPrefix(comCd: string): string {
+  const head = comCd.slice(0, 3).toUpperCase();
+  return CODE_ID_PREFIXES.includes(head) ? head : DEFAULT_CODE_ID_PREFIX;
+}
+
+/**
+ * 접두사를 바꿔 끼운 코드ID를 만든다. 기존 값이 아는 접두사로 시작하면 그 자리를 갈아 끼우고,
+ * 아니면 앞에 덧붙인다 — 사용자가 이미 적어 둔 일련번호를 지우지 않기 위해서다.
+ */
+export function applyCodeIdPrefix(comCd: string, prefix: string): string {
+  const rest = CODE_ID_PREFIXES.includes(comCd.slice(0, 3).toUpperCase())
+    ? comCd.slice(3)
+    : comCd;
+  return `${prefix}${rest}`;
+}
+
+export function formatOptionalText(value: string | null | undefined): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : value;
+}
+
 /** 코드값 → 이름. 목록에 없는 코드는 코드값 자체를 보여준다(이름을 지어내지 않는다). */
 export function formatCommonCode(
   codes: readonly CommonCode[],
Add a comment
List