+++ app/(protected)/(basic)/points/accumulated/_actions.ts
... | ... | @@ -0,0 +1,25 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { verifySession } from '@/lib/auth/dal'; | |
| 4 | +import { fetchPointHistory } from '@/lib/data/repositories/point-account-repository'; | |
| 5 | +import type { PointHistoryEntry } from '@/lib/domain/point-account'; | |
| 6 | +import { POINT_HISTORY_PAGE_SIZE } from '@/lib/domain/point-account-query'; | |
| 7 | + | |
| 8 | +/** 상세내역은 목록 행에 없는 데이터라 팝업이 열릴 때 따로 가져온다(기획 PTS10300001). */ | |
| 9 | +export async function fetchPointHistoryAction( | |
| 10 | + accountId: string, | |
| 11 | + page: number | |
| 12 | +): Promise<{ items: PointHistoryEntry[]; totalPages: number }> { | |
| 13 | + await verifySession(); | |
| 14 | + | |
| 15 | + const { items, totalCount } = await fetchPointHistory( | |
| 16 | + accountId, | |
| 17 | + page, | |
| 18 | + POINT_HISTORY_PAGE_SIZE | |
| 19 | + ); | |
| 20 | + | |
| 21 | + return { | |
| 22 | + items, | |
| 23 | + totalPages: Math.max(1, Math.ceil(totalCount / POINT_HISTORY_PAGE_SIZE)), | |
| 24 | + }; | |
| 25 | +} |
+++ app/(protected)/(basic)/points/accumulated/_components/point-account-filter.module.scss
... | ... | @@ -0,0 +1,16 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.filter { | |
| 4 | + display: flex; | |
| 5 | + flex-wrap: wrap; | |
| 6 | + align-items: center; | |
| 7 | + gap: fox.gap(3); | |
| 8 | +} | |
| 9 | + | |
| 10 | +.school { | |
| 11 | + inline-size: 24rem; | |
| 12 | +} | |
| 13 | + | |
| 14 | +.select { | |
| 15 | + inline-size: 12rem; | |
| 16 | +} |
+++ app/(protected)/(basic)/points/accumulated/_components/point-account-list.tsx
... | ... | @@ -0,0 +1,255 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState } from 'react'; | |
| 5 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 6 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 7 | +import { | |
| 8 | + FoxListContainer, | |
| 9 | + type FoxListColumn, | |
| 10 | +} from '@fox/core/components/fox-list-container'; | |
| 11 | +import { FoxSelect } from '@fox/core/components/fox-select'; | |
| 12 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 13 | +import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 14 | +import { FoxArrowUpRightIcon } from '@fox/core/icons'; | |
| 15 | +import { | |
| 16 | + formatOptionalValue, | |
| 17 | + formatPoint, | |
| 18 | + type PointAccount, | |
| 19 | +} from '@/lib/domain/point-account'; | |
| 20 | +import { | |
| 21 | + CLASS_OPTIONS, | |
| 22 | + FILTER_ALL, | |
| 23 | + GRADE_OPTIONS, | |
| 24 | + NUMBER_OPTIONS, | |
| 25 | + POINT_ACCOUNTS_PATH, | |
| 26 | + POINT_ACCOUNT_PAGE_SIZE_OPTIONS, | |
| 27 | + POINT_ACCOUNT_SEARCH_FIELD_OPTIONS, | |
| 28 | + buildPointAccountHref, | |
| 29 | + type PointAccountPageSize, | |
| 30 | + type PointAccountQuery, | |
| 31 | + type PointAccountSearchField, | |
| 32 | +} from '@/lib/domain/point-account-query'; | |
| 33 | +import { PointDetailModal } from './point-detail-modal'; | |
| 34 | +import styles from './point-account-filter.module.scss'; | |
| 35 | + | |
| 36 | +interface PointAccountListProps { | |
| 37 | + items: PointAccount[]; | |
| 38 | + query: PointAccountQuery; | |
| 39 | + currentPage: number; | |
| 40 | + totalPages: number; | |
| 41 | + totalCount: number; | |
| 42 | +} | |
| 43 | + | |
| 44 | +function numberOptions(values: readonly number[], suffix: string) { | |
| 45 | + return [ | |
| 46 | + { value: FILTER_ALL, label: `${suffix} 전체` }, | |
| 47 | + ...values.map((value) => ({ | |
| 48 | + value: String(value), | |
| 49 | + label: `${value}${suffix}`, | |
| 50 | + })), | |
| 51 | + ]; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export function PointAccountList({ | |
| 55 | + items, | |
| 56 | + query, | |
| 57 | + currentPage, | |
| 58 | + totalPages, | |
| 59 | + totalCount, | |
| 60 | +}: PointAccountListProps) { | |
| 61 | + const router = useRouter(); | |
| 62 | + const [detailAccount, setDetailAccount] = useState<PointAccount | null>(null); | |
| 63 | + const [schoolNameDraft, setSchoolNameDraft] = useState(query.schoolName); | |
| 64 | + | |
| 65 | + function go(patch: Partial<PointAccountQuery>) { | |
| 66 | + router.replace(buildPointAccountHref(query, { ...patch, page: 1 })); | |
| 67 | + } | |
| 68 | + | |
| 69 | + function selectedNumber(value: number | null) { | |
| 70 | + return value === null ? FILTER_ALL : String(value); | |
| 71 | + } | |
| 72 | + | |
| 73 | + function parseSelected(value: string): number | null { | |
| 74 | + return value === FILTER_ALL ? null : Number(value); | |
| 75 | + } | |
| 76 | + | |
| 77 | + const columns: FoxListColumn<PointAccount>[] = [ | |
| 78 | + { | |
| 79 | + key: 'no', | |
| 80 | + header: '번호', | |
| 81 | + width: 80, | |
| 82 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 83 | + }, | |
| 84 | + { key: 'loginId', header: '아이디', width: 140 }, | |
| 85 | + { key: 'studentName', header: '학생명', width: 120, emphasis: true }, | |
| 86 | + { key: 'schoolName', header: '학교명', width: 160 }, | |
| 87 | + { key: 'grade', header: '학년', width: 80, align: 'center' }, | |
| 88 | + { key: 'classNo', header: '반', width: 80, align: 'center' }, | |
| 89 | + { key: 'studentNo', header: '번호', width: 80, align: 'center' }, | |
| 90 | + { | |
| 91 | + key: 'totalPoint', | |
| 92 | + header: '총 포인트', | |
| 93 | + width: 120, | |
| 94 | + align: 'end', | |
| 95 | + render: (row) => formatPoint(row.totalPoint), | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + key: 'deductedPoint', | |
| 99 | + header: '차감 포인트', | |
| 100 | + width: 120, | |
| 101 | + align: 'end', | |
| 102 | + render: (row) => formatPoint(row.deductedPoint), | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + key: 'usedPoint', | |
| 106 | + header: '사용 포인트', | |
| 107 | + width: 120, | |
| 108 | + align: 'end', | |
| 109 | + render: (row) => formatPoint(row.usedPoint), | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + key: 'currentPoint', | |
| 113 | + header: '현재 포인트', | |
| 114 | + width: 120, | |
| 115 | + align: 'end', | |
| 116 | + render: (row) => formatPoint(row.currentPoint), | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + key: 'updatedAt', | |
| 120 | + header: '수정일시', | |
| 121 | + width: 160, | |
| 122 | + render: (row) => formatOptionalValue(row.updatedAt), | |
| 123 | + }, | |
| 124 | + { key: 'siteName', header: '구분(사이트)', width: 160 }, | |
| 125 | + { | |
| 126 | + key: 'actions', | |
| 127 | + header: '관리', | |
| 128 | + width: 100, | |
| 129 | + render: (row) => ( | |
| 130 | + <FoxIconButton | |
| 131 | + type="default" | |
| 132 | + size="sm" | |
| 133 | + icon={<FoxArrowUpRightIcon />} | |
| 134 | + label={`${row.studentName} 포인트 상세내역`} | |
| 135 | + onAction={() => setDetailAccount(row)} | |
| 136 | + /> | |
| 137 | + ), | |
| 138 | + }, | |
| 139 | + ]; | |
| 140 | + | |
| 141 | + return ( | |
| 142 | + <> | |
| 143 | + <FoxListContainer<PointAccount> | |
| 144 | + title="누적포인트 관리 목록" | |
| 145 | + breadcrumb={[ | |
| 146 | + { label: '홈', href: '/' }, | |
| 147 | + { label: '포인트관리' }, | |
| 148 | + { label: '누적포인트 관리' }, | |
| 149 | + ]} | |
| 150 | + caption="누적포인트 관리 목록" | |
| 151 | + columns={columns} | |
| 152 | + rows={items} | |
| 153 | + rowKey={(row) => row.id} | |
| 154 | + totalCount={totalCount} | |
| 155 | + emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 156 | + sorts={ | |
| 157 | + <FoxSelectText | |
| 158 | + size="sm" | |
| 159 | + ariaLabel="페이지 크기" | |
| 160 | + options={POINT_ACCOUNT_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 161 | + value: String(size), | |
| 162 | + label: `${size}개씩 보기`, | |
| 163 | + }))} | |
| 164 | + value={String(query.pageSize)} | |
| 165 | + onValueChange={(value) => | |
| 166 | + go({ pageSize: Number(value) as PointAccountPageSize }) | |
| 167 | + } | |
| 168 | + /> | |
| 169 | + } | |
| 170 | + filter={ | |
| 171 | + <div className={styles.filter}> | |
| 172 | + <div className={styles.school}> | |
| 173 | + <FoxInput | |
| 174 | + size="md" | |
| 175 | + aria-label="학교명" | |
| 176 | + placeholder="학교를 입력하세요." | |
| 177 | + value={schoolNameDraft} | |
| 178 | + onChange={setSchoolNameDraft} | |
| 179 | + onKeyDown={(event) => { | |
| 180 | + if (event.key === 'Enter') { | |
| 181 | + go({ schoolName: schoolNameDraft }); | |
| 182 | + } | |
| 183 | + }} | |
| 184 | + /> | |
| 185 | + </div> | |
| 186 | + <div className={styles.select}> | |
| 187 | + <FoxSelect | |
| 188 | + size="md" | |
| 189 | + options={numberOptions(GRADE_OPTIONS, '학년')} | |
| 190 | + value={selectedNumber(query.grade)} | |
| 191 | + onValueChange={(value) => go({ grade: parseSelected(value) })} | |
| 192 | + /> | |
| 193 | + </div> | |
| 194 | + <div className={styles.select}> | |
| 195 | + <FoxSelect | |
| 196 | + size="md" | |
| 197 | + options={numberOptions(CLASS_OPTIONS, '반')} | |
| 198 | + value={selectedNumber(query.classNo)} | |
| 199 | + onValueChange={(value) => go({ classNo: parseSelected(value) })} | |
| 200 | + /> | |
| 201 | + </div> | |
| 202 | + <div className={styles.select}> | |
| 203 | + <FoxSelect | |
| 204 | + size="md" | |
| 205 | + options={numberOptions(NUMBER_OPTIONS, '번')} | |
| 206 | + value={selectedNumber(query.studentNo)} | |
| 207 | + onValueChange={(value) => go({ studentNo: parseSelected(value) })} | |
| 208 | + /> | |
| 209 | + </div> | |
| 210 | + </div> | |
| 211 | + } | |
| 212 | + searchFields={POINT_ACCOUNT_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 213 | + value: option.value, | |
| 214 | + label: option.label, | |
| 215 | + }))} | |
| 216 | + searchField={query.searchField} | |
| 217 | + keyword={query.keyword} | |
| 218 | + searchPlaceholder="검색어를 입력하세요." | |
| 219 | + toolbarActions={ | |
| 220 | + <FoxButton | |
| 221 | + type="default" | |
| 222 | + size="md" | |
| 223 | + label="초기화" | |
| 224 | + onAction={() => { | |
| 225 | + setSchoolNameDraft(''); | |
| 226 | + router.replace(POINT_ACCOUNTS_PATH); | |
| 227 | + }} | |
| 228 | + /> | |
| 229 | + } | |
| 230 | + page={currentPage} | |
| 231 | + totalPages={totalPages} | |
| 232 | + buildHref={(patch) => | |
| 233 | + buildPointAccountHref(query, { page: Number(patch.page ?? 1) }) | |
| 234 | + } | |
| 235 | + onQueryChange={(patch) => { | |
| 236 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 237 | + go({ | |
| 238 | + schoolName: schoolNameDraft, | |
| 239 | + keyword: String(patch.keyword ?? ''), | |
| 240 | + searchField: (patch.searchField ?? | |
| 241 | + query.searchField) as PointAccountSearchField, | |
| 242 | + }); | |
| 243 | + } | |
| 244 | + }} | |
| 245 | + /> | |
| 246 | + | |
| 247 | + {detailAccount && ( | |
| 248 | + <PointDetailModal | |
| 249 | + account={detailAccount} | |
| 250 | + onClose={() => setDetailAccount(null)} | |
| 251 | + /> | |
| 252 | + )} | |
| 253 | + </> | |
| 254 | + ); | |
| 255 | +} |
+++ app/(protected)/(basic)/points/accumulated/_components/point-detail-modal.module.scss
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.body { | |
| 4 | + display: flex; | |
| 5 | + flex-direction: column; | |
| 6 | + gap: fox.gap(6); | |
| 7 | +} | |
| 8 | + | |
| 9 | +.pagination { | |
| 10 | + display: flex; | |
| 11 | + justify-content: center; | |
| 12 | + padding-block-start: fox.spacing(top-sm); | |
| 13 | +} | |
| 14 | + | |
| 15 | +.empty { | |
| 16 | + padding-block: fox.spacing(top-md); | |
| 17 | + color: fox.color(font-neutral-subtler); | |
| 18 | + font-size: fox.font-size(label-sm); | |
| 19 | + text-align: center; | |
| 20 | +} |
+++ app/(protected)/(basic)/points/accumulated/_components/point-detail-modal.tsx
... | ... | @@ -0,0 +1,139 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect, useState, useTransition } from 'react'; | |
| 4 | +import { FoxModal } from '@fox/core/components/fox-modal'; | |
| 5 | +import { FoxPagination } from '@fox/core/components/fox-pagination'; | |
| 6 | +import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; | |
| 7 | +import { FoxTable } from '@fox/core/components/fox-table'; | |
| 8 | +import { FoxTableCell } from '@fox/core/components/fox-table-cell'; | |
| 9 | +import { FoxTableRow } from '@fox/core/components/fox-table-row'; | |
| 10 | +import { | |
| 11 | + formatOptionalValue, | |
| 12 | + formatPoint, | |
| 13 | + formatPointDelta, | |
| 14 | + isPointEarned, | |
| 15 | + type PointAccount, | |
| 16 | + type PointHistoryEntry, | |
| 17 | +} from '@/lib/domain/point-account'; | |
| 18 | +import { fetchPointHistoryAction } from '../_actions'; | |
| 19 | +import styles from './point-detail-modal.module.scss'; | |
| 20 | + | |
| 21 | +interface PointDetailModalProps { | |
| 22 | + account: PointAccount; | |
| 23 | + onClose: () => void; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const MEMBER_COLUMNS = [ | |
| 27 | + '회원번호', | |
| 28 | + '아이디', | |
| 29 | + '학생명', | |
| 30 | + '학교명', | |
| 31 | + '학년', | |
| 32 | + '반', | |
| 33 | + '번호', | |
| 34 | + '총 포인트', | |
| 35 | + '차감 포인트', | |
| 36 | + '사용 포인트', | |
| 37 | + '현재 포인트', | |
| 38 | +]; | |
| 39 | + | |
| 40 | +export function PointDetailModal({ account, onClose }: PointDetailModalProps) { | |
| 41 | + const [page, setPage] = useState(1); | |
| 42 | + const [entries, setEntries] = useState<PointHistoryEntry[]>([]); | |
| 43 | + const [totalPages, setTotalPages] = useState(1); | |
| 44 | + const [isLoading, startLoading] = useTransition(); | |
| 45 | + | |
| 46 | + useEffect(() => { | |
| 47 | + startLoading(async () => { | |
| 48 | + const result = await fetchPointHistoryAction(account.id, page); | |
| 49 | + setEntries(result.items); | |
| 50 | + setTotalPages(result.totalPages); | |
| 51 | + }); | |
| 52 | + }, [account.id, page]); | |
| 53 | + | |
| 54 | + const memberValues = [ | |
| 55 | + account.memberNo, | |
| 56 | + account.loginId, | |
| 57 | + account.studentName, | |
| 58 | + account.schoolName, | |
| 59 | + account.grade, | |
| 60 | + account.classNo, | |
| 61 | + account.studentNo, | |
| 62 | + formatPoint(account.totalPoint), | |
| 63 | + formatPoint(account.deductedPoint), | |
| 64 | + formatPoint(account.usedPoint), | |
| 65 | + formatPoint(account.currentPoint), | |
| 66 | + ]; | |
| 67 | + | |
| 68 | + return ( | |
| 69 | + <FoxModal open size="lg" title="포인트 상세내역" onClose={onClose}> | |
| 70 | + <div className={styles.body}> | |
| 71 | + <FoxTable type="list" caption="회원정보"> | |
| 72 | + <FoxTableRow head> | |
| 73 | + {MEMBER_COLUMNS.map((column) => ( | |
| 74 | + <FoxTableCell key={column} head> | |
| 75 | + {column} | |
| 76 | + </FoxTableCell> | |
| 77 | + ))} | |
| 78 | + </FoxTableRow> | |
| 79 | + <FoxTableRow> | |
| 80 | + {memberValues.map((value, index) => ( | |
| 81 | + <FoxTableCell key={MEMBER_COLUMNS[index]} label={MEMBER_COLUMNS[index]}> | |
| 82 | + {formatOptionalValue(value)} | |
| 83 | + </FoxTableCell> | |
| 84 | + ))} | |
| 85 | + </FoxTableRow> | |
| 86 | + </FoxTable> | |
| 87 | + | |
| 88 | + <FoxTable type="list" caption="포인트 내역"> | |
| 89 | + <FoxTableRow head> | |
| 90 | + <FoxTableCell head width={320}> | |
| 91 | + 항목 | |
| 92 | + </FoxTableCell> | |
| 93 | + <FoxTableCell head width={140}> | |
| 94 | + 포인트 | |
| 95 | + </FoxTableCell> | |
| 96 | + <FoxTableCell head width={160}> | |
| 97 | + 구분(사이트) | |
| 98 | + </FoxTableCell> | |
| 99 | + <FoxTableCell head width={140}> | |
| 100 | + 날짜 | |
| 101 | + </FoxTableCell> | |
| 102 | + </FoxTableRow> | |
| 103 | + | |
| 104 | + {entries.length === 0 ? ( | |
| 105 | + <FoxTableRow> | |
| 106 | + <FoxTableCell colSpan={4} align="center"> | |
| 107 | + {isLoading ? '불러오는 중…' : '포인트 내역이 없습니다.'} | |
| 108 | + </FoxTableCell> | |
| 109 | + </FoxTableRow> | |
| 110 | + ) : ( | |
| 111 | + entries.map((entry) => ( | |
| 112 | + <FoxTableRow key={entry.id}> | |
| 113 | + <FoxTableCell label="항목">{entry.title}</FoxTableCell> | |
| 114 | + <FoxTableCell label="포인트"> | |
| 115 | + {/* 기획 ② 취득은 파랑 +, 사용은 빨강 −. */} | |
| 116 | + <FoxStatusIndicator | |
| 117 | + type={isPointEarned(entry.point) ? 'information' : 'danger'} | |
| 118 | + label={formatPointDelta(entry.point)} | |
| 119 | + /> | |
| 120 | + </FoxTableCell> | |
| 121 | + <FoxTableCell label="구분(사이트)">{entry.siteName}</FoxTableCell> | |
| 122 | + <FoxTableCell label="날짜">{entry.date}</FoxTableCell> | |
| 123 | + </FoxTableRow> | |
| 124 | + )) | |
| 125 | + )} | |
| 126 | + </FoxTable> | |
| 127 | + | |
| 128 | + <div className={styles.pagination}> | |
| 129 | + <FoxPagination | |
| 130 | + currentPage={page} | |
| 131 | + totalPages={totalPages} | |
| 132 | + onPageChange={setPage} | |
| 133 | + label="포인트 내역 페이지" | |
| 134 | + /> | |
| 135 | + </div> | |
| 136 | + </div> | |
| 137 | + </FoxModal> | |
| 138 | + ); | |
| 139 | +} |
+++ app/(protected)/(basic)/points/accumulated/page.tsx
... | ... | @@ -0,0 +1,30 @@ |
| 1 | +import { verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { fetchPointAccounts } from '@/lib/data/repositories/point-account-repository'; | |
| 3 | +import { parsePointAccountQuery } from '@/lib/domain/point-account-query'; | |
| 4 | +import { PointAccountList } from './_components/point-account-list'; | |
| 5 | + | |
| 6 | +export const dynamic = 'force-dynamic'; | |
| 7 | + | |
| 8 | +interface PageProps { | |
| 9 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export default async function Page({ searchParams }: PageProps) { | |
| 13 | + await verifySession(); | |
| 14 | + | |
| 15 | + const query = parsePointAccountQuery(await searchParams); | |
| 16 | + const { items, totalCount } = await fetchPointAccounts(query); | |
| 17 | + | |
| 18 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 19 | + const currentPage = Math.min(query.page, totalPages); | |
| 20 | + | |
| 21 | + return ( | |
| 22 | + <PointAccountList | |
| 23 | + items={items} | |
| 24 | + query={query} | |
| 25 | + currentPage={currentPage} | |
| 26 | + totalPages={totalPages} | |
| 27 | + totalCount={totalCount} | |
| 28 | + /> | |
| 29 | + ); | |
| 30 | +} |
+++ app/(protected)/(basic)/points/standards/_actions.ts
... | ... | @@ -0,0 +1,70 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + deletePointStandard, | |
| 7 | + updatePointStandard, | |
| 8 | +} from '@/lib/data/repositories/point-standard-repository'; | |
| 9 | +import { | |
| 10 | + validatePointStandard, | |
| 11 | + type PointStandardFormState, | |
| 12 | +} from '@/lib/domain/point-standard-form'; | |
| 13 | +import { POINT_STANDARDS_PATH } from '@/lib/domain/point-standard-query'; | |
| 14 | + | |
| 15 | +const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 16 | +const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 17 | + | |
| 18 | +function readString(formData: FormData, key: string): string { | |
| 19 | + const value = formData.get(key); | |
| 20 | + return typeof value === 'string' ? value : ''; | |
| 21 | +} | |
| 22 | + | |
| 23 | +function parseFormNumber(formData: FormData, key: string): number { | |
| 24 | + const trimmed = readString(formData, key).trim(); | |
| 25 | + return trimmed === '' ? NaN : Number(trimmed); | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** 기획 PTS10100002_p — 이벤트는 읽기 전용이라 대상 id는 hidden 필드에서 읽는다. */ | |
| 29 | +export async function updatePointStandardAction( | |
| 30 | + _prevState: PointStandardFormState, | |
| 31 | + formData: FormData | |
| 32 | +): Promise<PointStandardFormState> { | |
| 33 | + await verifySession(); | |
| 34 | + | |
| 35 | + const validation = validatePointStandard({ | |
| 36 | + id: readString(formData, 'id'), | |
| 37 | + point: parseFormNumber(formData, 'point'), | |
| 38 | + limitPoint: parseFormNumber(formData, 'limitPoint'), | |
| 39 | + isDuplicable: readString(formData, 'isDuplicable') === 'Y', | |
| 40 | + description: readString(formData, 'description'), | |
| 41 | + isActive: readString(formData, 'isActive') === 'Y', | |
| 42 | + }); | |
| 43 | + if (!validation.ok) { | |
| 44 | + return { status: 'error', errors: validation.errors }; | |
| 45 | + } | |
| 46 | + | |
| 47 | + try { | |
| 48 | + await updatePointStandard(validation.values); | |
| 49 | + } catch { | |
| 50 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 51 | + } | |
| 52 | + | |
| 53 | + revalidatePath(POINT_STANDARDS_PATH); | |
| 54 | + return { status: 'success' }; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export async function deletePointStandardAction( | |
| 58 | + id: string | |
| 59 | +): Promise<{ status: 'success' } | { status: 'error'; message: string }> { | |
| 60 | + await verifySession(); | |
| 61 | + | |
| 62 | + try { | |
| 63 | + await deletePointStandard(id); | |
| 64 | + } catch { | |
| 65 | + return { status: 'error', message: DELETE_FAILED_MESSAGE }; | |
| 66 | + } | |
| 67 | + | |
| 68 | + revalidatePath(POINT_STANDARDS_PATH); | |
| 69 | + return { status: 'success' }; | |
| 70 | +} |
+++ app/(protected)/(basic)/points/standards/_components/point-standard-form-modal.tsx
... | ... | @@ -0,0 +1,195 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect, useRef, useState } from 'react'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { FoxFormLabel } from '@fox/core/components/fox-form-label'; | |
| 6 | +import { FoxHelperText } from '@fox/core/components/fox-helper-text'; | |
| 7 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 8 | +import { FoxModal } from '@fox/core/components/fox-modal'; | |
| 9 | +import { FoxRadio } from '@fox/core/components/fox-radio'; | |
| 10 | +import { FoxRadioGroup } from '@fox/core/components/fox-radio-group'; | |
| 11 | +import { FoxTextArea } from '@fox/core/components/fox-text-area'; | |
| 12 | +import { submitFormAction } from '@/app/_hooks/submit-form-action'; | |
| 13 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 14 | +import { | |
| 15 | + DESCRIPTION_MAX_LENGTH, | |
| 16 | + INITIAL_POINT_STANDARD_FORM_STATE, | |
| 17 | +} from '@/lib/domain/point-standard-form'; | |
| 18 | +import type { PointStandard } from '@/lib/domain/point-standard'; | |
| 19 | +import { updatePointStandardAction } from '../_actions'; | |
| 20 | +import styles from './point-standard-form.module.scss'; | |
| 21 | + | |
| 22 | +interface PointStandardFormModalProps { | |
| 23 | + standard: PointStandard; | |
| 24 | + onClose: () => void; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function PointStandardFormModal({ | |
| 28 | + standard, | |
| 29 | + onClose, | |
| 30 | +}: PointStandardFormModalProps) { | |
| 31 | + const { showToast } = useFeedback(); | |
| 32 | + const formRef = useRef<HTMLFormElement>(null); | |
| 33 | + const [state, formAction, isPending] = useActionState( | |
| 34 | + updatePointStandardAction, | |
| 35 | + INITIAL_POINT_STANDARD_FORM_STATE | |
| 36 | + ); | |
| 37 | + | |
| 38 | + const [isDuplicable, setIsDuplicable] = useState(standard.isDuplicable); | |
| 39 | + const [isActive, setIsActive] = useState(standard.isActive); | |
| 40 | + | |
| 41 | + useEffect(() => { | |
| 42 | + if (state.status === 'success') { | |
| 43 | + showToast({ variant: 'success', message: '포인트 기준을 수정했습니다.' }); | |
| 44 | + onClose(); | |
| 45 | + } | |
| 46 | + }, [state, showToast, onClose]); | |
| 47 | + | |
| 48 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <FoxModal | |
| 52 | + open | |
| 53 | + size="md" | |
| 54 | + title="수정" | |
| 55 | + onClose={onClose} | |
| 56 | + actions={ | |
| 57 | + <> | |
| 58 | + <FoxButton type="default" size="md" label="취소" onAction={onClose} /> | |
| 59 | + <FoxButton | |
| 60 | + type="primary" | |
| 61 | + size="md" | |
| 62 | + label={isPending ? '저장 중...' : '수정'} | |
| 63 | + disabled={isPending} | |
| 64 | + onAction={() => formRef.current?.requestSubmit()} | |
| 65 | + /> | |
| 66 | + </> | |
| 67 | + } | |
| 68 | + > | |
| 69 | + <form | |
| 70 | + ref={formRef} | |
| 71 | + onSubmit={(event) => submitFormAction(event, formAction)} | |
| 72 | + className={styles.fields} | |
| 73 | + > | |
| 74 | + <p className={styles.required}>* 는 필수 항목입니다.</p> | |
| 75 | + | |
| 76 | + <input type="hidden" name="id" value={standard.id} /> | |
| 77 | + | |
| 78 | + <div className={styles.field}> | |
| 79 | + {/* 기획 ① 이벤트행은 수정불가(Read Only). 값은 hidden id가 갖는다. */} | |
| 80 | + <FoxInput | |
| 81 | + size="md" | |
| 82 | + label="이벤트" | |
| 83 | + requirement="required" | |
| 84 | + value={standard.eventCode} | |
| 85 | + readOnly | |
| 86 | + state="view" | |
| 87 | + /> | |
| 88 | + </div> | |
| 89 | + | |
| 90 | + <div className={styles.field}> | |
| 91 | + <FoxFormLabel as="span" requirement="required"> | |
| 92 | + 포인트 | |
| 93 | + </FoxFormLabel> | |
| 94 | + <div className={styles.numberRow}> | |
| 95 | + <div className={styles.numberField}> | |
| 96 | + <FoxInput | |
| 97 | + size="md" | |
| 98 | + name="point" | |
| 99 | + type="number" | |
| 100 | + inputMode="numeric" | |
| 101 | + min={0} | |
| 102 | + aria-label="포인트" | |
| 103 | + defaultValue={String(standard.point)} | |
| 104 | + invalid={Boolean(errors.point)} | |
| 105 | + /> | |
| 106 | + </div> | |
| 107 | + <span className={styles.numberNote}>포인트</span> | |
| 108 | + </div> | |
| 109 | + {errors.point && <FoxHelperText type="danger" message={errors.point} />} | |
| 110 | + </div> | |
| 111 | + | |
| 112 | + <div className={styles.field}> | |
| 113 | + <FoxFormLabel as="span" requirement="required"> | |
| 114 | + 제한포인트 | |
| 115 | + </FoxFormLabel> | |
| 116 | + <div className={styles.numberRow}> | |
| 117 | + <div className={styles.numberField}> | |
| 118 | + <FoxInput | |
| 119 | + size="md" | |
| 120 | + name="limitPoint" | |
| 121 | + type="number" | |
| 122 | + inputMode="numeric" | |
| 123 | + min={0} | |
| 124 | + aria-label="제한포인트" | |
| 125 | + defaultValue={String(standard.limitPoint)} | |
| 126 | + invalid={Boolean(errors.limitPoint)} | |
| 127 | + /> | |
| 128 | + </div> | |
| 129 | + <span className={styles.numberNote}> | |
| 130 | + '0' 일경우 일 누적 포인트에 대한 제한이 없습니다. | |
| 131 | + </span> | |
| 132 | + </div> | |
| 133 | + {errors.limitPoint && ( | |
| 134 | + <FoxHelperText type="danger" message={errors.limitPoint} /> | |
| 135 | + )} | |
| 136 | + </div> | |
| 137 | + | |
| 138 | + <div className={styles.field}> | |
| 139 | + <FoxFormLabel as="span">중복 여부</FoxFormLabel> | |
| 140 | + <FoxRadioGroup name="isDuplicable" size="md" label="중복 여부"> | |
| 141 | + <FoxRadio | |
| 142 | + value="Y" | |
| 143 | + label="중복 가능" | |
| 144 | + checked={isDuplicable} | |
| 145 | + onChange={() => setIsDuplicable(true)} | |
| 146 | + /> | |
| 147 | + <FoxRadio | |
| 148 | + value="N" | |
| 149 | + label="중복 불가" | |
| 150 | + checked={!isDuplicable} | |
| 151 | + onChange={() => setIsDuplicable(false)} | |
| 152 | + /> | |
| 153 | + </FoxRadioGroup> | |
| 154 | + </div> | |
| 155 | + | |
| 156 | + <div className={styles.field}> | |
| 157 | + <FoxTextArea | |
| 158 | + name="description" | |
| 159 | + label="이벤트 설명" | |
| 160 | + requirement="required" | |
| 161 | + defaultValue={standard.description} | |
| 162 | + placeholder="이벤트 설명을 입력하세요." | |
| 163 | + rows={4} | |
| 164 | + counter | |
| 165 | + maxLength={DESCRIPTION_MAX_LENGTH} | |
| 166 | + invalid={Boolean(errors.description)} | |
| 167 | + message={errors.description} | |
| 168 | + /> | |
| 169 | + </div> | |
| 170 | + | |
| 171 | + <div className={styles.field}> | |
| 172 | + <FoxFormLabel as="span">사용여부</FoxFormLabel> | |
| 173 | + <FoxRadioGroup name="isActive" size="md" label="사용여부"> | |
| 174 | + <FoxRadio | |
| 175 | + value="Y" | |
| 176 | + label="사용" | |
| 177 | + checked={isActive} | |
| 178 | + onChange={() => setIsActive(true)} | |
| 179 | + /> | |
| 180 | + <FoxRadio | |
| 181 | + value="N" | |
| 182 | + label="사용안함" | |
| 183 | + checked={!isActive} | |
| 184 | + onChange={() => setIsActive(false)} | |
| 185 | + /> | |
| 186 | + </FoxRadioGroup> | |
| 187 | + </div> | |
| 188 | + | |
| 189 | + {state.status === 'error' && state.message && ( | |
| 190 | + <FoxHelperText type="danger" message={state.message} /> | |
| 191 | + )} | |
| 192 | + </form> | |
| 193 | + </FoxModal> | |
| 194 | + ); | |
| 195 | +} |
+++ app/(protected)/(basic)/points/standards/_components/point-standard-form.module.scss
... | ... | @@ -0,0 +1,38 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.fields { | |
| 4 | + display: flex; | |
| 5 | + inline-size: 100%; | |
| 6 | + flex-direction: column; | |
| 7 | +} | |
| 8 | + | |
| 9 | +.field { | |
| 10 | + display: flex; | |
| 11 | + inline-size: 100%; | |
| 12 | + flex-direction: column; | |
| 13 | + gap: fox.gap(3); | |
| 14 | + padding-block: fox.padding(6); | |
| 15 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 16 | +} | |
| 17 | + | |
| 18 | +// 숫자 칸은 시안처럼 좁게 두고 단위·안내가 옆에 선다. | |
| 19 | +.numberRow { | |
| 20 | + display: flex; | |
| 21 | + align-items: center; | |
| 22 | + gap: fox.gap(3); | |
| 23 | +} | |
| 24 | + | |
| 25 | +.numberField { | |
| 26 | + inline-size: 16rem; | |
| 27 | +} | |
| 28 | + | |
| 29 | +.numberNote { | |
| 30 | + color: fox.color(font-neutral-subtler); | |
| 31 | + font-size: fox.font-size(label-sm); | |
| 32 | +} | |
| 33 | + | |
| 34 | +.required { | |
| 35 | + color: fox.color(font-system-danger); | |
| 36 | + font-size: fox.font-size(label-sm); | |
| 37 | + text-align: end; | |
| 38 | +} |
+++ app/(protected)/(basic)/points/standards/_components/point-standard-list.tsx
... | ... | @@ -0,0 +1,165 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { | |
| 6 | + FoxListContainer, | |
| 7 | + type FoxListColumn, | |
| 8 | +} from '@fox/core/components/fox-list-container'; | |
| 9 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 10 | +import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; | |
| 11 | +import { | |
| 12 | + formatActiveLabel, | |
| 13 | + formatDuplicableLabel, | |
| 14 | + formatLimitPoint, | |
| 15 | + formatOptionalValue, | |
| 16 | + formatPoint, | |
| 17 | + type PointStandard, | |
| 18 | +} from '@/lib/domain/point-standard'; | |
| 19 | +import { | |
| 20 | + POINT_STANDARDS_PATH, | |
| 21 | + POINT_STANDARD_PAGE_SIZE_OPTIONS, | |
| 22 | + buildPointStandardHref, | |
| 23 | + type PointStandardPageSize, | |
| 24 | + type PointStandardQuery, | |
| 25 | +} from '@/lib/domain/point-standard-query'; | |
| 26 | +import { PointStandardRowActions } from './point-standard-row-actions'; | |
| 27 | + | |
| 28 | +interface PointStandardListProps { | |
| 29 | + items: PointStandard[]; | |
| 30 | + query: PointStandardQuery; | |
| 31 | + currentPage: number; | |
| 32 | + totalPages: number; | |
| 33 | + totalCount: number; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export function PointStandardList({ | |
| 37 | + items, | |
| 38 | + query, | |
| 39 | + currentPage, | |
| 40 | + totalPages, | |
| 41 | + totalCount, | |
| 42 | +}: PointStandardListProps) { | |
| 43 | + const router = useRouter(); | |
| 44 | + | |
| 45 | + function go(patch: Partial<PointStandardQuery>) { | |
| 46 | + router.replace(buildPointStandardHref(query, { ...patch, page: 1 })); | |
| 47 | + } | |
| 48 | + | |
| 49 | + const columns: FoxListColumn<PointStandard>[] = [ | |
| 50 | + { | |
| 51 | + key: 'no', | |
| 52 | + header: '번호', | |
| 53 | + width: 80, | |
| 54 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 55 | + }, | |
| 56 | + { key: 'eventCode', header: '이벤트/구분', width: 140, emphasis: true }, | |
| 57 | + { | |
| 58 | + key: 'point', | |
| 59 | + header: '포인트', | |
| 60 | + width: 100, | |
| 61 | + align: 'end', | |
| 62 | + render: (row) => formatPoint(row.point), | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + key: 'limitPoint', | |
| 66 | + header: '제한포인트', | |
| 67 | + width: 120, | |
| 68 | + align: 'end', | |
| 69 | + render: (row) => formatLimitPoint(row.limitPoint), | |
| 70 | + }, | |
| 71 | + { | |
| 72 | + key: 'isDuplicable', | |
| 73 | + header: '중복여부', | |
| 74 | + width: 120, | |
| 75 | + render: (row) => ( | |
| 76 | + <FoxStatusIndicator | |
| 77 | + type={row.isDuplicable ? 'success' : 'default'} | |
| 78 | + label={formatDuplicableLabel(row.isDuplicable)} | |
| 79 | + /> | |
| 80 | + ), | |
| 81 | + }, | |
| 82 | + { | |
| 83 | + key: 'siteCode', | |
| 84 | + header: '사이트', | |
| 85 | + width: 140, | |
| 86 | + render: (row) => formatOptionalValue(row.siteCode), | |
| 87 | + }, | |
| 88 | + { key: 'description', header: '설명', width: 320 }, | |
| 89 | + { | |
| 90 | + key: 'isActive', | |
| 91 | + header: '사용여부', | |
| 92 | + width: 120, | |
| 93 | + render: (row) => ( | |
| 94 | + <FoxStatusIndicator | |
| 95 | + type={row.isActive ? 'success' : 'danger'} | |
| 96 | + label={formatActiveLabel(row.isActive)} | |
| 97 | + /> | |
| 98 | + ), | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + key: 'updatedAt', | |
| 102 | + header: '수정일시', | |
| 103 | + width: 160, | |
| 104 | + render: (row) => formatOptionalValue(row.updatedAt), | |
| 105 | + }, | |
| 106 | + { | |
| 107 | + key: 'actions', | |
| 108 | + header: '관리', | |
| 109 | + width: 120, | |
| 110 | + render: (row) => <PointStandardRowActions standard={row} />, | |
| 111 | + }, | |
| 112 | + ]; | |
| 113 | + | |
| 114 | + return ( | |
| 115 | + <FoxListContainer<PointStandard> | |
| 116 | + title="포인트 기준관리 목록" | |
| 117 | + breadcrumb={[ | |
| 118 | + { label: '홈', href: '/' }, | |
| 119 | + { label: '포인트관리' }, | |
| 120 | + { label: '포인트 기준관리' }, | |
| 121 | + ]} | |
| 122 | + caption="포인트 기준관리 목록" | |
| 123 | + columns={columns} | |
| 124 | + rows={items} | |
| 125 | + rowKey={(row) => row.id} | |
| 126 | + totalCount={totalCount} | |
| 127 | + emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요." | |
| 128 | + sorts={ | |
| 129 | + <FoxSelectText | |
| 130 | + size="sm" | |
| 131 | + ariaLabel="페이지 크기" | |
| 132 | + options={POINT_STANDARD_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 133 | + value: String(size), | |
| 134 | + label: `${size}개씩 보기`, | |
| 135 | + }))} | |
| 136 | + value={String(query.pageSize)} | |
| 137 | + onValueChange={(value) => | |
| 138 | + go({ pageSize: Number(value) as PointStandardPageSize }) | |
| 139 | + } | |
| 140 | + /> | |
| 141 | + } | |
| 142 | + searchFields={[]} | |
| 143 | + keyword={query.keyword} | |
| 144 | + searchPlaceholder="검색어를 입력하세요." | |
| 145 | + toolbarActions={ | |
| 146 | + <FoxButton | |
| 147 | + type="default" | |
| 148 | + size="md" | |
| 149 | + label="초기화" | |
| 150 | + onAction={() => router.replace(POINT_STANDARDS_PATH)} | |
| 151 | + /> | |
| 152 | + } | |
| 153 | + page={currentPage} | |
| 154 | + totalPages={totalPages} | |
| 155 | + buildHref={(patch) => | |
| 156 | + buildPointStandardHref(query, { page: Number(patch.page ?? 1) }) | |
| 157 | + } | |
| 158 | + onQueryChange={(patch) => { | |
| 159 | + if (patch.keyword !== undefined) { | |
| 160 | + go({ keyword: String(patch.keyword ?? '') }); | |
| 161 | + } | |
| 162 | + }} | |
| 163 | + /> | |
| 164 | + ); | |
| 165 | +} |
+++ app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
... | ... | @@ -0,0 +1,76 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState, useTransition } from 'react'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 6 | +import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 7 | +import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons'; | |
| 8 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 9 | +import type { PointStandard } from '@/lib/domain/point-standard'; | |
| 10 | +import { deletePointStandardAction } from '../_actions'; | |
| 11 | +import { PointStandardFormModal } from './point-standard-form-modal'; | |
| 12 | + | |
| 13 | +interface PointStandardRowActionsProps { | |
| 14 | + standard: PointStandard; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export function PointStandardRowActions({ | |
| 18 | + standard, | |
| 19 | +}: PointStandardRowActionsProps) { | |
| 20 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 21 | + const [isEditOpen, setIsEditOpen] = useState(false); | |
| 22 | + const [isDeleting, startDeleting] = useTransition(); | |
| 23 | + | |
| 24 | + function runDelete() { | |
| 25 | + hideAlert(); | |
| 26 | + startDeleting(async () => { | |
| 27 | + const result = await deletePointStandardAction(standard.id); | |
| 28 | + if (result.status === 'error') { | |
| 29 | + showToast({ variant: 'danger', message: result.message }); | |
| 30 | + return; | |
| 31 | + } | |
| 32 | + showToast({ variant: 'success', message: '포인트 기준을 삭제했습니다.' }); | |
| 33 | + }); | |
| 34 | + } | |
| 35 | + | |
| 36 | + function confirmDelete() { | |
| 37 | + showAlert({ | |
| 38 | + variant: 'danger', | |
| 39 | + title: '포인트 기준을 삭제하시겠습니까?', | |
| 40 | + message: `"${standard.eventCode}" 기준을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 41 | + actions: ( | |
| 42 | + <FoxButtonGroup size="md"> | |
| 43 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 44 | + <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} /> | |
| 45 | + </FoxButtonGroup> | |
| 46 | + ), | |
| 47 | + }); | |
| 48 | + } | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <div className="flex items-center gap-1"> | |
| 52 | + <FoxIconButton | |
| 53 | + type="default" | |
| 54 | + size="sm" | |
| 55 | + icon={<FoxPencilSimpleIcon />} | |
| 56 | + label="수정" | |
| 57 | + onAction={() => setIsEditOpen(true)} | |
| 58 | + /> | |
| 59 | + <FoxIconButton | |
| 60 | + type="default" | |
| 61 | + size="sm" | |
| 62 | + icon={<FoxTrashIcon />} | |
| 63 | + label={isDeleting ? '삭제 중' : '삭제'} | |
| 64 | + disabled={isDeleting} | |
| 65 | + onAction={confirmDelete} | |
| 66 | + /> | |
| 67 | + | |
| 68 | + {isEditOpen && ( | |
| 69 | + <PointStandardFormModal | |
| 70 | + standard={standard} | |
| 71 | + onClose={() => setIsEditOpen(false)} | |
| 72 | + /> | |
| 73 | + )} | |
| 74 | + </div> | |
| 75 | + ); | |
| 76 | +} |
+++ app/(protected)/(basic)/points/standards/page.tsx
... | ... | @@ -0,0 +1,30 @@ |
| 1 | +import { verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { fetchPointStandards } from '@/lib/data/repositories/point-standard-repository'; | |
| 3 | +import { parsePointStandardQuery } from '@/lib/domain/point-standard-query'; | |
| 4 | +import { PointStandardList } from './_components/point-standard-list'; | |
| 5 | + | |
| 6 | +export const dynamic = 'force-dynamic'; | |
| 7 | + | |
| 8 | +interface PageProps { | |
| 9 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export default async function Page({ searchParams }: PageProps) { | |
| 13 | + await verifySession(); | |
| 14 | + | |
| 15 | + const query = parsePointStandardQuery(await searchParams); | |
| 16 | + const { items, totalCount } = await fetchPointStandards(query); | |
| 17 | + | |
| 18 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 19 | + const currentPage = Math.min(query.page, totalPages); | |
| 20 | + | |
| 21 | + return ( | |
| 22 | + <PointStandardList | |
| 23 | + items={items} | |
| 24 | + query={query} | |
| 25 | + currentPage={currentPage} | |
| 26 | + totalPages={totalPages} | |
| 27 | + totalCount={totalCount} | |
| 28 | + /> | |
| 29 | + ); | |
| 30 | +} |
+++ lib/data/repositories/point-account-repository.ts
... | ... | @@ -0,0 +1,153 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import type { | |
| 3 | + PointAccount, | |
| 4 | + PointHistoryEntry, | |
| 5 | +} from '@/lib/domain/point-account'; | |
| 6 | +import type { PointAccountQuery } from '@/lib/domain/point-account-query'; | |
| 7 | + | |
| 8 | +// TODO(백엔드): 누적포인트 API가 아직 없다. 아래 mock 생성기를 지우고 다른 Repository처럼 | |
| 9 | +// backendFetch + getSessionAccessToken으로 교체한다. | |
| 10 | +// 이슈: 구분(사이트)은 기획대로 FOX PAY App. 하나뿐이다 — 확장은 백엔드가 사이트를 실어 준 뒤다. | |
| 11 | + | |
| 12 | +const TOTAL_ACCOUNTS = 3290; | |
| 13 | +const SITE_NAME = 'FOX PAY App.'; | |
| 14 | +const SCHOOL_NAMES = [ | |
| 15 | + '폭스고등학교', | |
| 16 | + '여우고등학교', | |
| 17 | + '한빛고등학교', | |
| 18 | + '새롬고등학교', | |
| 19 | +]; | |
| 20 | +const EVENT_TITLES = ['EVENT-1', 'EVENT-2', 'EVENT-3']; | |
| 21 | +const BASE_DATE = Date.UTC(2026, 1, 5); | |
| 22 | +const DAY_MS = 24 * 60 * 60 * 1000; | |
| 23 | + | |
| 24 | +function dateAt(offsetDays: number): string { | |
| 25 | + return new Date(BASE_DATE - offsetDays * DAY_MS).toISOString().slice(0, 10); | |
| 26 | +} | |
| 27 | + | |
| 28 | +function timestampAt(index: number): string { | |
| 29 | + const hour = 9 + (index % 9); | |
| 30 | + const minute = (index * 7) % 60; | |
| 31 | + const pad = (value: number) => String(value).padStart(2, '0'); | |
| 32 | + return `${dateAt(index % 400)} ${pad(hour)}:${pad(minute)}`; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** 인덱스로만 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다. */ | |
| 36 | +function buildAccount(index: number): PointAccount { | |
| 37 | + const serial = TOTAL_ACCOUNTS - index; | |
| 38 | + const totalPoint = 120 + ((index * 37) % 880); | |
| 39 | + const deductedPoint = (index * 13) % 60; | |
| 40 | + const usedPoint = (index * 29) % 300; | |
| 41 | + | |
| 42 | + return { | |
| 43 | + id: `PA-${String(serial).padStart(5, '0')}`, | |
| 44 | + memberNo: `NO_${String(10000 + serial)}`, | |
| 45 | + loginId: `user_${String(serial).padStart(4, '0')}`, | |
| 46 | + studentName: `학생${serial}`, | |
| 47 | + schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length], | |
| 48 | + grade: (index % 3) + 1, | |
| 49 | + classNo: (index % 10) + 1, | |
| 50 | + studentNo: (index % 30) + 1, | |
| 51 | + totalPoint, | |
| 52 | + deductedPoint, | |
| 53 | + usedPoint, | |
| 54 | + currentPoint: totalPoint - deductedPoint - usedPoint, | |
| 55 | + updatedAt: timestampAt(index), | |
| 56 | + siteName: SITE_NAME, | |
| 57 | + }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +const ACCOUNTS: PointAccount[] = Array.from( | |
| 61 | + { length: TOTAL_ACCOUNTS }, | |
| 62 | + (_, index) => buildAccount(index) | |
| 63 | +); | |
| 64 | + | |
| 65 | +function matchesQuery(account: PointAccount, query: PointAccountQuery): boolean { | |
| 66 | + if ( | |
| 67 | + query.schoolName && | |
| 68 | + !account.schoolName.toLowerCase().includes(query.schoolName.toLowerCase()) | |
| 69 | + ) { | |
| 70 | + return false; | |
| 71 | + } | |
| 72 | + if (query.grade !== null && account.grade !== query.grade) { | |
| 73 | + return false; | |
| 74 | + } | |
| 75 | + if (query.classNo !== null && account.classNo !== query.classNo) { | |
| 76 | + return false; | |
| 77 | + } | |
| 78 | + if (query.studentNo !== null && account.studentNo !== query.studentNo) { | |
| 79 | + return false; | |
| 80 | + } | |
| 81 | + if (query.keyword) { | |
| 82 | + const needle = query.keyword.toLowerCase(); | |
| 83 | + const haystack = | |
| 84 | + query.searchField === 'loginId' ? account.loginId : account.studentName; | |
| 85 | + if (!haystack.toLowerCase().includes(needle)) { | |
| 86 | + return false; | |
| 87 | + } | |
| 88 | + } | |
| 89 | + return true; | |
| 90 | +} | |
| 91 | + | |
| 92 | +/** 기획 PTS10200001 ② — 최근 수정일시로 정렬. */ | |
| 93 | +function byUpdatedAtDesc(a: PointAccount, b: PointAccount): number { | |
| 94 | + return b.updatedAt.localeCompare(a.updatedAt); | |
| 95 | +} | |
| 96 | + | |
| 97 | +export async function fetchPointAccounts( | |
| 98 | + query: PointAccountQuery | |
| 99 | +): Promise<{ items: PointAccount[]; totalCount: number }> { | |
| 100 | + const filtered = ACCOUNTS.filter((account) => matchesQuery(account, query)); | |
| 101 | + filtered.sort(byUpdatedAtDesc); | |
| 102 | + | |
| 103 | + const start = (query.page - 1) * query.pageSize; | |
| 104 | + return { | |
| 105 | + items: filtered.slice(start, start + query.pageSize), | |
| 106 | + totalCount: filtered.length, | |
| 107 | + }; | |
| 108 | +} | |
| 109 | + | |
| 110 | +export async function fetchPointAccount( | |
| 111 | + id: string | |
| 112 | +): Promise<PointAccount | null> { | |
| 113 | + return ACCOUNTS.find((account) => account.id === id) ?? null; | |
| 114 | +} | |
| 115 | + | |
| 116 | +function buildHistory(account: PointAccount): PointHistoryEntry[] { | |
| 117 | + const seed = Number(account.id.slice(3)); | |
| 118 | + const length = 12 + (seed % 9); | |
| 119 | + | |
| 120 | + return Array.from({ length }, (_, index) => { | |
| 121 | + const isPurchase = (seed + index) % 4 === 3; | |
| 122 | + return { | |
| 123 | + id: `${account.id}-H${String(index + 1).padStart(3, '0')}`, | |
| 124 | + title: isPurchase | |
| 125 | + ? `배지 이미지 구입 - BADGE_${String(((seed + index) % 20) + 1).padStart(3, '0')}` | |
| 126 | + : EVENT_TITLES[(seed + index) % EVENT_TITLES.length], | |
| 127 | + point: isPurchase ? -100 : 10, | |
| 128 | + siteName: SITE_NAME, | |
| 129 | + date: dateAt(index), | |
| 130 | + }; | |
| 131 | + }); | |
| 132 | +} | |
| 133 | + | |
| 134 | +/** 기획 PTS10300001 ② — 최근 날짜순 정렬. */ | |
| 135 | +export async function fetchPointHistory( | |
| 136 | + accountId: string, | |
| 137 | + page: number, | |
| 138 | + pageSize: number | |
| 139 | +): Promise<{ items: PointHistoryEntry[]; totalCount: number }> { | |
| 140 | + const account = await fetchPointAccount(accountId); | |
| 141 | + if (!account) { | |
| 142 | + return { items: [], totalCount: 0 }; | |
| 143 | + } | |
| 144 | + | |
| 145 | + const entries = buildHistory(account); | |
| 146 | + entries.sort((a, b) => b.date.localeCompare(a.date)); | |
| 147 | + | |
| 148 | + const start = (page - 1) * pageSize; | |
| 149 | + return { | |
| 150 | + items: entries.slice(start, start + pageSize), | |
| 151 | + totalCount: entries.length, | |
| 152 | + }; | |
| 153 | +} |
+++ lib/data/repositories/point-standard-repository.ts
... | ... | @@ -0,0 +1,131 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import type { PointStandard } from '@/lib/domain/point-standard'; | |
| 3 | +import type { PointStandardQuery } from '@/lib/domain/point-standard-query'; | |
| 4 | +import type { PointStandardValues } from '@/lib/domain/point-standard-form'; | |
| 5 | + | |
| 6 | +// TODO(백엔드): 포인트 기준관리 API가 아직 없다. 아래 mock 저장소를 지우고 다른 Repository처럼 | |
| 7 | +// backendFetch + getSessionAccessToken으로 교체하면 호출부는 그대로 둘 수 있다. | |
| 8 | +// 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 수정·삭제가 초기값으로 되돌아간다. | |
| 9 | + | |
| 10 | +let STANDARDS: PointStandard[] = [ | |
| 11 | + { | |
| 12 | + id: 'EVENT-1', | |
| 13 | + eventCode: 'EVENT-1', | |
| 14 | + point: 5, | |
| 15 | + limitPoint: 10, | |
| 16 | + isDuplicable: true, | |
| 17 | + siteCode: 'SELF_STUDY', | |
| 18 | + description: '과제 제출 시 20포인트 지급', | |
| 19 | + isActive: true, | |
| 20 | + updatedAt: '2026-01-10 09:40', | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + id: 'EVENT-2', | |
| 24 | + eventCode: 'EVENT-2', | |
| 25 | + point: 10, | |
| 26 | + limitPoint: 0, | |
| 27 | + isDuplicable: true, | |
| 28 | + siteCode: 'SELF_STUDY', | |
| 29 | + description: '수업완료 시 10포인트 지급', | |
| 30 | + isActive: true, | |
| 31 | + updatedAt: '2026-01-18 10:02', | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + id: 'EVENT-3', | |
| 35 | + eventCode: 'EVENT-3', | |
| 36 | + point: 5, | |
| 37 | + limitPoint: 0, | |
| 38 | + isDuplicable: false, | |
| 39 | + siteCode: 'SELF_STUDY', | |
| 40 | + description: '과정 이수 시 100포인트 지급', | |
| 41 | + isActive: true, | |
| 42 | + updatedAt: '2026-02-05 13:26', | |
| 43 | + }, | |
| 44 | + { | |
| 45 | + id: 'EVENT-4', | |
| 46 | + eventCode: 'EVENT-4', | |
| 47 | + point: 3, | |
| 48 | + limitPoint: 30, | |
| 49 | + isDuplicable: true, | |
| 50 | + siteCode: 'FOX_PAY_APP', | |
| 51 | + description: '출석 체크 시 3포인트 지급', | |
| 52 | + isActive: true, | |
| 53 | + updatedAt: '2026-01-24 16:11', | |
| 54 | + }, | |
| 55 | + { | |
| 56 | + id: 'EVENT-5', | |
| 57 | + eventCode: 'EVENT-5', | |
| 58 | + point: 20, | |
| 59 | + limitPoint: 60, | |
| 60 | + isDuplicable: false, | |
| 61 | + siteCode: 'FOX_PAY_APP', | |
| 62 | + description: '금융OX퀴즈 정답 시 20포인트 지급', | |
| 63 | + isActive: false, | |
| 64 | + updatedAt: '2026-01-02 11:35', | |
| 65 | + }, | |
| 66 | +]; | |
| 67 | + | |
| 68 | +function matchesKeyword(standard: PointStandard, keyword: string): boolean { | |
| 69 | + const needle = keyword.toLowerCase(); | |
| 70 | + return ( | |
| 71 | + standard.eventCode.toLowerCase().includes(needle) || | |
| 72 | + standard.description.toLowerCase().includes(needle) | |
| 73 | + ); | |
| 74 | +} | |
| 75 | + | |
| 76 | +/** 기획 PTS10100001 ② — 최근 수정일시 순 정렬. */ | |
| 77 | +function byUpdatedAtDesc(a: PointStandard, b: PointStandard): number { | |
| 78 | + return b.updatedAt.localeCompare(a.updatedAt); | |
| 79 | +} | |
| 80 | + | |
| 81 | +export async function fetchPointStandards( | |
| 82 | + query: PointStandardQuery | |
| 83 | +): Promise<{ items: PointStandard[]; totalCount: number }> { | |
| 84 | + const filtered = query.keyword | |
| 85 | + ? STANDARDS.filter((standard) => matchesKeyword(standard, query.keyword)) | |
| 86 | + : [...STANDARDS]; | |
| 87 | + | |
| 88 | + filtered.sort(byUpdatedAtDesc); | |
| 89 | + | |
| 90 | + const start = (query.page - 1) * query.pageSize; | |
| 91 | + return { | |
| 92 | + items: filtered.slice(start, start + query.pageSize), | |
| 93 | + totalCount: filtered.length, | |
| 94 | + }; | |
| 95 | +} | |
| 96 | + | |
| 97 | +function nowStamp(): string { | |
| 98 | + const now = new Date(); | |
| 99 | + const pad = (value: number) => String(value).padStart(2, '0'); | |
| 100 | + return ( | |
| 101 | + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` + | |
| 102 | + `${pad(now.getHours())}:${pad(now.getMinutes())}` | |
| 103 | + ); | |
| 104 | +} | |
| 105 | + | |
| 106 | +export async function updatePointStandard( | |
| 107 | + values: PointStandardValues | |
| 108 | +): Promise<void> { | |
| 109 | + const index = STANDARDS.findIndex((standard) => standard.id === values.id); | |
| 110 | + if (index < 0) { | |
| 111 | + throw new Error(`포인트 기준을 찾을 수 없습니다: ${values.id}`); | |
| 112 | + } | |
| 113 | + | |
| 114 | + STANDARDS[index] = { | |
| 115 | + ...STANDARDS[index], | |
| 116 | + point: values.point, | |
| 117 | + limitPoint: values.limitPoint, | |
| 118 | + isDuplicable: values.isDuplicable, | |
| 119 | + description: values.description, | |
| 120 | + isActive: values.isActive, | |
| 121 | + updatedAt: nowStamp(), | |
| 122 | + }; | |
| 123 | +} | |
| 124 | + | |
| 125 | +export async function deletePointStandard(id: string): Promise<void> { | |
| 126 | + const next = STANDARDS.filter((standard) => standard.id !== id); | |
| 127 | + if (next.length === STANDARDS.length) { | |
| 128 | + throw new Error(`포인트 기준을 찾을 수 없습니다: ${id}`); | |
| 129 | + } | |
| 130 | + STANDARDS = next; | |
| 131 | +} |
+++ lib/domain/point-account-query.ts
... | ... | @@ -0,0 +1,135 @@ |
| 1 | +export const POINT_ACCOUNTS_PATH = '/points/accumulated'; | |
| 2 | + | |
| 3 | +export type PointAccountSearchField = 'loginId' | 'studentName'; | |
| 4 | + | |
| 5 | +export const POINT_ACCOUNT_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ | |
| 6 | + value: PointAccountSearchField; | |
| 7 | + label: string; | |
| 8 | +}> = [ | |
| 9 | + { value: 'loginId', label: '아이디' }, | |
| 10 | + { value: 'studentName', label: '이름' }, | |
| 11 | +]; | |
| 12 | + | |
| 13 | +/** 필터 셀렉트의 "전체" 값 — URL에서는 생략되고 화면에서는 이 값이 선택된다. */ | |
| 14 | +export const FILTER_ALL = 'all'; | |
| 15 | + | |
| 16 | +export const GRADE_OPTIONS = [1, 2, 3] as const; | |
| 17 | +export const CLASS_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const; | |
| 18 | +export const NUMBER_OPTIONS = Array.from({ length: 30 }, (_, index) => index + 1); | |
| 19 | + | |
| 20 | +/** 상세내역 팝업 안의 페이지 크기(기획 PTS10300001). */ | |
| 21 | +export const POINT_HISTORY_PAGE_SIZE = 5; | |
| 22 | + | |
| 23 | +export const POINT_ACCOUNT_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 24 | +export type PointAccountPageSize = | |
| 25 | + (typeof POINT_ACCOUNT_PAGE_SIZE_OPTIONS)[number]; | |
| 26 | + | |
| 27 | +export const DEFAULT_POINT_ACCOUNT_SEARCH_FIELD: PointAccountSearchField = | |
| 28 | + 'loginId'; | |
| 29 | +export const DEFAULT_POINT_ACCOUNT_PAGE_SIZE: PointAccountPageSize = 10; | |
| 30 | +const DEFAULT_PAGE = 1; | |
| 31 | +const MAX_KEYWORD_LENGTH = 100; | |
| 32 | +const MAX_SCHOOL_NAME_LENGTH = 100; | |
| 33 | + | |
| 34 | +export type PointAccountQuery = { | |
| 35 | + schoolName: string; | |
| 36 | + grade: number | null; | |
| 37 | + classNo: number | null; | |
| 38 | + studentNo: number | null; | |
| 39 | + searchField: PointAccountSearchField; | |
| 40 | + keyword: string; | |
| 41 | + page: number; | |
| 42 | + pageSize: PointAccountPageSize; | |
| 43 | +}; | |
| 44 | + | |
| 45 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 46 | + | |
| 47 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 48 | + const value = params[key]; | |
| 49 | + return Array.isArray(value) ? value[0] : value; | |
| 50 | +} | |
| 51 | + | |
| 52 | +function readNumberIn( | |
| 53 | + params: RawSearchParams, | |
| 54 | + key: string, | |
| 55 | + allowed: readonly number[] | |
| 56 | +): number | null { | |
| 57 | + const value = Number(readParam(params, key)); | |
| 58 | + return Number.isInteger(value) && allowed.includes(value) ? value : null; | |
| 59 | +} | |
| 60 | + | |
| 61 | +function isPointAccountSearchField( | |
| 62 | + value: string | undefined | |
| 63 | +): value is PointAccountSearchField { | |
| 64 | + return ( | |
| 65 | + value !== undefined && | |
| 66 | + POINT_ACCOUNT_SEARCH_FIELD_OPTIONS.some((option) => option.value === value) | |
| 67 | + ); | |
| 68 | +} | |
| 69 | + | |
| 70 | +function isPointAccountPageSize(value: number): value is PointAccountPageSize { | |
| 71 | + return (POINT_ACCOUNT_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); | |
| 72 | +} | |
| 73 | + | |
| 74 | +export function parsePointAccountQuery( | |
| 75 | + searchParams: RawSearchParams | |
| 76 | +): PointAccountQuery { | |
| 77 | + const searchFieldRaw = readParam(searchParams, 'searchField'); | |
| 78 | + const keywordRaw = readParam(searchParams, 'keyword'); | |
| 79 | + const schoolNameRaw = readParam(searchParams, 'schoolName'); | |
| 80 | + const pageRaw = Number(readParam(searchParams, 'page')); | |
| 81 | + const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); | |
| 82 | + | |
| 83 | + return { | |
| 84 | + schoolName: (schoolNameRaw ?? '').trim().slice(0, MAX_SCHOOL_NAME_LENGTH), | |
| 85 | + grade: readNumberIn(searchParams, 'grade', GRADE_OPTIONS), | |
| 86 | + classNo: readNumberIn(searchParams, 'classNo', CLASS_OPTIONS), | |
| 87 | + studentNo: readNumberIn(searchParams, 'studentNo', NUMBER_OPTIONS), | |
| 88 | + searchField: isPointAccountSearchField(searchFieldRaw) | |
| 89 | + ? searchFieldRaw | |
| 90 | + : DEFAULT_POINT_ACCOUNT_SEARCH_FIELD, | |
| 91 | + keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 92 | + page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, | |
| 93 | + pageSize: isPointAccountPageSize(pageSizeRaw) | |
| 94 | + ? pageSizeRaw | |
| 95 | + : DEFAULT_POINT_ACCOUNT_PAGE_SIZE, | |
| 96 | + }; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export function buildPointAccountHref( | |
| 100 | + query: PointAccountQuery, | |
| 101 | + overrides: Partial<PointAccountQuery> = {} | |
| 102 | +): string { | |
| 103 | + const merged = { ...query, ...overrides }; | |
| 104 | + const params = new URLSearchParams(); | |
| 105 | + | |
| 106 | + if (merged.schoolName) { | |
| 107 | + params.set('schoolName', merged.schoolName); | |
| 108 | + } | |
| 109 | + if (merged.grade !== null) { | |
| 110 | + params.set('grade', String(merged.grade)); | |
| 111 | + } | |
| 112 | + if (merged.classNo !== null) { | |
| 113 | + params.set('classNo', String(merged.classNo)); | |
| 114 | + } | |
| 115 | + if (merged.studentNo !== null) { | |
| 116 | + params.set('studentNo', String(merged.studentNo)); | |
| 117 | + } | |
| 118 | + if (merged.searchField !== DEFAULT_POINT_ACCOUNT_SEARCH_FIELD) { | |
| 119 | + params.set('searchField', merged.searchField); | |
| 120 | + } | |
| 121 | + if (merged.keyword) { | |
| 122 | + params.set('keyword', merged.keyword); | |
| 123 | + } | |
| 124 | + if (merged.pageSize !== DEFAULT_POINT_ACCOUNT_PAGE_SIZE) { | |
| 125 | + params.set('pageSize', String(merged.pageSize)); | |
| 126 | + } | |
| 127 | + if (merged.page !== DEFAULT_PAGE) { | |
| 128 | + params.set('page', String(merged.page)); | |
| 129 | + } | |
| 130 | + | |
| 131 | + const queryString = params.toString(); | |
| 132 | + return queryString | |
| 133 | + ? `${POINT_ACCOUNTS_PATH}?${queryString}` | |
| 134 | + : POINT_ACCOUNTS_PATH; | |
| 135 | +} |
+++ lib/domain/point-account.ts
... | ... | @@ -0,0 +1,52 @@ |
| 1 | +export interface PointAccount { | |
| 2 | + id: string; | |
| 3 | + memberNo: string; | |
| 4 | + loginId: string; | |
| 5 | + studentName: string; | |
| 6 | + schoolName: string; | |
| 7 | + grade: number; | |
| 8 | + classNo: number; | |
| 9 | + studentNo: number; | |
| 10 | + totalPoint: number; | |
| 11 | + deductedPoint: number; | |
| 12 | + usedPoint: number; | |
| 13 | + currentPoint: number; | |
| 14 | + updatedAt: string; | |
| 15 | + siteName: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export interface PointHistoryEntry { | |
| 19 | + id: string; | |
| 20 | + title: string; | |
| 21 | + point: number; | |
| 22 | + siteName: string; | |
| 23 | + date: string; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 27 | + | |
| 28 | +export function formatOptionalValue( | |
| 29 | + value: string | number | null | undefined | |
| 30 | +): string { | |
| 31 | + return value === null || value === undefined || value === '' | |
| 32 | + ? EMPTY_FIELD_PLACEHOLDER | |
| 33 | + : String(value); | |
| 34 | +} | |
| 35 | + | |
| 36 | +export function formatPoint(value: number): string { | |
| 37 | + return value.toLocaleString('ko-KR'); | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** 취득은 `+`, 사용은 `-`를 붙여 부호가 곧 방향이 되게 한다(기획 PTS10300001 ②). */ | |
| 41 | +export function formatPointDelta(value: number): string { | |
| 42 | + const sign = value > 0 ? '+' : ''; | |
| 43 | + return `${sign}${formatPoint(value)} P`; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export function isPointEarned(value: number): boolean { | |
| 47 | + return value > 0; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export function formatGradeClassNumber(account: PointAccount): string { | |
| 51 | + return `${account.grade}학년 ${account.classNo}반 ${account.studentNo}번`; | |
| 52 | +} |
+++ lib/domain/point-standard-form.ts
... | ... | @@ -0,0 +1,89 @@ |
| 1 | +export const POINT_MAX = 1_000_000; | |
| 2 | +export const DESCRIPTION_MAX_LENGTH = 200; | |
| 3 | + | |
| 4 | +export interface PointStandardValues { | |
| 5 | + id: string; | |
| 6 | + point: number; | |
| 7 | + limitPoint: number; | |
| 8 | + isDuplicable: boolean; | |
| 9 | + description: string; | |
| 10 | + isActive: boolean; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export type PointStandardFormErrors = Partial< | |
| 14 | + Record<'id' | 'point' | 'limitPoint' | 'description', string> | |
| 15 | +>; | |
| 16 | + | |
| 17 | +export type PointStandardFormState = | |
| 18 | + | { status: 'idle' } | |
| 19 | + | { status: 'error'; message?: string; errors?: PointStandardFormErrors } | |
| 20 | + | { status: 'success' }; | |
| 21 | + | |
| 22 | +export const INITIAL_POINT_STANDARD_FORM_STATE: PointStandardFormState = { | |
| 23 | + status: 'idle', | |
| 24 | +}; | |
| 25 | + | |
| 26 | +type ValidationResult = | |
| 27 | + | { ok: true; values: PointStandardValues } | |
| 28 | + | { ok: false; errors: PointStandardFormErrors }; | |
| 29 | + | |
| 30 | +function validatePointNumber( | |
| 31 | + value: number, | |
| 32 | + label: string | |
| 33 | +): { error?: string } { | |
| 34 | + if (!Number.isInteger(value)) { | |
| 35 | + return { error: `${label}는 숫자만 입력할 수 있습니다.` }; | |
| 36 | + } | |
| 37 | + if (value < 0) { | |
| 38 | + return { error: `${label}는 0 이상으로 입력해 주세요.` }; | |
| 39 | + } | |
| 40 | + if (value > POINT_MAX) { | |
| 41 | + return { | |
| 42 | + error: `${label}는 ${POINT_MAX.toLocaleString('ko-KR')} 이하로 입력해 주세요.`, | |
| 43 | + }; | |
| 44 | + } | |
| 45 | + return {}; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export function validatePointStandard( | |
| 49 | + values: PointStandardValues | |
| 50 | +): ValidationResult { | |
| 51 | + const errors: PointStandardFormErrors = {}; | |
| 52 | + | |
| 53 | + if (!values.id.trim()) { | |
| 54 | + errors.id = '수정할 대상을 찾을 수 없습니다.'; | |
| 55 | + } | |
| 56 | + | |
| 57 | + const point = validatePointNumber(values.point, '포인트'); | |
| 58 | + if (point.error) { | |
| 59 | + errors.point = point.error; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const limitPoint = validatePointNumber(values.limitPoint, '제한포인트'); | |
| 63 | + if (limitPoint.error) { | |
| 64 | + errors.limitPoint = limitPoint.error; | |
| 65 | + } | |
| 66 | + | |
| 67 | + const description = values.description.trim(); | |
| 68 | + if (!description) { | |
| 69 | + errors.description = '이벤트 설명을 입력해 주세요.'; | |
| 70 | + } else if (description.length > DESCRIPTION_MAX_LENGTH) { | |
| 71 | + errors.description = `이벤트 설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 72 | + } | |
| 73 | + | |
| 74 | + if (Object.keys(errors).length > 0) { | |
| 75 | + return { ok: false, errors }; | |
| 76 | + } | |
| 77 | + | |
| 78 | + return { | |
| 79 | + ok: true, | |
| 80 | + values: { | |
| 81 | + id: values.id.trim(), | |
| 82 | + point: values.point, | |
| 83 | + limitPoint: values.limitPoint, | |
| 84 | + isDuplicable: values.isDuplicable, | |
| 85 | + description, | |
| 86 | + isActive: values.isActive, | |
| 87 | + }, | |
| 88 | + }; | |
| 89 | +} |
+++ lib/domain/point-standard-query.ts
... | ... | @@ -0,0 +1,66 @@ |
| 1 | +export const POINT_STANDARDS_PATH = '/points/standards'; | |
| 2 | + | |
| 3 | +export const POINT_STANDARD_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 4 | +export type PointStandardPageSize = | |
| 5 | + (typeof POINT_STANDARD_PAGE_SIZE_OPTIONS)[number]; | |
| 6 | + | |
| 7 | +export const DEFAULT_POINT_STANDARD_PAGE_SIZE: PointStandardPageSize = 10; | |
| 8 | +const DEFAULT_PAGE = 1; | |
| 9 | +const MAX_KEYWORD_LENGTH = 100; | |
| 10 | + | |
| 11 | +/** 검색어 하나가 이벤트와 이벤트 설명을 함께 훑는다(기획 PTS10100001 ①). */ | |
| 12 | +export type PointStandardQuery = { | |
| 13 | + keyword: string; | |
| 14 | + page: number; | |
| 15 | + pageSize: PointStandardPageSize; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 19 | + | |
| 20 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 21 | + const value = params[key]; | |
| 22 | + return Array.isArray(value) ? value[0] : value; | |
| 23 | +} | |
| 24 | + | |
| 25 | +function isPointStandardPageSize(value: number): value is PointStandardPageSize { | |
| 26 | + return (POINT_STANDARD_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function parsePointStandardQuery( | |
| 30 | + searchParams: RawSearchParams | |
| 31 | +): PointStandardQuery { | |
| 32 | + const keywordRaw = readParam(searchParams, 'keyword'); | |
| 33 | + const pageRaw = Number(readParam(searchParams, 'page')); | |
| 34 | + const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); | |
| 35 | + | |
| 36 | + return { | |
| 37 | + keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 38 | + page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, | |
| 39 | + pageSize: isPointStandardPageSize(pageSizeRaw) | |
| 40 | + ? pageSizeRaw | |
| 41 | + : DEFAULT_POINT_STANDARD_PAGE_SIZE, | |
| 42 | + }; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export function buildPointStandardHref( | |
| 46 | + query: PointStandardQuery, | |
| 47 | + overrides: Partial<PointStandardQuery> = {} | |
| 48 | +): string { | |
| 49 | + const merged = { ...query, ...overrides }; | |
| 50 | + const params = new URLSearchParams(); | |
| 51 | + | |
| 52 | + if (merged.keyword) { | |
| 53 | + params.set('keyword', merged.keyword); | |
| 54 | + } | |
| 55 | + if (merged.pageSize !== DEFAULT_POINT_STANDARD_PAGE_SIZE) { | |
| 56 | + params.set('pageSize', String(merged.pageSize)); | |
| 57 | + } | |
| 58 | + if (merged.page !== DEFAULT_PAGE) { | |
| 59 | + params.set('page', String(merged.page)); | |
| 60 | + } | |
| 61 | + | |
| 62 | + const queryString = params.toString(); | |
| 63 | + return queryString | |
| 64 | + ? `${POINT_STANDARDS_PATH}?${queryString}` | |
| 65 | + : POINT_STANDARDS_PATH; | |
| 66 | +} |
+++ lib/domain/point-standard.ts
... | ... | @@ -0,0 +1,40 @@ |
| 1 | +export interface PointStandard { | |
| 2 | + id: string; | |
| 3 | + eventCode: string; | |
| 4 | + point: number; | |
| 5 | + limitPoint: number; | |
| 6 | + isDuplicable: boolean; | |
| 7 | + siteCode: string; | |
| 8 | + description: string; | |
| 9 | + isActive: boolean; | |
| 10 | + updatedAt: string; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 14 | + | |
| 15 | +/** 제한포인트가 0이면 일 누적 제한이 없다(기획 PTS10100002_p ③). */ | |
| 16 | +export const UNLIMITED_LIMIT_POINT = 0; | |
| 17 | + | |
| 18 | +export function formatOptionalValue( | |
| 19 | + value: string | number | null | undefined | |
| 20 | +): string { | |
| 21 | + return value === null || value === undefined || value === '' | |
| 22 | + ? EMPTY_FIELD_PLACEHOLDER | |
| 23 | + : String(value); | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function formatPoint(value: number): string { | |
| 27 | + return value.toLocaleString('ko-KR'); | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function formatLimitPoint(value: number): string { | |
| 31 | + return value === UNLIMITED_LIMIT_POINT ? '제한 없음' : formatPoint(value); | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function formatDuplicableLabel(isDuplicable: boolean): string { | |
| 35 | + return isDuplicable ? '중복 가능' : '중복 불가'; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function formatActiveLabel(isActive: boolean): string { | |
| 39 | + return isActive ? '사용' : '사용안함'; | |
| 40 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?