임동욱 임동욱 08-20
feat: 포인트관리 2화면 — 기준관리·누적포인트 (mock)
기획 PTS10100001 목록 / PTS10100002_p 수정 / PTS10200001 목록 /
PTS10300001 상세내역. 디자인 시안이 없어 기존 화면들과 같은 뼈대로 짰다 —
FoxListContainer + searchParams SSOT + Server Action.

백엔드 API가 없어 Repository 두 개가 mock이다. 기준관리는 모듈 메모리를 고쳐
수정·삭제가 실제로 반영되고(서버 재시작 시 초기화), 누적포인트는 인덱스로만
3,290건을 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다.

기획을 그대로 따른 것: 기준관리에 신규등록이 없다(코드관리와 연동되는 항목),
이벤트는 읽기 전용, 제한포인트 0 = 제한 없음, 상세내역 취득은 파랑 +·사용은 빨강 -.

FoxSelect에는 FoxSelectText와 달리 ariaLabel이 없어 필터 셀렉트의 접근성 이름을
옵션 라벨("학년 전체", "3반")이 대신한다.

Co-Authored-By: Claude Opus 5 
@786eab7f331fa983c4d24b594f0e57d724bd2b12
app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
--- app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
+++ app/(protected)/(basic)/_components/sidebar-menu-icon.tsx
@@ -1,6 +1,7 @@
 import type { ComponentType } from 'react';
 import {
   FoxCalendarStarIcon,
+  FoxCoinsIcon,
   FoxChartBarIcon,
   FoxHouseIcon,
   FoxIdentificationCardIcon,
@@ -35,6 +36,7 @@
   medal: FoxMedalIcon,
   'identification-card': FoxIdentificationCardIcon,
   'calendar-star': FoxCalendarStarIcon,
+  coins: FoxCoinsIcon,
   nut: FoxNutIcon,
   'shield-check': FoxShieldCheckIcon,
   'phone-call': FoxPhoneCallIcon,
 
app/(protected)/(basic)/points/accumulated/_actions.ts (added)
+++ app/(protected)/(basic)/points/accumulated/_actions.ts
@@ -0,0 +1,25 @@
+'use server';
+
+import { verifySession } from '@/lib/auth/dal';
+import { fetchPointHistory } from '@/lib/data/repositories/point-account-repository';
+import type { PointHistoryEntry } from '@/lib/domain/point-account';
+import { POINT_HISTORY_PAGE_SIZE } from '@/lib/domain/point-account-query';
+
+/** 상세내역은 목록 행에 없는 데이터라 팝업이 열릴 때 따로 가져온다(기획 PTS10300001). */
+export async function fetchPointHistoryAction(
+  accountId: string,
+  page: number
+): Promise<{ items: PointHistoryEntry[]; totalPages: number }> {
+  await verifySession();
+
+  const { items, totalCount } = await fetchPointHistory(
+    accountId,
+    page,
+    POINT_HISTORY_PAGE_SIZE
+  );
+
+  return {
+    items,
+    totalPages: Math.max(1, Math.ceil(totalCount / POINT_HISTORY_PAGE_SIZE)),
+  };
+}
 
app/(protected)/(basic)/points/accumulated/_components/point-account-filter.module.scss (added)
+++ app/(protected)/(basic)/points/accumulated/_components/point-account-filter.module.scss
@@ -0,0 +1,16 @@
+@use "@fox/styles/abstracts" as fox;
+
+.filter {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: fox.gap(3);
+}
+
+.school {
+  inline-size: 24rem;
+}
+
+.select {
+  inline-size: 12rem;
+}
 
app/(protected)/(basic)/points/accumulated/_components/point-account-list.tsx (added)
+++ app/(protected)/(basic)/points/accumulated/_components/point-account-list.tsx
@@ -0,0 +1,255 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxInput } from '@fox/core/components/fox-input';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { FoxArrowUpRightIcon } from '@fox/core/icons';
+import {
+  formatOptionalValue,
+  formatPoint,
+  type PointAccount,
+} from '@/lib/domain/point-account';
+import {
+  CLASS_OPTIONS,
+  FILTER_ALL,
+  GRADE_OPTIONS,
+  NUMBER_OPTIONS,
+  POINT_ACCOUNTS_PATH,
+  POINT_ACCOUNT_PAGE_SIZE_OPTIONS,
+  POINT_ACCOUNT_SEARCH_FIELD_OPTIONS,
+  buildPointAccountHref,
+  type PointAccountPageSize,
+  type PointAccountQuery,
+  type PointAccountSearchField,
+} from '@/lib/domain/point-account-query';
+import { PointDetailModal } from './point-detail-modal';
+import styles from './point-account-filter.module.scss';
+
+interface PointAccountListProps {
+  items: PointAccount[];
+  query: PointAccountQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+function numberOptions(values: readonly number[], suffix: string) {
+  return [
+    { value: FILTER_ALL, label: `${suffix} 전체` },
+    ...values.map((value) => ({
+      value: String(value),
+      label: `${value}${suffix}`,
+    })),
+  ];
+}
+
+export function PointAccountList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: PointAccountListProps) {
+  const router = useRouter();
+  const [detailAccount, setDetailAccount] = useState<PointAccount | null>(null);
+  const [schoolNameDraft, setSchoolNameDraft] = useState(query.schoolName);
+
+  function go(patch: Partial<PointAccountQuery>) {
+    router.replace(buildPointAccountHref(query, { ...patch, page: 1 }));
+  }
+
+  function selectedNumber(value: number | null) {
+    return value === null ? FILTER_ALL : String(value);
+  }
+
+  function parseSelected(value: string): number | null {
+    return value === FILTER_ALL ? null : Number(value);
+  }
+
+  const columns: FoxListColumn<PointAccount>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    { key: 'loginId', header: '아이디', width: 140 },
+    { key: 'studentName', header: '학생명', width: 120, emphasis: true },
+    { key: 'schoolName', header: '학교명', width: 160 },
+    { key: 'grade', header: '학년', width: 80, align: 'center' },
+    { key: 'classNo', header: '반', width: 80, align: 'center' },
+    { key: 'studentNo', header: '번호', width: 80, align: 'center' },
+    {
+      key: 'totalPoint',
+      header: '총 포인트',
+      width: 120,
+      align: 'end',
+      render: (row) => formatPoint(row.totalPoint),
+    },
+    {
+      key: 'deductedPoint',
+      header: '차감 포인트',
+      width: 120,
+      align: 'end',
+      render: (row) => formatPoint(row.deductedPoint),
+    },
+    {
+      key: 'usedPoint',
+      header: '사용 포인트',
+      width: 120,
+      align: 'end',
+      render: (row) => formatPoint(row.usedPoint),
+    },
+    {
+      key: 'currentPoint',
+      header: '현재 포인트',
+      width: 120,
+      align: 'end',
+      render: (row) => formatPoint(row.currentPoint),
+    },
+    {
+      key: 'updatedAt',
+      header: '수정일시',
+      width: 160,
+      render: (row) => formatOptionalValue(row.updatedAt),
+    },
+    { key: 'siteName', header: '구분(사이트)', width: 160 },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 100,
+      render: (row) => (
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxArrowUpRightIcon />}
+          label={`${row.studentName} 포인트 상세내역`}
+          onAction={() => setDetailAccount(row)}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<PointAccount>
+        title="누적포인트 관리 목록"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '포인트관리' },
+          { label: '누적포인트 관리' },
+        ]}
+        caption="누적포인트 관리 목록"
+        columns={columns}
+        rows={items}
+        rowKey={(row) => row.id}
+        totalCount={totalCount}
+        emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
+        sorts={
+          <FoxSelectText
+            size="sm"
+            ariaLabel="페이지 크기"
+            options={POINT_ACCOUNT_PAGE_SIZE_OPTIONS.map((size) => ({
+              value: String(size),
+              label: `${size}개씩 보기`,
+            }))}
+            value={String(query.pageSize)}
+            onValueChange={(value) =>
+              go({ pageSize: Number(value) as PointAccountPageSize })
+            }
+          />
+        }
+        filter={
+          <div className={styles.filter}>
+            <div className={styles.school}>
+              <FoxInput
+                size="md"
+                aria-label="학교명"
+                placeholder="학교를 입력하세요."
+                value={schoolNameDraft}
+                onChange={setSchoolNameDraft}
+                onKeyDown={(event) => {
+                  if (event.key === 'Enter') {
+                    go({ schoolName: schoolNameDraft });
+                  }
+                }}
+              />
+            </div>
+            <div className={styles.select}>
+              <FoxSelect
+                size="md"
+                options={numberOptions(GRADE_OPTIONS, '학년')}
+                value={selectedNumber(query.grade)}
+                onValueChange={(value) => go({ grade: parseSelected(value) })}
+              />
+            </div>
+            <div className={styles.select}>
+              <FoxSelect
+                size="md"
+                options={numberOptions(CLASS_OPTIONS, '반')}
+                value={selectedNumber(query.classNo)}
+                onValueChange={(value) => go({ classNo: parseSelected(value) })}
+              />
+            </div>
+            <div className={styles.select}>
+              <FoxSelect
+                size="md"
+                options={numberOptions(NUMBER_OPTIONS, '번')}
+                value={selectedNumber(query.studentNo)}
+                onValueChange={(value) => go({ studentNo: parseSelected(value) })}
+              />
+            </div>
+          </div>
+        }
+        searchFields={POINT_ACCOUNT_SEARCH_FIELD_OPTIONS.map((option) => ({
+          value: option.value,
+          label: option.label,
+        }))}
+        searchField={query.searchField}
+        keyword={query.keyword}
+        searchPlaceholder="검색어를 입력하세요."
+        toolbarActions={
+          <FoxButton
+            type="default"
+            size="md"
+            label="초기화"
+            onAction={() => {
+              setSchoolNameDraft('');
+              router.replace(POINT_ACCOUNTS_PATH);
+            }}
+          />
+        }
+        page={currentPage}
+        totalPages={totalPages}
+        buildHref={(patch) =>
+          buildPointAccountHref(query, { page: Number(patch.page ?? 1) })
+        }
+        onQueryChange={(patch) => {
+          if (patch.keyword !== undefined || patch.searchField !== undefined) {
+            go({
+              schoolName: schoolNameDraft,
+              keyword: String(patch.keyword ?? ''),
+              searchField: (patch.searchField ??
+                query.searchField) as PointAccountSearchField,
+            });
+          }
+        }}
+      />
+
+      {detailAccount && (
+        <PointDetailModal
+          account={detailAccount}
+          onClose={() => setDetailAccount(null)}
+        />
+      )}
+    </>
+  );
+}
 
app/(protected)/(basic)/points/accumulated/_components/point-detail-modal.module.scss (added)
+++ app/(protected)/(basic)/points/accumulated/_components/point-detail-modal.module.scss
@@ -0,0 +1,20 @@
+@use "@fox/styles/abstracts" as fox;
+
+.body {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(6);
+}
+
+.pagination {
+  display: flex;
+  justify-content: center;
+  padding-block-start: fox.spacing(top-sm);
+}
+
+.empty {
+  padding-block: fox.spacing(top-md);
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-sm);
+  text-align: center;
+}
 
app/(protected)/(basic)/points/accumulated/_components/point-detail-modal.tsx (added)
+++ app/(protected)/(basic)/points/accumulated/_components/point-detail-modal.tsx
@@ -0,0 +1,139 @@
+'use client';
+
+import { useEffect, useState, useTransition } from 'react';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxPagination } from '@fox/core/components/fox-pagination';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import { FoxTable } from '@fox/core/components/fox-table';
+import { FoxTableCell } from '@fox/core/components/fox-table-cell';
+import { FoxTableRow } from '@fox/core/components/fox-table-row';
+import {
+  formatOptionalValue,
+  formatPoint,
+  formatPointDelta,
+  isPointEarned,
+  type PointAccount,
+  type PointHistoryEntry,
+} from '@/lib/domain/point-account';
+import { fetchPointHistoryAction } from '../_actions';
+import styles from './point-detail-modal.module.scss';
+
+interface PointDetailModalProps {
+  account: PointAccount;
+  onClose: () => void;
+}
+
+const MEMBER_COLUMNS = [
+  '회원번호',
+  '아이디',
+  '학생명',
+  '학교명',
+  '학년',
+  '반',
+  '번호',
+  '총 포인트',
+  '차감 포인트',
+  '사용 포인트',
+  '현재 포인트',
+];
+
+export function PointDetailModal({ account, onClose }: PointDetailModalProps) {
+  const [page, setPage] = useState(1);
+  const [entries, setEntries] = useState<PointHistoryEntry[]>([]);
+  const [totalPages, setTotalPages] = useState(1);
+  const [isLoading, startLoading] = useTransition();
+
+  useEffect(() => {
+    startLoading(async () => {
+      const result = await fetchPointHistoryAction(account.id, page);
+      setEntries(result.items);
+      setTotalPages(result.totalPages);
+    });
+  }, [account.id, page]);
+
+  const memberValues = [
+    account.memberNo,
+    account.loginId,
+    account.studentName,
+    account.schoolName,
+    account.grade,
+    account.classNo,
+    account.studentNo,
+    formatPoint(account.totalPoint),
+    formatPoint(account.deductedPoint),
+    formatPoint(account.usedPoint),
+    formatPoint(account.currentPoint),
+  ];
+
+  return (
+    <FoxModal open size="lg" title="포인트 상세내역" onClose={onClose}>
+      <div className={styles.body}>
+        <FoxTable type="list" caption="회원정보">
+          <FoxTableRow head>
+            {MEMBER_COLUMNS.map((column) => (
+              <FoxTableCell key={column} head>
+                {column}
+              </FoxTableCell>
+            ))}
+          </FoxTableRow>
+          <FoxTableRow>
+            {memberValues.map((value, index) => (
+              <FoxTableCell key={MEMBER_COLUMNS[index]} label={MEMBER_COLUMNS[index]}>
+                {formatOptionalValue(value)}
+              </FoxTableCell>
+            ))}
+          </FoxTableRow>
+        </FoxTable>
+
+        <FoxTable type="list" caption="포인트 내역">
+          <FoxTableRow head>
+            <FoxTableCell head width={320}>
+              항목
+            </FoxTableCell>
+            <FoxTableCell head width={140}>
+              포인트
+            </FoxTableCell>
+            <FoxTableCell head width={160}>
+              구분(사이트)
+            </FoxTableCell>
+            <FoxTableCell head width={140}>
+              날짜
+            </FoxTableCell>
+          </FoxTableRow>
+
+          {entries.length === 0 ? (
+            <FoxTableRow>
+              <FoxTableCell colSpan={4} align="center">
+                {isLoading ? '불러오는 중…' : '포인트 내역이 없습니다.'}
+              </FoxTableCell>
+            </FoxTableRow>
+          ) : (
+            entries.map((entry) => (
+              <FoxTableRow key={entry.id}>
+                <FoxTableCell label="항목">{entry.title}</FoxTableCell>
+                <FoxTableCell label="포인트">
+                  {/* 기획 ② 취득은 파랑 +, 사용은 빨강 −. */}
+                  <FoxStatusIndicator
+                    type={isPointEarned(entry.point) ? 'information' : 'danger'}
+                    label={formatPointDelta(entry.point)}
+                  />
+                </FoxTableCell>
+                <FoxTableCell label="구분(사이트)">{entry.siteName}</FoxTableCell>
+                <FoxTableCell label="날짜">{entry.date}</FoxTableCell>
+              </FoxTableRow>
+            ))
+          )}
+        </FoxTable>
+
+        <div className={styles.pagination}>
+          <FoxPagination
+            currentPage={page}
+            totalPages={totalPages}
+            onPageChange={setPage}
+            label="포인트 내역 페이지"
+          />
+        </div>
+      </div>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/points/accumulated/page.tsx (added)
+++ app/(protected)/(basic)/points/accumulated/page.tsx
@@ -0,0 +1,30 @@
+import { verifySession } from '@/lib/auth/dal';
+import { fetchPointAccounts } from '@/lib/data/repositories/point-account-repository';
+import { parsePointAccountQuery } from '@/lib/domain/point-account-query';
+import { PointAccountList } from './_components/point-account-list';
+
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parsePointAccountQuery(await searchParams);
+  const { items, totalCount } = await fetchPointAccounts(query);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <PointAccountList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
app/(protected)/(basic)/points/standards/_actions.ts (added)
+++ app/(protected)/(basic)/points/standards/_actions.ts
@@ -0,0 +1,70 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  deletePointStandard,
+  updatePointStandard,
+} from '@/lib/data/repositories/point-standard-repository';
+import {
+  validatePointStandard,
+  type PointStandardFormState,
+} from '@/lib/domain/point-standard-form';
+import { POINT_STANDARDS_PATH } from '@/lib/domain/point-standard-query';
+
+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 : '';
+}
+
+function parseFormNumber(formData: FormData, key: string): number {
+  const trimmed = readString(formData, key).trim();
+  return trimmed === '' ? NaN : Number(trimmed);
+}
+
+/** 기획 PTS10100002_p — 이벤트는 읽기 전용이라 대상 id는 hidden 필드에서 읽는다. */
+export async function updatePointStandardAction(
+  _prevState: PointStandardFormState,
+  formData: FormData
+): Promise<PointStandardFormState> {
+  await verifySession();
+
+  const validation = validatePointStandard({
+    id: readString(formData, 'id'),
+    point: parseFormNumber(formData, 'point'),
+    limitPoint: parseFormNumber(formData, 'limitPoint'),
+    isDuplicable: readString(formData, 'isDuplicable') === 'Y',
+    description: readString(formData, 'description'),
+    isActive: readString(formData, 'isActive') === 'Y',
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await updatePointStandard(validation.values);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(POINT_STANDARDS_PATH);
+  return { status: 'success' };
+}
+
+export async function deletePointStandardAction(
+  id: string
+): Promise<{ status: 'success' } | { status: 'error'; message: string }> {
+  await verifySession();
+
+  try {
+    await deletePointStandard(id);
+  } catch {
+    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(POINT_STANDARDS_PATH);
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/points/standards/_components/point-standard-form-modal.tsx (added)
+++ app/(protected)/(basic)/points/standards/_components/point-standard-form-modal.tsx
@@ -0,0 +1,195 @@
+'use client';
+
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+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 { FoxRadio } from '@fox/core/components/fox-radio';
+import { FoxRadioGroup } from '@fox/core/components/fox-radio-group';
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  DESCRIPTION_MAX_LENGTH,
+  INITIAL_POINT_STANDARD_FORM_STATE,
+} from '@/lib/domain/point-standard-form';
+import type { PointStandard } from '@/lib/domain/point-standard';
+import { updatePointStandardAction } from '../_actions';
+import styles from './point-standard-form.module.scss';
+
+interface PointStandardFormModalProps {
+  standard: PointStandard;
+  onClose: () => void;
+}
+
+export function PointStandardFormModal({
+  standard,
+  onClose,
+}: PointStandardFormModalProps) {
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    updatePointStandardAction,
+    INITIAL_POINT_STANDARD_FORM_STATE
+  );
+
+  const [isDuplicable, setIsDuplicable] = useState(standard.isDuplicable);
+  const [isActive, setIsActive] = useState(standard.isActive);
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({ variant: 'success', message: '포인트 기준을 수정했습니다.' });
+      onClose();
+    }
+  }, [state, showToast, onClose]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <FoxModal
+      open
+      size="md"
+      title="수정"
+      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}
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.fields}
+      >
+        <p className={styles.required}>* 는 필수 항목입니다.</p>
+
+        <input type="hidden" name="id" value={standard.id} />
+
+        <div className={styles.field}>
+          {/* 기획 ① 이벤트행은 수정불가(Read Only). 값은 hidden id가 갖는다. */}
+          <FoxInput
+            size="md"
+            label="이벤트"
+            requirement="required"
+            value={standard.eventCode}
+            readOnly
+            state="view"
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span" requirement="required">
+            포인트
+          </FoxFormLabel>
+          <div className={styles.numberRow}>
+            <div className={styles.numberField}>
+              <FoxInput
+                size="md"
+                name="point"
+                type="number"
+                inputMode="numeric"
+                min={0}
+                aria-label="포인트"
+                defaultValue={String(standard.point)}
+                invalid={Boolean(errors.point)}
+              />
+            </div>
+            <span className={styles.numberNote}>포인트</span>
+          </div>
+          {errors.point && <FoxHelperText type="danger" message={errors.point} />}
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span" requirement="required">
+            제한포인트
+          </FoxFormLabel>
+          <div className={styles.numberRow}>
+            <div className={styles.numberField}>
+              <FoxInput
+                size="md"
+                name="limitPoint"
+                type="number"
+                inputMode="numeric"
+                min={0}
+                aria-label="제한포인트"
+                defaultValue={String(standard.limitPoint)}
+                invalid={Boolean(errors.limitPoint)}
+              />
+            </div>
+            <span className={styles.numberNote}>
+              &apos;0&apos; 일경우 일 누적 포인트에 대한 제한이 없습니다.
+            </span>
+          </div>
+          {errors.limitPoint && (
+            <FoxHelperText type="danger" message={errors.limitPoint} />
+          )}
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span">중복 여부</FoxFormLabel>
+          <FoxRadioGroup name="isDuplicable" size="md" label="중복 여부">
+            <FoxRadio
+              value="Y"
+              label="중복 가능"
+              checked={isDuplicable}
+              onChange={() => setIsDuplicable(true)}
+            />
+            <FoxRadio
+              value="N"
+              label="중복 불가"
+              checked={!isDuplicable}
+              onChange={() => setIsDuplicable(false)}
+            />
+          </FoxRadioGroup>
+        </div>
+
+        <div className={styles.field}>
+          <FoxTextArea
+            name="description"
+            label="이벤트 설명"
+            requirement="required"
+            defaultValue={standard.description}
+            placeholder="이벤트 설명을 입력하세요."
+            rows={4}
+            counter
+            maxLength={DESCRIPTION_MAX_LENGTH}
+            invalid={Boolean(errors.description)}
+            message={errors.description}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span">사용여부</FoxFormLabel>
+          <FoxRadioGroup name="isActive" size="md" label="사용여부">
+            <FoxRadio
+              value="Y"
+              label="사용"
+              checked={isActive}
+              onChange={() => setIsActive(true)}
+            />
+            <FoxRadio
+              value="N"
+              label="사용안함"
+              checked={!isActive}
+              onChange={() => setIsActive(false)}
+            />
+          </FoxRadioGroup>
+        </div>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/points/standards/_components/point-standard-form.module.scss (added)
+++ app/(protected)/(basic)/points/standards/_components/point-standard-form.module.scss
@@ -0,0 +1,38 @@
+@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);
+}
+
+// 숫자 칸은 시안처럼 좁게 두고 단위·안내가 옆에 선다.
+.numberRow {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(3);
+}
+
+.numberField {
+  inline-size: 16rem;
+}
+
+.numberNote {
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-sm);
+}
+
+.required {
+  color: fox.color(font-system-danger);
+  font-size: fox.font-size(label-sm);
+  text-align: end;
+}
 
app/(protected)/(basic)/points/standards/_components/point-standard-list.tsx (added)
+++ app/(protected)/(basic)/points/standards/_components/point-standard-list.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { FoxButton } from '@fox/core/components/fox-button';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import {
+  formatActiveLabel,
+  formatDuplicableLabel,
+  formatLimitPoint,
+  formatOptionalValue,
+  formatPoint,
+  type PointStandard,
+} from '@/lib/domain/point-standard';
+import {
+  POINT_STANDARDS_PATH,
+  POINT_STANDARD_PAGE_SIZE_OPTIONS,
+  buildPointStandardHref,
+  type PointStandardPageSize,
+  type PointStandardQuery,
+} from '@/lib/domain/point-standard-query';
+import { PointStandardRowActions } from './point-standard-row-actions';
+
+interface PointStandardListProps {
+  items: PointStandard[];
+  query: PointStandardQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+export function PointStandardList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: PointStandardListProps) {
+  const router = useRouter();
+
+  function go(patch: Partial<PointStandardQuery>) {
+    router.replace(buildPointStandardHref(query, { ...patch, page: 1 }));
+  }
+
+  const columns: FoxListColumn<PointStandard>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    { key: 'eventCode', header: '이벤트/구분', width: 140, emphasis: true },
+    {
+      key: 'point',
+      header: '포인트',
+      width: 100,
+      align: 'end',
+      render: (row) => formatPoint(row.point),
+    },
+    {
+      key: 'limitPoint',
+      header: '제한포인트',
+      width: 120,
+      align: 'end',
+      render: (row) => formatLimitPoint(row.limitPoint),
+    },
+    {
+      key: 'isDuplicable',
+      header: '중복여부',
+      width: 120,
+      render: (row) => (
+        <FoxStatusIndicator
+          type={row.isDuplicable ? 'success' : 'default'}
+          label={formatDuplicableLabel(row.isDuplicable)}
+        />
+      ),
+    },
+    {
+      key: 'siteCode',
+      header: '사이트',
+      width: 140,
+      render: (row) => formatOptionalValue(row.siteCode),
+    },
+    { key: 'description', header: '설명', width: 320 },
+    {
+      key: 'isActive',
+      header: '사용여부',
+      width: 120,
+      render: (row) => (
+        <FoxStatusIndicator
+          type={row.isActive ? 'success' : 'danger'}
+          label={formatActiveLabel(row.isActive)}
+        />
+      ),
+    },
+    {
+      key: 'updatedAt',
+      header: '수정일시',
+      width: 160,
+      render: (row) => formatOptionalValue(row.updatedAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => <PointStandardRowActions standard={row} />,
+    },
+  ];
+
+  return (
+    <FoxListContainer<PointStandard>
+      title="포인트 기준관리 목록"
+      breadcrumb={[
+        { label: '홈', href: '/' },
+        { label: '포인트관리' },
+        { label: '포인트 기준관리' },
+      ]}
+      caption="포인트 기준관리 목록"
+      columns={columns}
+      rows={items}
+      rowKey={(row) => row.id}
+      totalCount={totalCount}
+      emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
+      sorts={
+        <FoxSelectText
+          size="sm"
+          ariaLabel="페이지 크기"
+          options={POINT_STANDARD_PAGE_SIZE_OPTIONS.map((size) => ({
+            value: String(size),
+            label: `${size}개씩 보기`,
+          }))}
+          value={String(query.pageSize)}
+          onValueChange={(value) =>
+            go({ pageSize: Number(value) as PointStandardPageSize })
+          }
+        />
+      }
+      searchFields={[]}
+      keyword={query.keyword}
+      searchPlaceholder="검색어를 입력하세요."
+      toolbarActions={
+        <FoxButton
+          type="default"
+          size="md"
+          label="초기화"
+          onAction={() => router.replace(POINT_STANDARDS_PATH)}
+        />
+      }
+      page={currentPage}
+      totalPages={totalPages}
+      buildHref={(patch) =>
+        buildPointStandardHref(query, { page: Number(patch.page ?? 1) })
+      }
+      onQueryChange={(patch) => {
+        if (patch.keyword !== undefined) {
+          go({ keyword: String(patch.keyword ?? '') });
+        }
+      }}
+    />
+  );
+}
 
app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx (added)
+++ app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+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';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { PointStandard } from '@/lib/domain/point-standard';
+import { deletePointStandardAction } from '../_actions';
+import { PointStandardFormModal } from './point-standard-form-modal';
+
+interface PointStandardRowActionsProps {
+  standard: PointStandard;
+}
+
+export function PointStandardRowActions({
+  standard,
+}: PointStandardRowActionsProps) {
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isEditOpen, setIsEditOpen] = useState(false);
+  const [isDeleting, startDeleting] = useTransition();
+
+  function runDelete() {
+    hideAlert();
+    startDeleting(async () => {
+      const result = await deletePointStandardAction(standard.id);
+      if (result.status === 'error') {
+        showToast({ variant: 'danger', message: result.message });
+        return;
+      }
+      showToast({ variant: 'success', message: '포인트 기준을 삭제했습니다.' });
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '포인트 기준을 삭제하시겠습니까?',
+      message: `"${standard.eventCode}" 기준을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  return (
+    <div className="flex items-center gap-1">
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<FoxPencilSimpleIcon />}
+        label="수정"
+        onAction={() => setIsEditOpen(true)}
+      />
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<FoxTrashIcon />}
+        label={isDeleting ? '삭제 중' : '삭제'}
+        disabled={isDeleting}
+        onAction={confirmDelete}
+      />
+
+      {isEditOpen && (
+        <PointStandardFormModal
+          standard={standard}
+          onClose={() => setIsEditOpen(false)}
+        />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/points/standards/page.tsx (added)
+++ app/(protected)/(basic)/points/standards/page.tsx
@@ -0,0 +1,30 @@
+import { verifySession } from '@/lib/auth/dal';
+import { fetchPointStandards } from '@/lib/data/repositories/point-standard-repository';
+import { parsePointStandardQuery } from '@/lib/domain/point-standard-query';
+import { PointStandardList } from './_components/point-standard-list';
+
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parsePointStandardQuery(await searchParams);
+  const { items, totalCount } = await fetchPointStandards(query);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <PointStandardList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
lib/data/repositories/point-account-repository.ts (added)
+++ lib/data/repositories/point-account-repository.ts
@@ -0,0 +1,153 @@
+import 'server-only';
+import type {
+  PointAccount,
+  PointHistoryEntry,
+} from '@/lib/domain/point-account';
+import type { PointAccountQuery } from '@/lib/domain/point-account-query';
+
+// TODO(백엔드): 누적포인트 API가 아직 없다. 아래 mock 생성기를 지우고 다른 Repository처럼
+// backendFetch + getSessionAccessToken으로 교체한다.
+// 이슈: 구분(사이트)은 기획대로 FOX PAY App. 하나뿐이다 — 확장은 백엔드가 사이트를 실어 준 뒤다.
+
+const TOTAL_ACCOUNTS = 3290;
+const SITE_NAME = 'FOX PAY App.';
+const SCHOOL_NAMES = [
+  '폭스고등학교',
+  '여우고등학교',
+  '한빛고등학교',
+  '새롬고등학교',
+];
+const EVENT_TITLES = ['EVENT-1', 'EVENT-2', 'EVENT-3'];
+const BASE_DATE = Date.UTC(2026, 1, 5);
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function dateAt(offsetDays: number): string {
+  return new Date(BASE_DATE - offsetDays * DAY_MS).toISOString().slice(0, 10);
+}
+
+function timestampAt(index: number): string {
+  const hour = 9 + (index % 9);
+  const minute = (index * 7) % 60;
+  const pad = (value: number) => String(value).padStart(2, '0');
+  return `${dateAt(index % 400)} ${pad(hour)}:${pad(minute)}`;
+}
+
+/** 인덱스로만 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다. */
+function buildAccount(index: number): PointAccount {
+  const serial = TOTAL_ACCOUNTS - index;
+  const totalPoint = 120 + ((index * 37) % 880);
+  const deductedPoint = (index * 13) % 60;
+  const usedPoint = (index * 29) % 300;
+
+  return {
+    id: `PA-${String(serial).padStart(5, '0')}`,
+    memberNo: `NO_${String(10000 + serial)}`,
+    loginId: `user_${String(serial).padStart(4, '0')}`,
+    studentName: `학생${serial}`,
+    schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length],
+    grade: (index % 3) + 1,
+    classNo: (index % 10) + 1,
+    studentNo: (index % 30) + 1,
+    totalPoint,
+    deductedPoint,
+    usedPoint,
+    currentPoint: totalPoint - deductedPoint - usedPoint,
+    updatedAt: timestampAt(index),
+    siteName: SITE_NAME,
+  };
+}
+
+const ACCOUNTS: PointAccount[] = Array.from(
+  { length: TOTAL_ACCOUNTS },
+  (_, index) => buildAccount(index)
+);
+
+function matchesQuery(account: PointAccount, query: PointAccountQuery): boolean {
+  if (
+    query.schoolName &&
+    !account.schoolName.toLowerCase().includes(query.schoolName.toLowerCase())
+  ) {
+    return false;
+  }
+  if (query.grade !== null && account.grade !== query.grade) {
+    return false;
+  }
+  if (query.classNo !== null && account.classNo !== query.classNo) {
+    return false;
+  }
+  if (query.studentNo !== null && account.studentNo !== query.studentNo) {
+    return false;
+  }
+  if (query.keyword) {
+    const needle = query.keyword.toLowerCase();
+    const haystack =
+      query.searchField === 'loginId' ? account.loginId : account.studentName;
+    if (!haystack.toLowerCase().includes(needle)) {
+      return false;
+    }
+  }
+  return true;
+}
+
+/** 기획 PTS10200001 ② — 최근 수정일시로 정렬. */
+function byUpdatedAtDesc(a: PointAccount, b: PointAccount): number {
+  return b.updatedAt.localeCompare(a.updatedAt);
+}
+
+export async function fetchPointAccounts(
+  query: PointAccountQuery
+): Promise<{ items: PointAccount[]; totalCount: number }> {
+  const filtered = ACCOUNTS.filter((account) => matchesQuery(account, query));
+  filtered.sort(byUpdatedAtDesc);
+
+  const start = (query.page - 1) * query.pageSize;
+  return {
+    items: filtered.slice(start, start + query.pageSize),
+    totalCount: filtered.length,
+  };
+}
+
+export async function fetchPointAccount(
+  id: string
+): Promise<PointAccount | null> {
+  return ACCOUNTS.find((account) => account.id === id) ?? null;
+}
+
+function buildHistory(account: PointAccount): PointHistoryEntry[] {
+  const seed = Number(account.id.slice(3));
+  const length = 12 + (seed % 9);
+
+  return Array.from({ length }, (_, index) => {
+    const isPurchase = (seed + index) % 4 === 3;
+    return {
+      id: `${account.id}-H${String(index + 1).padStart(3, '0')}`,
+      title: isPurchase
+        ? `배지 이미지 구입 - BADGE_${String(((seed + index) % 20) + 1).padStart(3, '0')}`
+        : EVENT_TITLES[(seed + index) % EVENT_TITLES.length],
+      point: isPurchase ? -100 : 10,
+      siteName: SITE_NAME,
+      date: dateAt(index),
+    };
+  });
+}
+
+/** 기획 PTS10300001 ② — 최근 날짜순 정렬. */
+export async function fetchPointHistory(
+  accountId: string,
+  page: number,
+  pageSize: number
+): Promise<{ items: PointHistoryEntry[]; totalCount: number }> {
+  const account = await fetchPointAccount(accountId);
+  if (!account) {
+    return { items: [], totalCount: 0 };
+  }
+
+  const entries = buildHistory(account);
+  entries.sort((a, b) => b.date.localeCompare(a.date));
+
+  const start = (page - 1) * pageSize;
+  return {
+    items: entries.slice(start, start + pageSize),
+    totalCount: entries.length,
+  };
+}
 
lib/data/repositories/point-standard-repository.ts (added)
+++ lib/data/repositories/point-standard-repository.ts
@@ -0,0 +1,131 @@
+import 'server-only';
+import type { PointStandard } from '@/lib/domain/point-standard';
+import type { PointStandardQuery } from '@/lib/domain/point-standard-query';
+import type { PointStandardValues } from '@/lib/domain/point-standard-form';
+
+// TODO(백엔드): 포인트 기준관리 API가 아직 없다. 아래 mock 저장소를 지우고 다른 Repository처럼
+// backendFetch + getSessionAccessToken으로 교체하면 호출부는 그대로 둘 수 있다.
+// 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 수정·삭제가 초기값으로 되돌아간다.
+
+let STANDARDS: PointStandard[] = [
+  {
+    id: 'EVENT-1',
+    eventCode: 'EVENT-1',
+    point: 5,
+    limitPoint: 10,
+    isDuplicable: true,
+    siteCode: 'SELF_STUDY',
+    description: '과제 제출 시 20포인트 지급',
+    isActive: true,
+    updatedAt: '2026-01-10 09:40',
+  },
+  {
+    id: 'EVENT-2',
+    eventCode: 'EVENT-2',
+    point: 10,
+    limitPoint: 0,
+    isDuplicable: true,
+    siteCode: 'SELF_STUDY',
+    description: '수업완료 시 10포인트 지급',
+    isActive: true,
+    updatedAt: '2026-01-18 10:02',
+  },
+  {
+    id: 'EVENT-3',
+    eventCode: 'EVENT-3',
+    point: 5,
+    limitPoint: 0,
+    isDuplicable: false,
+    siteCode: 'SELF_STUDY',
+    description: '과정 이수 시 100포인트 지급',
+    isActive: true,
+    updatedAt: '2026-02-05 13:26',
+  },
+  {
+    id: 'EVENT-4',
+    eventCode: 'EVENT-4',
+    point: 3,
+    limitPoint: 30,
+    isDuplicable: true,
+    siteCode: 'FOX_PAY_APP',
+    description: '출석 체크 시 3포인트 지급',
+    isActive: true,
+    updatedAt: '2026-01-24 16:11',
+  },
+  {
+    id: 'EVENT-5',
+    eventCode: 'EVENT-5',
+    point: 20,
+    limitPoint: 60,
+    isDuplicable: false,
+    siteCode: 'FOX_PAY_APP',
+    description: '금융OX퀴즈 정답 시 20포인트 지급',
+    isActive: false,
+    updatedAt: '2026-01-02 11:35',
+  },
+];
+
+function matchesKeyword(standard: PointStandard, keyword: string): boolean {
+  const needle = keyword.toLowerCase();
+  return (
+    standard.eventCode.toLowerCase().includes(needle) ||
+    standard.description.toLowerCase().includes(needle)
+  );
+}
+
+/** 기획 PTS10100001 ② — 최근 수정일시 순 정렬. */
+function byUpdatedAtDesc(a: PointStandard, b: PointStandard): number {
+  return b.updatedAt.localeCompare(a.updatedAt);
+}
+
+export async function fetchPointStandards(
+  query: PointStandardQuery
+): Promise<{ items: PointStandard[]; totalCount: number }> {
+  const filtered = query.keyword
+    ? STANDARDS.filter((standard) => matchesKeyword(standard, query.keyword))
+    : [...STANDARDS];
+
+  filtered.sort(byUpdatedAtDesc);
+
+  const start = (query.page - 1) * query.pageSize;
+  return {
+    items: filtered.slice(start, start + query.pageSize),
+    totalCount: filtered.length,
+  };
+}
+
+function nowStamp(): string {
+  const now = new Date();
+  const pad = (value: number) => String(value).padStart(2, '0');
+  return (
+    `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
+    `${pad(now.getHours())}:${pad(now.getMinutes())}`
+  );
+}
+
+export async function updatePointStandard(
+  values: PointStandardValues
+): Promise<void> {
+  const index = STANDARDS.findIndex((standard) => standard.id === values.id);
+  if (index < 0) {
+    throw new Error(`포인트 기준을 찾을 수 없습니다: ${values.id}`);
+  }
+
+  STANDARDS[index] = {
+    ...STANDARDS[index],
+    point: values.point,
+    limitPoint: values.limitPoint,
+    isDuplicable: values.isDuplicable,
+    description: values.description,
+    isActive: values.isActive,
+    updatedAt: nowStamp(),
+  };
+}
+
+export async function deletePointStandard(id: string): Promise<void> {
+  const next = STANDARDS.filter((standard) => standard.id !== id);
+  if (next.length === STANDARDS.length) {
+    throw new Error(`포인트 기준을 찾을 수 없습니다: ${id}`);
+  }
+  STANDARDS = next;
+}
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -71,6 +71,15 @@
           ],
         },
         {
+          id: 'points',
+          label: '포인트관리',
+          icon: 'coins',
+          children: [
+            { id: 'point-standards', label: '포인트 기준관리', href: '/points/standards' },
+            { id: 'point-accounts', label: '누적포인트 관리', href: '/points/accumulated' },
+          ],
+        },
+        {
           id: 'contents',
           label: '콘텐츠관리',
           icon: 'image',
 
lib/domain/point-account-query.ts (added)
+++ lib/domain/point-account-query.ts
@@ -0,0 +1,135 @@
+export const POINT_ACCOUNTS_PATH = '/points/accumulated';
+
+export type PointAccountSearchField = 'loginId' | 'studentName';
+
+export const POINT_ACCOUNT_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
+  value: PointAccountSearchField;
+  label: string;
+}> = [
+  { value: 'loginId', label: '아이디' },
+  { value: 'studentName', label: '이름' },
+];
+
+/** 필터 셀렉트의 "전체" 값 — URL에서는 생략되고 화면에서는 이 값이 선택된다. */
+export const FILTER_ALL = 'all';
+
+export const GRADE_OPTIONS = [1, 2, 3] as const;
+export const CLASS_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
+export const NUMBER_OPTIONS = Array.from({ length: 30 }, (_, index) => index + 1);
+
+/** 상세내역 팝업 안의 페이지 크기(기획 PTS10300001). */
+export const POINT_HISTORY_PAGE_SIZE = 5;
+
+export const POINT_ACCOUNT_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type PointAccountPageSize =
+  (typeof POINT_ACCOUNT_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_POINT_ACCOUNT_SEARCH_FIELD: PointAccountSearchField =
+  'loginId';
+export const DEFAULT_POINT_ACCOUNT_PAGE_SIZE: PointAccountPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+const MAX_SCHOOL_NAME_LENGTH = 100;
+
+export type PointAccountQuery = {
+  schoolName: string;
+  grade: number | null;
+  classNo: number | null;
+  studentNo: number | null;
+  searchField: PointAccountSearchField;
+  keyword: string;
+  page: number;
+  pageSize: PointAccountPageSize;
+};
+
+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 readNumberIn(
+  params: RawSearchParams,
+  key: string,
+  allowed: readonly number[]
+): number | null {
+  const value = Number(readParam(params, key));
+  return Number.isInteger(value) && allowed.includes(value) ? value : null;
+}
+
+function isPointAccountSearchField(
+  value: string | undefined
+): value is PointAccountSearchField {
+  return (
+    value !== undefined &&
+    POINT_ACCOUNT_SEARCH_FIELD_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+function isPointAccountPageSize(value: number): value is PointAccountPageSize {
+  return (POINT_ACCOUNT_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
+}
+
+export function parsePointAccountQuery(
+  searchParams: RawSearchParams
+): PointAccountQuery {
+  const searchFieldRaw = readParam(searchParams, 'searchField');
+  const keywordRaw = readParam(searchParams, 'keyword');
+  const schoolNameRaw = readParam(searchParams, 'schoolName');
+  const pageRaw = Number(readParam(searchParams, 'page'));
+  const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
+
+  return {
+    schoolName: (schoolNameRaw ?? '').trim().slice(0, MAX_SCHOOL_NAME_LENGTH),
+    grade: readNumberIn(searchParams, 'grade', GRADE_OPTIONS),
+    classNo: readNumberIn(searchParams, 'classNo', CLASS_OPTIONS),
+    studentNo: readNumberIn(searchParams, 'studentNo', NUMBER_OPTIONS),
+    searchField: isPointAccountSearchField(searchFieldRaw)
+      ? searchFieldRaw
+      : DEFAULT_POINT_ACCOUNT_SEARCH_FIELD,
+    keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH),
+    page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
+    pageSize: isPointAccountPageSize(pageSizeRaw)
+      ? pageSizeRaw
+      : DEFAULT_POINT_ACCOUNT_PAGE_SIZE,
+  };
+}
+
+export function buildPointAccountHref(
+  query: PointAccountQuery,
+  overrides: Partial<PointAccountQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.schoolName) {
+    params.set('schoolName', merged.schoolName);
+  }
+  if (merged.grade !== null) {
+    params.set('grade', String(merged.grade));
+  }
+  if (merged.classNo !== null) {
+    params.set('classNo', String(merged.classNo));
+  }
+  if (merged.studentNo !== null) {
+    params.set('studentNo', String(merged.studentNo));
+  }
+  if (merged.searchField !== DEFAULT_POINT_ACCOUNT_SEARCH_FIELD) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.pageSize !== DEFAULT_POINT_ACCOUNT_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) {
+    params.set('page', String(merged.page));
+  }
+
+  const queryString = params.toString();
+  return queryString
+    ? `${POINT_ACCOUNTS_PATH}?${queryString}`
+    : POINT_ACCOUNTS_PATH;
+}
 
lib/domain/point-account.ts (added)
+++ lib/domain/point-account.ts
@@ -0,0 +1,52 @@
+export interface PointAccount {
+  id: string;
+  memberNo: string;
+  loginId: string;
+  studentName: string;
+  schoolName: string;
+  grade: number;
+  classNo: number;
+  studentNo: number;
+  totalPoint: number;
+  deductedPoint: number;
+  usedPoint: number;
+  currentPoint: number;
+  updatedAt: string;
+  siteName: string;
+}
+
+export interface PointHistoryEntry {
+  id: string;
+  title: string;
+  point: number;
+  siteName: string;
+  date: string;
+}
+
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+export function formatOptionalValue(
+  value: string | number | null | undefined
+): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : String(value);
+}
+
+export function formatPoint(value: number): string {
+  return value.toLocaleString('ko-KR');
+}
+
+/** 취득은 `+`, 사용은 `-`를 붙여 부호가 곧 방향이 되게 한다(기획 PTS10300001 ②). */
+export function formatPointDelta(value: number): string {
+  const sign = value > 0 ? '+' : '';
+  return `${sign}${formatPoint(value)} P`;
+}
+
+export function isPointEarned(value: number): boolean {
+  return value > 0;
+}
+
+export function formatGradeClassNumber(account: PointAccount): string {
+  return `${account.grade}학년 ${account.classNo}반 ${account.studentNo}번`;
+}
 
lib/domain/point-standard-form.ts (added)
+++ lib/domain/point-standard-form.ts
@@ -0,0 +1,89 @@
+export const POINT_MAX = 1_000_000;
+export const DESCRIPTION_MAX_LENGTH = 200;
+
+export interface PointStandardValues {
+  id: string;
+  point: number;
+  limitPoint: number;
+  isDuplicable: boolean;
+  description: string;
+  isActive: boolean;
+}
+
+export type PointStandardFormErrors = Partial<
+  Record<'id' | 'point' | 'limitPoint' | 'description', string>
+>;
+
+export type PointStandardFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: PointStandardFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_POINT_STANDARD_FORM_STATE: PointStandardFormState = {
+  status: 'idle',
+};
+
+type ValidationResult =
+  | { ok: true; values: PointStandardValues }
+  | { ok: false; errors: PointStandardFormErrors };
+
+function validatePointNumber(
+  value: number,
+  label: string
+): { error?: string } {
+  if (!Number.isInteger(value)) {
+    return { error: `${label}는 숫자만 입력할 수 있습니다.` };
+  }
+  if (value < 0) {
+    return { error: `${label}는 0 이상으로 입력해 주세요.` };
+  }
+  if (value > POINT_MAX) {
+    return {
+      error: `${label}는 ${POINT_MAX.toLocaleString('ko-KR')} 이하로 입력해 주세요.`,
+    };
+  }
+  return {};
+}
+
+export function validatePointStandard(
+  values: PointStandardValues
+): ValidationResult {
+  const errors: PointStandardFormErrors = {};
+
+  if (!values.id.trim()) {
+    errors.id = '수정할 대상을 찾을 수 없습니다.';
+  }
+
+  const point = validatePointNumber(values.point, '포인트');
+  if (point.error) {
+    errors.point = point.error;
+  }
+
+  const limitPoint = validatePointNumber(values.limitPoint, '제한포인트');
+  if (limitPoint.error) {
+    errors.limitPoint = limitPoint.error;
+  }
+
+  const description = values.description.trim();
+  if (!description) {
+    errors.description = '이벤트 설명을 입력해 주세요.';
+  } else if (description.length > DESCRIPTION_MAX_LENGTH) {
+    errors.description = `이벤트 설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: {
+      id: values.id.trim(),
+      point: values.point,
+      limitPoint: values.limitPoint,
+      isDuplicable: values.isDuplicable,
+      description,
+      isActive: values.isActive,
+    },
+  };
+}
 
lib/domain/point-standard-query.ts (added)
+++ lib/domain/point-standard-query.ts
@@ -0,0 +1,66 @@
+export const POINT_STANDARDS_PATH = '/points/standards';
+
+export const POINT_STANDARD_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type PointStandardPageSize =
+  (typeof POINT_STANDARD_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_POINT_STANDARD_PAGE_SIZE: PointStandardPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+/** 검색어 하나가 이벤트와 이벤트 설명을 함께 훑는다(기획 PTS10100001 ①). */
+export type PointStandardQuery = {
+  keyword: string;
+  page: number;
+  pageSize: PointStandardPageSize;
+};
+
+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 isPointStandardPageSize(value: number): value is PointStandardPageSize {
+  return (POINT_STANDARD_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
+}
+
+export function parsePointStandardQuery(
+  searchParams: RawSearchParams
+): PointStandardQuery {
+  const keywordRaw = readParam(searchParams, 'keyword');
+  const pageRaw = Number(readParam(searchParams, 'page'));
+  const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
+
+  return {
+    keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH),
+    page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
+    pageSize: isPointStandardPageSize(pageSizeRaw)
+      ? pageSizeRaw
+      : DEFAULT_POINT_STANDARD_PAGE_SIZE,
+  };
+}
+
+export function buildPointStandardHref(
+  query: PointStandardQuery,
+  overrides: Partial<PointStandardQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.pageSize !== DEFAULT_POINT_STANDARD_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) {
+    params.set('page', String(merged.page));
+  }
+
+  const queryString = params.toString();
+  return queryString
+    ? `${POINT_STANDARDS_PATH}?${queryString}`
+    : POINT_STANDARDS_PATH;
+}
 
lib/domain/point-standard.ts (added)
+++ lib/domain/point-standard.ts
@@ -0,0 +1,40 @@
+export interface PointStandard {
+  id: string;
+  eventCode: string;
+  point: number;
+  limitPoint: number;
+  isDuplicable: boolean;
+  siteCode: string;
+  description: string;
+  isActive: boolean;
+  updatedAt: string;
+}
+
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+/** 제한포인트가 0이면 일 누적 제한이 없다(기획 PTS10100002_p ③). */
+export const UNLIMITED_LIMIT_POINT = 0;
+
+export function formatOptionalValue(
+  value: string | number | null | undefined
+): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : String(value);
+}
+
+export function formatPoint(value: number): string {
+  return value.toLocaleString('ko-KR');
+}
+
+export function formatLimitPoint(value: number): string {
+  return value === UNLIMITED_LIMIT_POINT ? '제한 없음' : formatPoint(value);
+}
+
+export function formatDuplicableLabel(isDuplicable: boolean): string {
+  return isDuplicable ? '중복 가능' : '중복 불가';
+}
+
+export function formatActiveLabel(isActive: boolean): string {
+  return isActive ? '사용' : '사용안함';
+}
Add a comment
List