feat: 시스템관리 가맹점 관리 (mock)
기획 MCH_001 목록 / MCH_002_p 등록 / MCH_003_p 수정. 관리자용 가맹점 API가 없어 (mngr 도메인에 컨트롤러가 없고 common/qrauth의 결제 조회용 VO는 5개 필드뿐) Repository를 mock으로 둔다 — 화면·검증·액션은 실제 구조다. 목록은 업종·사용여부 필터와 가맹점명/사업자번호/주소 검색, 최근등록순 정렬, 엑셀다운로드(CSV)까지. 가맹점코드는 저장 시 서버가 만들고 수정에서는 읽기 전용이다. 시안에 있으나 정할 것이 남은 두 가지는 자리를 두고 표시해 둔다 — 주소 검색은 연동할 서비스가 없어 직접 입력으로 받고, 엑셀등록은 양식·검증 규칙이 없어 비활성이다. Co-Authored-By: Claude Opus 5
@6284fb74c5efdc7a4e81e2abb5b638b13e7213c1
+++ app/(protected)/(basic)/system/merchants/_actions.ts
... | ... | @@ -0,0 +1,76 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + createMerchant, | |
| 7 | + deleteMerchant, | |
| 8 | + updateMerchant, | |
| 9 | +} from '@/lib/data/repositories/merchant-repository'; | |
| 10 | +import { | |
| 11 | + validateMerchant, | |
| 12 | + type MerchantFormState, | |
| 13 | +} from '@/lib/domain/merchant-form'; | |
| 14 | +import { MERCHANTS_PATH } from '@/lib/domain/merchant-query'; | |
| 15 | + | |
| 16 | +const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 17 | +const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 18 | + | |
| 19 | +function readString(formData: FormData, key: string): string { | |
| 20 | + const value = formData.get(key); | |
| 21 | + return typeof value === 'string' ? value : ''; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export async function saveMerchantAction( | |
| 25 | + _prevState: MerchantFormState, | |
| 26 | + formData: FormData | |
| 27 | +): Promise<MerchantFormState> { | |
| 28 | + await verifySession(); | |
| 29 | + | |
| 30 | + const validation = validateMerchant({ | |
| 31 | + id: readString(formData, 'id'), | |
| 32 | + brandName: readString(formData, 'brandName'), | |
| 33 | + name: readString(formData, 'name'), | |
| 34 | + categoryCode: readString(formData, 'categoryCode'), | |
| 35 | + businessNumber: readString(formData, 'businessNumber'), | |
| 36 | + ownerName: readString(formData, 'ownerName'), | |
| 37 | + phoneNumber: readString(formData, 'phoneNumber'), | |
| 38 | + address: readString(formData, 'address'), | |
| 39 | + addressDetail: readString(formData, 'addressDetail'), | |
| 40 | + latitude: readString(formData, 'latitude'), | |
| 41 | + longitude: readString(formData, 'longitude'), | |
| 42 | + isVisible: readString(formData, 'isVisible') === 'Y', | |
| 43 | + memo: readString(formData, 'memo'), | |
| 44 | + }); | |
| 45 | + if (!validation.ok) { | |
| 46 | + return { status: 'error', errors: validation.errors }; | |
| 47 | + } | |
| 48 | + | |
| 49 | + try { | |
| 50 | + if (validation.values.id) { | |
| 51 | + await updateMerchant(validation.values); | |
| 52 | + } else { | |
| 53 | + await createMerchant(validation.values); | |
| 54 | + } | |
| 55 | + } catch { | |
| 56 | + return { status: 'error', message: SAVE_FAILED_MESSAGE }; | |
| 57 | + } | |
| 58 | + | |
| 59 | + revalidatePath(MERCHANTS_PATH); | |
| 60 | + return { status: 'success' }; | |
| 61 | +} | |
| 62 | + | |
| 63 | +export async function deleteMerchantAction( | |
| 64 | + id: string | |
| 65 | +): Promise<{ status: 'success' } | { status: 'error'; message: string }> { | |
| 66 | + await verifySession(); | |
| 67 | + | |
| 68 | + try { | |
| 69 | + await deleteMerchant(id); | |
| 70 | + } catch { | |
| 71 | + return { status: 'error', message: DELETE_FAILED_MESSAGE }; | |
| 72 | + } | |
| 73 | + | |
| 74 | + revalidatePath(MERCHANTS_PATH); | |
| 75 | + return { status: 'success' }; | |
| 76 | +} |
+++ app/(protected)/(basic)/system/merchants/_components/merchant-filter.module.scss
... | ... | @@ -0,0 +1,12 @@ |
| 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 | +.select { | |
| 11 | + inline-size: 16rem; | |
| 12 | +} |
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
... | ... | @@ -0,0 +1,308 @@ |
| 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 { FoxSelect } from '@fox/core/components/fox-select'; | |
| 12 | +import { submitFormAction } from '@/app/_hooks/submit-form-action'; | |
| 13 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 14 | +import { | |
| 15 | + MERCHANT_CATEGORY_OPTIONS, | |
| 16 | + type Merchant, | |
| 17 | +} from '@/lib/domain/merchant'; | |
| 18 | +import { | |
| 19 | + ADDRESS_DETAIL_MAX_LENGTH, | |
| 20 | + BRAND_MAX_LENGTH, | |
| 21 | + INITIAL_MERCHANT_FORM_STATE, | |
| 22 | + MEMO_MAX_LENGTH, | |
| 23 | + NAME_MAX_LENGTH, | |
| 24 | + OWNER_MAX_LENGTH, | |
| 25 | +} from '@/lib/domain/merchant-form'; | |
| 26 | +import { saveMerchantAction } from '../_actions'; | |
| 27 | +import styles from './merchant-form.module.scss'; | |
| 28 | + | |
| 29 | +interface MerchantFormModalProps { | |
| 30 | + merchant?: Merchant; | |
| 31 | + onClose: () => void; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function MerchantFormModal({ merchant, onClose }: MerchantFormModalProps) { | |
| 35 | + const isEdit = Boolean(merchant); | |
| 36 | + const { showToast } = useFeedback(); | |
| 37 | + const formRef = useRef<HTMLFormElement>(null); | |
| 38 | + const [state, formAction, isPending] = useActionState( | |
| 39 | + saveMerchantAction, | |
| 40 | + INITIAL_MERCHANT_FORM_STATE | |
| 41 | + ); | |
| 42 | + | |
| 43 | + const [categoryCode, setCategoryCode] = useState(merchant?.categoryCode ?? ''); | |
| 44 | + const [isVisible, setIsVisible] = useState(merchant?.isVisible ?? true); | |
| 45 | + // 주소·좌표는 검색 결과가 채운다(기획 ③ Read Only). | |
| 46 | + const [address, setAddress] = useState(merchant?.address ?? ''); | |
| 47 | + const [latitude, setLatitude] = useState(merchant?.latitude ?? ''); | |
| 48 | + const [longitude, setLongitude] = useState(merchant?.longitude ?? ''); | |
| 49 | + | |
| 50 | + useEffect(() => { | |
| 51 | + if (state.status === 'success') { | |
| 52 | + showToast({ | |
| 53 | + variant: 'success', | |
| 54 | + message: isEdit ? '가맹점을 수정했습니다.' : '가맹점을 등록했습니다.', | |
| 55 | + }); | |
| 56 | + onClose(); | |
| 57 | + } | |
| 58 | + }, [state, showToast, onClose, isEdit]); | |
| 59 | + | |
| 60 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 61 | + | |
| 62 | + // TODO(기획): 주소 검색 서비스가 정해지지 않았다. 연동 전까지 직접 입력으로 받는다. | |
| 63 | + function searchAddress() { | |
| 64 | + const picked = window.prompt('주소를 입력하세요.', address); | |
| 65 | + if (picked === null) { | |
| 66 | + return; | |
| 67 | + } | |
| 68 | + setAddress(picked.trim()); | |
| 69 | + setLatitude(''); | |
| 70 | + setLongitude(''); | |
| 71 | + } | |
| 72 | + | |
| 73 | + return ( | |
| 74 | + <FoxModal | |
| 75 | + open | |
| 76 | + size="md" | |
| 77 | + title={isEdit ? '가맹점 수정' : '가맹점 등록'} | |
| 78 | + onClose={onClose} | |
| 79 | + actions={ | |
| 80 | + <> | |
| 81 | + <FoxButton type="default" size="md" label="취소" onAction={onClose} /> | |
| 82 | + <FoxButton | |
| 83 | + type="primary" | |
| 84 | + size="md" | |
| 85 | + label={isPending ? '저장 중...' : isEdit ? '수정' : '등록'} | |
| 86 | + disabled={isPending} | |
| 87 | + onAction={() => formRef.current?.requestSubmit()} | |
| 88 | + /> | |
| 89 | + </> | |
| 90 | + } | |
| 91 | + > | |
| 92 | + <form | |
| 93 | + ref={formRef} | |
| 94 | + onSubmit={(event) => submitFormAction(event, formAction)} | |
| 95 | + className={styles.fields} | |
| 96 | + > | |
| 97 | + <input type="hidden" name="id" value={merchant?.id ?? ''} /> | |
| 98 | + <input type="hidden" name="categoryCode" value={categoryCode} /> | |
| 99 | + <input type="hidden" name="isVisible" value={isVisible ? 'Y' : 'N'} /> | |
| 100 | + <input type="hidden" name="address" value={address} /> | |
| 101 | + <input type="hidden" name="latitude" value={latitude} /> | |
| 102 | + <input type="hidden" name="longitude" value={longitude} /> | |
| 103 | + | |
| 104 | + {/* 등록 시에는 코드가 아직 없다 — 저장할 때 서버가 만든다. */} | |
| 105 | + {isEdit && ( | |
| 106 | + <div className={styles.field}> | |
| 107 | + <FoxInput | |
| 108 | + size="md" | |
| 109 | + label="가맹점코드" | |
| 110 | + value={merchant?.code ?? ''} | |
| 111 | + readOnly | |
| 112 | + state="view" | |
| 113 | + /> | |
| 114 | + </div> | |
| 115 | + )} | |
| 116 | + | |
| 117 | + <div className={styles.field}> | |
| 118 | + <FoxInput | |
| 119 | + size="md" | |
| 120 | + name="brandName" | |
| 121 | + label="브랜드명" | |
| 122 | + defaultValue={merchant?.brandName ?? ''} | |
| 123 | + placeholder="브랜드명을 입력하세요." | |
| 124 | + maxLength={BRAND_MAX_LENGTH} | |
| 125 | + /> | |
| 126 | + </div> | |
| 127 | + | |
| 128 | + <div className={styles.field}> | |
| 129 | + <FoxInput | |
| 130 | + size="md" | |
| 131 | + name="name" | |
| 132 | + label="가맹점명" | |
| 133 | + requirement="required" | |
| 134 | + defaultValue={merchant?.name ?? ''} | |
| 135 | + placeholder="가맹점명을 입력하세요." | |
| 136 | + maxLength={NAME_MAX_LENGTH} | |
| 137 | + invalid={Boolean(errors.name)} | |
| 138 | + message={errors.name} | |
| 139 | + /> | |
| 140 | + </div> | |
| 141 | + | |
| 142 | + <div className={styles.field}> | |
| 143 | + <FoxSelect | |
| 144 | + size="md" | |
| 145 | + label="업종" | |
| 146 | + requirement="required" | |
| 147 | + options={MERCHANT_CATEGORY_OPTIONS.map((option) => ({ | |
| 148 | + value: option.value, | |
| 149 | + label: option.label, | |
| 150 | + }))} | |
| 151 | + value={categoryCode} | |
| 152 | + onValueChange={setCategoryCode} | |
| 153 | + placeholder="선택" | |
| 154 | + error={Boolean(errors.categoryCode)} | |
| 155 | + hint={errors.categoryCode} | |
| 156 | + /> | |
| 157 | + </div> | |
| 158 | + | |
| 159 | + <div className={styles.field}> | |
| 160 | + <div className={styles.pair}> | |
| 161 | + <FoxInput | |
| 162 | + size="md" | |
| 163 | + name="businessNumber" | |
| 164 | + label="사업자등록번호" | |
| 165 | + defaultValue={merchant?.businessNumber ?? ''} | |
| 166 | + placeholder="000-00-00000" | |
| 167 | + invalid={Boolean(errors.businessNumber)} | |
| 168 | + message={errors.businessNumber} | |
| 169 | + /> | |
| 170 | + <FoxInput | |
| 171 | + size="md" | |
| 172 | + name="ownerName" | |
| 173 | + label="대표자명" | |
| 174 | + defaultValue={merchant?.ownerName ?? ''} | |
| 175 | + placeholder="대표자명을 입력하세요." | |
| 176 | + maxLength={OWNER_MAX_LENGTH} | |
| 177 | + /> | |
| 178 | + </div> | |
| 179 | + </div> | |
| 180 | + | |
| 181 | + <div className={styles.field}> | |
| 182 | + <FoxInput | |
| 183 | + size="md" | |
| 184 | + name="phoneNumber" | |
| 185 | + label="전화번호" | |
| 186 | + requirement="required" | |
| 187 | + defaultValue={merchant?.phoneNumber ?? ''} | |
| 188 | + placeholder="가게 전화번호를 입력하세요." | |
| 189 | + invalid={Boolean(errors.phoneNumber)} | |
| 190 | + message={errors.phoneNumber} | |
| 191 | + /> | |
| 192 | + </div> | |
| 193 | + | |
| 194 | + <div className={styles.field}> | |
| 195 | + <FoxFormLabel as="span" requirement="required"> | |
| 196 | + 주소 | |
| 197 | + </FoxFormLabel> | |
| 198 | + <div className={styles.addressRow}> | |
| 199 | + <FoxInput | |
| 200 | + size="md" | |
| 201 | + aria-label="주소" | |
| 202 | + value={address} | |
| 203 | + readOnly | |
| 204 | + state="view" | |
| 205 | + placeholder="주소 검색을 진행해주세요." | |
| 206 | + /> | |
| 207 | + <FoxButton | |
| 208 | + type="secondary" | |
| 209 | + size="md" | |
| 210 | + label="주소 검색" | |
| 211 | + onAction={searchAddress} | |
| 212 | + /> | |
| 213 | + </div> | |
| 214 | + <FoxInput | |
| 215 | + size="md" | |
| 216 | + name="addressDetail" | |
| 217 | + aria-label="상세주소" | |
| 218 | + defaultValue={merchant?.addressDetail ?? ''} | |
| 219 | + placeholder="상세주소를 입력하세요." | |
| 220 | + maxLength={ADDRESS_DETAIL_MAX_LENGTH} | |
| 221 | + /> | |
| 222 | + {errors.address && ( | |
| 223 | + <FoxHelperText type="danger" message={errors.address} /> | |
| 224 | + )} | |
| 225 | + </div> | |
| 226 | + | |
| 227 | + <div className={styles.field}> | |
| 228 | + <FoxFormLabel as="span">위도 / 경도</FoxFormLabel> | |
| 229 | + <div className={styles.pair}> | |
| 230 | + <FoxInput | |
| 231 | + size="md" | |
| 232 | + aria-label="위도" | |
| 233 | + value={latitude} | |
| 234 | + readOnly | |
| 235 | + state="view" | |
| 236 | + placeholder="주소 검색 시 자동 입력" | |
| 237 | + /> | |
| 238 | + <FoxInput | |
| 239 | + size="md" | |
| 240 | + aria-label="경도" | |
| 241 | + value={longitude} | |
| 242 | + readOnly | |
| 243 | + state="view" | |
| 244 | + placeholder="주소 검색 시 자동 입력" | |
| 245 | + /> | |
| 246 | + </div> | |
| 247 | + </div> | |
| 248 | + | |
| 249 | + <div className={styles.field}> | |
| 250 | + <FoxFormLabel as="span" requirement="required"> | |
| 251 | + 사용여부 | |
| 252 | + </FoxFormLabel> | |
| 253 | + <FoxRadioGroup name="visibility" size="md" label="사용여부"> | |
| 254 | + <FoxRadio | |
| 255 | + value="Y" | |
| 256 | + label="노출" | |
| 257 | + checked={isVisible} | |
| 258 | + onChange={() => setIsVisible(true)} | |
| 259 | + /> | |
| 260 | + <FoxRadio | |
| 261 | + value="N" | |
| 262 | + label="미노출" | |
| 263 | + checked={!isVisible} | |
| 264 | + onChange={() => setIsVisible(false)} | |
| 265 | + /> | |
| 266 | + </FoxRadioGroup> | |
| 267 | + </div> | |
| 268 | + | |
| 269 | + <div className={styles.field}> | |
| 270 | + <FoxInput | |
| 271 | + size="md" | |
| 272 | + name="memo" | |
| 273 | + label="메모" | |
| 274 | + defaultValue={merchant?.memo ?? ''} | |
| 275 | + placeholder="관리자 메모 (사용자에게 노출되지 않음)" | |
| 276 | + maxLength={MEMO_MAX_LENGTH} | |
| 277 | + /> | |
| 278 | + </div> | |
| 279 | + | |
| 280 | + {isEdit && ( | |
| 281 | + <div className={styles.field}> | |
| 282 | + <FoxFormLabel as="span">등록일 / 최종수정일</FoxFormLabel> | |
| 283 | + <div className={styles.pair}> | |
| 284 | + <FoxInput | |
| 285 | + size="md" | |
| 286 | + aria-label="등록일" | |
| 287 | + value={merchant?.createdAt ?? ''} | |
| 288 | + readOnly | |
| 289 | + state="view" | |
| 290 | + /> | |
| 291 | + <FoxInput | |
| 292 | + size="md" | |
| 293 | + aria-label="최종수정일" | |
| 294 | + value={merchant?.updatedAt ?? ''} | |
| 295 | + readOnly | |
| 296 | + state="view" | |
| 297 | + /> | |
| 298 | + </div> | |
| 299 | + </div> | |
| 300 | + )} | |
| 301 | + | |
| 302 | + {state.status === 'error' && state.message && ( | |
| 303 | + <FoxHelperText type="danger" message={state.message} /> | |
| 304 | + )} | |
| 305 | + </form> | |
| 306 | + </FoxModal> | |
| 307 | + ); | |
| 308 | +} |
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form.module.scss
... | ... | @@ -0,0 +1,39 @@ |
| 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(5); | |
| 15 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 16 | +} | |
| 17 | + | |
| 18 | +// 사업자등록번호/대표자명, 위도/경도, 등록일/최종수정일처럼 두 칸이 나란히 서는 줄. | |
| 19 | +.pair { | |
| 20 | + display: flex; | |
| 21 | + gap: fox.gap(4); | |
| 22 | + | |
| 23 | + > * { | |
| 24 | + flex: 1 1 0; | |
| 25 | + min-inline-size: 0; | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +// 주소 입력 + [주소 검색] 버튼. | |
| 30 | +.addressRow { | |
| 31 | + display: flex; | |
| 32 | + gap: fox.gap(3); | |
| 33 | + align-items: flex-end; | |
| 34 | + | |
| 35 | + > :first-child { | |
| 36 | + flex: 1 1 0; | |
| 37 | + min-inline-size: 0; | |
| 38 | + } | |
| 39 | +} |
+++ app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
... | ... | @@ -0,0 +1,257 @@ |
| 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 { | |
| 7 | + FoxListContainer, | |
| 8 | + type FoxListColumn, | |
| 9 | +} from '@fox/core/components/fox-list-container'; | |
| 10 | +import { FoxSelect } from '@fox/core/components/fox-select'; | |
| 11 | +import { FoxSelectText } from '@fox/core/components/fox-select-text'; | |
| 12 | +import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; | |
| 13 | +import { | |
| 14 | + MERCHANT_CATEGORY_OPTIONS, | |
| 15 | + formatAddressSummary, | |
| 16 | + formatCategoryLabel, | |
| 17 | + formatOptionalValue, | |
| 18 | + formatVisibilityLabel, | |
| 19 | + type Merchant, | |
| 20 | +} from '@/lib/domain/merchant'; | |
| 21 | +import { | |
| 22 | + MERCHANTS_EXCEL_PATH, | |
| 23 | + MERCHANTS_PATH, | |
| 24 | + MERCHANT_PAGE_SIZE_OPTIONS, | |
| 25 | + MERCHANT_SEARCH_FIELD_OPTIONS, | |
| 26 | + MERCHANT_VISIBILITY_OPTIONS, | |
| 27 | + buildMerchantHref, | |
| 28 | + type MerchantPageSize, | |
| 29 | + type MerchantQuery, | |
| 30 | + type MerchantSearchField, | |
| 31 | +} from '@/lib/domain/merchant-query'; | |
| 32 | +import { MerchantFormModal } from './merchant-form-modal'; | |
| 33 | +import { MerchantRowActions } from './merchant-row-actions'; | |
| 34 | +import styles from './merchant-filter.module.scss'; | |
| 35 | + | |
| 36 | +interface MerchantListProps { | |
| 37 | + items: Merchant[]; | |
| 38 | + query: MerchantQuery; | |
| 39 | + currentPage: number; | |
| 40 | + totalPages: number; | |
| 41 | + totalCount: number; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** 기획 MCH_001 ④ — 정렬은 최근등록순 하나뿐이라 선택지가 아니라 표시다. */ | |
| 45 | +const SORT_OPTIONS = [{ value: 'recent', label: '최근등록순' }]; | |
| 46 | + | |
| 47 | +export function MerchantList({ | |
| 48 | + items, | |
| 49 | + query, | |
| 50 | + currentPage, | |
| 51 | + totalPages, | |
| 52 | + totalCount, | |
| 53 | +}: MerchantListProps) { | |
| 54 | + const router = useRouter(); | |
| 55 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 56 | + | |
| 57 | + function go(patch: Partial<MerchantQuery>) { | |
| 58 | + router.replace(buildMerchantHref(query, { ...patch, page: 1 })); | |
| 59 | + } | |
| 60 | + | |
| 61 | + const columns: FoxListColumn<Merchant>[] = [ | |
| 62 | + { | |
| 63 | + key: 'no', | |
| 64 | + header: '번호', | |
| 65 | + width: 80, | |
| 66 | + render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, | |
| 67 | + }, | |
| 68 | + { key: 'code', header: '가맹점코드', width: 140 }, | |
| 69 | + { | |
| 70 | + key: 'brandName', | |
| 71 | + header: '브랜드명', | |
| 72 | + width: 120, | |
| 73 | + render: (row) => formatOptionalValue(row.brandName), | |
| 74 | + }, | |
| 75 | + { key: 'name', header: '가맹점명', width: 240, emphasis: true }, | |
| 76 | + { | |
| 77 | + key: 'categoryCode', | |
| 78 | + header: '업종', | |
| 79 | + width: 100, | |
| 80 | + render: (row) => formatCategoryLabel(row.categoryCode), | |
| 81 | + }, | |
| 82 | + { | |
| 83 | + key: 'phoneNumber', | |
| 84 | + header: '전화번호', | |
| 85 | + width: 160, | |
| 86 | + render: (row) => formatOptionalValue(row.phoneNumber), | |
| 87 | + }, | |
| 88 | + { | |
| 89 | + key: 'address', | |
| 90 | + header: '주소', | |
| 91 | + width: 280, | |
| 92 | + render: formatAddressSummary, | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + key: 'isVisible', | |
| 96 | + header: '사용여부', | |
| 97 | + width: 120, | |
| 98 | + render: (row) => ( | |
| 99 | + <FoxStatusIndicator | |
| 100 | + type={row.isVisible ? 'success' : 'danger'} | |
| 101 | + label={formatVisibilityLabel(row.isVisible)} | |
| 102 | + /> | |
| 103 | + ), | |
| 104 | + }, | |
| 105 | + { | |
| 106 | + key: 'createdAt', | |
| 107 | + header: '등록일', | |
| 108 | + width: 140, | |
| 109 | + render: (row) => formatOptionalValue(row.createdAt), | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + key: 'actions', | |
| 113 | + header: '관리', | |
| 114 | + width: 120, | |
| 115 | + render: (row) => <MerchantRowActions merchant={row} />, | |
| 116 | + }, | |
| 117 | + ]; | |
| 118 | + | |
| 119 | + return ( | |
| 120 | + <> | |
| 121 | + <FoxListContainer<Merchant> | |
| 122 | + title="가맹점 목록" | |
| 123 | + breadcrumb={[ | |
| 124 | + { label: '홈', href: '/' }, | |
| 125 | + { label: '시스템관리' }, | |
| 126 | + { label: '가맹점 관리' }, | |
| 127 | + ]} | |
| 128 | + caption="가맹점 목록" | |
| 129 | + columns={columns} | |
| 130 | + rows={items} | |
| 131 | + rowKey={(row) => row.id} | |
| 132 | + totalCount={totalCount} | |
| 133 | + emptyMessage="검색 결과가 없습니다." | |
| 134 | + sorts={ | |
| 135 | + <> | |
| 136 | + <FoxSelectText | |
| 137 | + size="sm" | |
| 138 | + ariaLabel="정렬" | |
| 139 | + options={SORT_OPTIONS} | |
| 140 | + value="recent" | |
| 141 | + /> | |
| 142 | + <FoxSelectText | |
| 143 | + size="sm" | |
| 144 | + ariaLabel="페이지 크기" | |
| 145 | + options={MERCHANT_PAGE_SIZE_OPTIONS.map((size) => ({ | |
| 146 | + value: String(size), | |
| 147 | + label: `${size}개씩`, | |
| 148 | + }))} | |
| 149 | + value={String(query.pageSize)} | |
| 150 | + onValueChange={(value) => | |
| 151 | + go({ pageSize: Number(value) as MerchantPageSize }) | |
| 152 | + } | |
| 153 | + /> | |
| 154 | + </> | |
| 155 | + } | |
| 156 | + filter={ | |
| 157 | + <div className={styles.filter}> | |
| 158 | + <div className={styles.select}> | |
| 159 | + <FoxSelect | |
| 160 | + size="md" | |
| 161 | + ariaLabel="업종" | |
| 162 | + options={[ | |
| 163 | + { value: '', label: '업종: 전체' }, | |
| 164 | + ...MERCHANT_CATEGORY_OPTIONS.map((option) => ({ | |
| 165 | + value: option.value, | |
| 166 | + label: option.label, | |
| 167 | + })), | |
| 168 | + ]} | |
| 169 | + value={query.category} | |
| 170 | + onValueChange={(value) => go({ category: value })} | |
| 171 | + /> | |
| 172 | + </div> | |
| 173 | + <div className={styles.select}> | |
| 174 | + <FoxSelect | |
| 175 | + size="md" | |
| 176 | + ariaLabel="사용여부" | |
| 177 | + options={[ | |
| 178 | + { value: '', label: '사용여부: 전체' }, | |
| 179 | + ...MERCHANT_VISIBILITY_OPTIONS.map((option) => ({ | |
| 180 | + value: option.value, | |
| 181 | + label: option.label, | |
| 182 | + })), | |
| 183 | + ]} | |
| 184 | + value={query.visibility} | |
| 185 | + onValueChange={(value) => go({ visibility: value })} | |
| 186 | + /> | |
| 187 | + </div> | |
| 188 | + </div> | |
| 189 | + } | |
| 190 | + searchFields={MERCHANT_SEARCH_FIELD_OPTIONS.map((option) => ({ | |
| 191 | + value: option.value, | |
| 192 | + label: option.label, | |
| 193 | + }))} | |
| 194 | + searchField={query.searchField} | |
| 195 | + keyword={query.keyword} | |
| 196 | + searchPlaceholder="검색어를 입력하세요." | |
| 197 | + toolbarActions={ | |
| 198 | + <> | |
| 199 | + {/* TODO(기획): 엑셀등록 양식·검증 규칙이 정해지지 않았다. 규칙이 나오면 금칙어 | |
| 200 | + 화면처럼 업로드 모달을 붙인다. */} | |
| 201 | + <FoxButton | |
| 202 | + type="secondary" | |
| 203 | + size="md" | |
| 204 | + leadingIcon={ | |
| 205 | + <i className="fox-ico fox-ico-UploadSimple" aria-hidden="true" /> | |
| 206 | + } | |
| 207 | + label="엑셀등록" | |
| 208 | + disabled | |
| 209 | + /> | |
| 210 | + {/* 파일 응답이라 라우터가 아니라 네이티브 GET 폼으로 보낸다. */} | |
| 211 | + <form action={MERCHANTS_EXCEL_PATH} method="get"> | |
| 212 | + <FoxButton | |
| 213 | + type="default" | |
| 214 | + size="md" | |
| 215 | + htmlType="submit" | |
| 216 | + label="엑셀다운로드" | |
| 217 | + /> | |
| 218 | + </form> | |
| 219 | + <FoxButton | |
| 220 | + type="primary" | |
| 221 | + size="md" | |
| 222 | + leadingIcon={ | |
| 223 | + <i className="fox-ico fox-ico-Plus" aria-hidden="true" /> | |
| 224 | + } | |
| 225 | + label="등록" | |
| 226 | + onAction={() => setIsCreateOpen(true)} | |
| 227 | + /> | |
| 228 | + <FoxButton | |
| 229 | + type="default" | |
| 230 | + size="md" | |
| 231 | + label="초기화" | |
| 232 | + onAction={() => router.replace(MERCHANTS_PATH)} | |
| 233 | + /> | |
| 234 | + </> | |
| 235 | + } | |
| 236 | + page={currentPage} | |
| 237 | + totalPages={totalPages} | |
| 238 | + buildHref={(patch) => | |
| 239 | + buildMerchantHref(query, { page: Number(patch.page ?? 1) }) | |
| 240 | + } | |
| 241 | + onQueryChange={(patch) => { | |
| 242 | + if (patch.keyword !== undefined || patch.searchField !== undefined) { | |
| 243 | + go({ | |
| 244 | + keyword: String(patch.keyword ?? ''), | |
| 245 | + searchField: (patch.searchField ?? | |
| 246 | + query.searchField) as MerchantSearchField, | |
| 247 | + }); | |
| 248 | + } | |
| 249 | + }} | |
| 250 | + /> | |
| 251 | + | |
| 252 | + {isCreateOpen && ( | |
| 253 | + <MerchantFormModal onClose={() => setIsCreateOpen(false)} /> | |
| 254 | + )} | |
| 255 | + </> | |
| 256 | + ); | |
| 257 | +} |
+++ app/(protected)/(basic)/system/merchants/_components/merchant-row-actions.tsx
... | ... | @@ -0,0 +1,73 @@ |
| 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 { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 8 | +import type { Merchant } from '@/lib/domain/merchant'; | |
| 9 | +import { deleteMerchantAction } from '../_actions'; | |
| 10 | +import { MerchantFormModal } from './merchant-form-modal'; | |
| 11 | + | |
| 12 | +interface MerchantRowActionsProps { | |
| 13 | + merchant: Merchant; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function MerchantRowActions({ merchant }: MerchantRowActionsProps) { | |
| 17 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 18 | + const [isEditOpen, setIsEditOpen] = useState(false); | |
| 19 | + const [isDeleting, startDeleting] = useTransition(); | |
| 20 | + | |
| 21 | + function runDelete() { | |
| 22 | + hideAlert(); | |
| 23 | + startDeleting(async () => { | |
| 24 | + const result = await deleteMerchantAction(merchant.id); | |
| 25 | + if (result.status === 'error') { | |
| 26 | + showToast({ variant: 'danger', message: result.message }); | |
| 27 | + return; | |
| 28 | + } | |
| 29 | + showToast({ variant: 'success', message: '가맹점을 삭제했습니다.' }); | |
| 30 | + }); | |
| 31 | + } | |
| 32 | + | |
| 33 | + function confirmDelete() { | |
| 34 | + showAlert({ | |
| 35 | + variant: 'danger', | |
| 36 | + title: '가맹점을 삭제하시겠습니까?', | |
| 37 | + message: `"${merchant.name}"을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 38 | + actions: ( | |
| 39 | + <FoxButtonGroup size="md"> | |
| 40 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 41 | + <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} /> | |
| 42 | + </FoxButtonGroup> | |
| 43 | + ), | |
| 44 | + }); | |
| 45 | + } | |
| 46 | + | |
| 47 | + return ( | |
| 48 | + <FoxButtonGroup size="sm" align="center"> | |
| 49 | + <FoxIconButton | |
| 50 | + type="default" | |
| 51 | + size="sm" | |
| 52 | + icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />} | |
| 53 | + label="수정" | |
| 54 | + onAction={() => setIsEditOpen(true)} | |
| 55 | + /> | |
| 56 | + <FoxIconButton | |
| 57 | + type="default" | |
| 58 | + size="sm" | |
| 59 | + icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />} | |
| 60 | + label={isDeleting ? '삭제 중' : '삭제'} | |
| 61 | + disabled={isDeleting} | |
| 62 | + onAction={confirmDelete} | |
| 63 | + /> | |
| 64 | + | |
| 65 | + {isEditOpen && ( | |
| 66 | + <MerchantFormModal | |
| 67 | + merchant={merchant} | |
| 68 | + onClose={() => setIsEditOpen(false)} | |
| 69 | + /> | |
| 70 | + )} | |
| 71 | + </FoxButtonGroup> | |
| 72 | + ); | |
| 73 | +} |
+++ app/(protected)/(basic)/system/merchants/excel/route.ts
... | ... | @@ -0,0 +1,66 @@ |
| 1 | +import { verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { fetchAllMerchants } from '@/lib/data/repositories/merchant-repository'; | |
| 3 | +import { formatCategoryLabel, formatVisibilityLabel } from '@/lib/domain/merchant'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 가맹점 목록 엑셀 다운로드(기획 MCH_001 ②) — 검색 조건과 무관하게 전체다. | |
| 7 | + * | |
| 8 | + * TODO(백엔드): 서버가 xlsx를 만들어 주면 학생 목록처럼 스트림 중계로 바꾼다. 지금은 백엔드 | |
| 9 | + * 자체가 없어 여기서 CSV를 만든다. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +const HEADERS = [ | |
| 13 | + '번호', | |
| 14 | + '가맹점코드', | |
| 15 | + '브랜드명', | |
| 16 | + '가맹점명', | |
| 17 | + '업종', | |
| 18 | + '사업자등록번호', | |
| 19 | + '대표자명', | |
| 20 | + '전화번호', | |
| 21 | + '주소', | |
| 22 | + '상세주소', | |
| 23 | + '사용여부', | |
| 24 | + '등록일', | |
| 25 | +]; | |
| 26 | + | |
| 27 | +/** 쉼표·따옴표·줄바꿈이 든 값은 따옴표로 감싸고 내부 따옴표는 두 번 쓴다(RFC 4180). */ | |
| 28 | +function toCsvCell(value: string): string { | |
| 29 | + return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export async function GET() { | |
| 33 | + await verifySession(); | |
| 34 | + | |
| 35 | + const merchants = await fetchAllMerchants(); | |
| 36 | + | |
| 37 | + const rows = merchants.map((merchant, index) => | |
| 38 | + [ | |
| 39 | + String(index + 1), | |
| 40 | + merchant.code, | |
| 41 | + merchant.brandName, | |
| 42 | + merchant.name, | |
| 43 | + formatCategoryLabel(merchant.categoryCode), | |
| 44 | + merchant.businessNumber, | |
| 45 | + merchant.ownerName, | |
| 46 | + merchant.phoneNumber, | |
| 47 | + merchant.address, | |
| 48 | + merchant.addressDetail, | |
| 49 | + formatVisibilityLabel(merchant.isVisible), | |
| 50 | + merchant.createdAt, | |
| 51 | + ] | |
| 52 | + .map(toCsvCell) | |
| 53 | + .join(',') | |
| 54 | + ); | |
| 55 | + | |
| 56 | + // 엑셀이 UTF-8을 알아보게 BOM을 앞에 붙인다 — 없으면 한글이 깨진다. | |
| 57 | + const csv = `${[HEADERS.join(','), ...rows].join('\r\n')}`; | |
| 58 | + | |
| 59 | + return new Response(csv, { | |
| 60 | + status: 200, | |
| 61 | + headers: { | |
| 62 | + 'Content-Type': 'text/csv; charset=utf-8', | |
| 63 | + 'Content-Disposition': 'attachment; filename="merchants.csv"', | |
| 64 | + }, | |
| 65 | + }); | |
| 66 | +} |
+++ app/(protected)/(basic)/system/merchants/page.tsx
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { verifySession } from '@/lib/auth/dal'; | |
| 3 | +import { fetchMerchants } from '@/lib/data/repositories/merchant-repository'; | |
| 4 | +import { parseMerchantQuery } from '@/lib/domain/merchant-query'; | |
| 5 | +import { MerchantList } from './_components/merchant-list'; | |
| 6 | + | |
| 7 | +export const metadata: Metadata = { | |
| 8 | + title: '가맹점 관리', | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export const dynamic = 'force-dynamic'; | |
| 12 | + | |
| 13 | +interface PageProps { | |
| 14 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export default async function Page({ searchParams }: PageProps) { | |
| 18 | + await verifySession(); | |
| 19 | + | |
| 20 | + const query = parseMerchantQuery(await searchParams); | |
| 21 | + const { items, totalCount } = await fetchMerchants(query); | |
| 22 | + | |
| 23 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 24 | + const currentPage = Math.min(query.page, totalPages); | |
| 25 | + | |
| 26 | + return ( | |
| 27 | + <MerchantList | |
| 28 | + items={items} | |
| 29 | + query={query} | |
| 30 | + currentPage={currentPage} | |
| 31 | + totalPages={totalPages} | |
| 32 | + totalCount={totalCount} | |
| 33 | + /> | |
| 34 | + ); | |
| 35 | +} |
+++ lib/data/repositories/merchant-repository.ts
... | ... | @@ -0,0 +1,162 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import type { Merchant } from '@/lib/domain/merchant'; | |
| 3 | +import { MERCHANT_CATEGORY_OPTIONS } from '@/lib/domain/merchant'; | |
| 4 | +import type { MerchantQuery } from '@/lib/domain/merchant-query'; | |
| 5 | +import type { MerchantValues } from '@/lib/domain/merchant-form'; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 가맹점 Repository — 기획 MCH_001/002_p/003_p. | |
| 9 | + * | |
| 10 | + * TODO(백엔드): 관리자용 가맹점 API가 아직 없다(`mngr` 도메인에 해당 컨트롤러가 없고, | |
| 11 | + * `common/qrauth`의 `CmmPaymentMerchantVo`는 결제 조회용이라 id·name·type·addr·telNo뿐이다). | |
| 12 | + * 아래 mock 저장소를 지우고 다른 Repository처럼 backendFetch + getSessionAccessToken으로 | |
| 13 | + * 교체하면 호출부는 그대로 둘 수 있다. | |
| 14 | + * | |
| 15 | + * 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 등록·수정·삭제가 초기값으로 되돌아간다. | |
| 16 | + */ | |
| 17 | + | |
| 18 | +const TOTAL = 128; | |
| 19 | +const BRANDS = ['GS25', 'CU', '', '스타벅스', '', '교보문고']; | |
| 20 | +const CATEGORY_CODES = MERCHANT_CATEGORY_OPTIONS.map((option) => option.value); | |
| 21 | +const CITIES = [ | |
| 22 | + '경기도 평택시 송탄1로', | |
| 23 | + '경상남도 양산시 배움터길', | |
| 24 | + '경기도 용인시 기흥구 죽전로', | |
| 25 | + '서울특별시 마포구 양화로', | |
| 26 | + '부산광역시 해운대구 센텀중앙로', | |
| 27 | +]; | |
| 28 | +const BASE_DATE = Date.UTC(2026, 0, 5); | |
| 29 | +const DAY_MS = 24 * 60 * 60 * 1000; | |
| 30 | + | |
| 31 | +function dateAt(offsetDays: number): string { | |
| 32 | + return new Date(BASE_DATE + offsetDays * DAY_MS).toISOString().slice(0, 10); | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** 인덱스로만 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다. */ | |
| 36 | +function buildMerchant(index: number): Merchant { | |
| 37 | + const serial = index + 1; | |
| 38 | + const brand = BRANDS[index % BRANDS.length]; | |
| 39 | + const city = CITIES[index % CITIES.length]; | |
| 40 | + | |
| 41 | + return { | |
| 42 | + id: `MCH-${String(serial).padStart(6, '0')}`, | |
| 43 | + code: `MCH-${String(serial).padStart(6, '0')}`, | |
| 44 | + brandName: brand, | |
| 45 | + name: brand ? `${brand} ${city.split(' ').at(-1)}점` : `${city.split(' ').at(-1)}점`, | |
| 46 | + categoryCode: CATEGORY_CODES[index % CATEGORY_CODES.length], | |
| 47 | + businessNumber: `${100 + (index % 800)}-${10 + (index % 80)}-${String(10000 + index).slice(0, 5)}`, | |
| 48 | + ownerName: `대표${serial}`, | |
| 49 | + phoneNumber: `0${2 + (index % 6)}${String(10000000 + index * 7).slice(0, 8)}`, | |
| 50 | + address: `${city} ${(index % 90) + 1}`, | |
| 51 | + addressDetail: brand ? `1층 ${brand}` : '', | |
| 52 | + latitude: (37.0987 + (index % 50) * 0.01).toFixed(4), | |
| 53 | + longitude: (127.0736 + (index % 50) * 0.01).toFixed(4), | |
| 54 | + // 몇 곳은 미노출로 둬 필터가 실제로 걸리는지 보이게 한다. | |
| 55 | + isVisible: index % 7 !== 0, | |
| 56 | + memo: '', | |
| 57 | + createdAt: dateAt(index % 60), | |
| 58 | + updatedAt: dateAt((index % 60) + 15), | |
| 59 | + }; | |
| 60 | +} | |
| 61 | + | |
| 62 | +let MERCHANTS: Merchant[] = Array.from({ length: TOTAL }, (_, index) => | |
| 63 | + buildMerchant(index) | |
| 64 | +); | |
| 65 | + | |
| 66 | +const SEARCH_VALUE: Record<string, (merchant: Merchant) => string> = { | |
| 67 | + name: (merchant) => merchant.name, | |
| 68 | + businessNumber: (merchant) => merchant.businessNumber, | |
| 69 | + address: (merchant) => `${merchant.address} ${merchant.addressDetail}`, | |
| 70 | +}; | |
| 71 | + | |
| 72 | +function matchesQuery(merchant: Merchant, query: MerchantQuery): boolean { | |
| 73 | + if (query.category && merchant.categoryCode !== query.category) { | |
| 74 | + return false; | |
| 75 | + } | |
| 76 | + if (query.visibility === 'visible' && !merchant.isVisible) { | |
| 77 | + return false; | |
| 78 | + } | |
| 79 | + if (query.visibility === 'hidden' && merchant.isVisible) { | |
| 80 | + return false; | |
| 81 | + } | |
| 82 | + | |
| 83 | + const keyword = query.keyword.trim().toLowerCase(); | |
| 84 | + if (!keyword) { | |
| 85 | + return true; | |
| 86 | + } | |
| 87 | + const read = SEARCH_VALUE[query.searchField]; | |
| 88 | + return read ? read(merchant).toLowerCase().includes(keyword) : true; | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** 기획 MCH_001 ④ — 최근등록순 정렬. */ | |
| 92 | +function byNewestFirst(a: Merchant, b: Merchant): number { | |
| 93 | + return Number(b.code.slice(4)) - Number(a.code.slice(4)); | |
| 94 | +} | |
| 95 | + | |
| 96 | +export async function fetchMerchants( | |
| 97 | + query: MerchantQuery | |
| 98 | +): Promise<{ items: Merchant[]; totalCount: number }> { | |
| 99 | + const matched = MERCHANTS.filter((merchant) => matchesQuery(merchant, query)); | |
| 100 | + matched.sort(byNewestFirst); | |
| 101 | + | |
| 102 | + const start = (query.page - 1) * query.pageSize; | |
| 103 | + return { | |
| 104 | + items: matched.slice(start, start + query.pageSize), | |
| 105 | + totalCount: matched.length, | |
| 106 | + }; | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** 엑셀 다운로드는 검색 조건과 무관하게 전체다(기획 MCH_001 ②). */ | |
| 110 | +export async function fetchAllMerchants(): Promise<Merchant[]> { | |
| 111 | + return [...MERCHANTS].sort(byNewestFirst); | |
| 112 | +} | |
| 113 | + | |
| 114 | +function nowDate(): string { | |
| 115 | + return new Date().toISOString().slice(0, 10); | |
| 116 | +} | |
| 117 | + | |
| 118 | +/** 가맹점코드는 저장 시 자동 생성한다(기획 MCH_002_p ⑤). */ | |
| 119 | +export async function createMerchant(values: MerchantValues): Promise<void> { | |
| 120 | + const nextSerial = | |
| 121 | + MERCHANTS.reduce( | |
| 122 | + (max, merchant) => Math.max(max, Number(merchant.code.slice(4)) || 0), | |
| 123 | + 0 | |
| 124 | + ) + 1; | |
| 125 | + const code = `MCH-${String(nextSerial).padStart(6, '0')}`; | |
| 126 | + | |
| 127 | + MERCHANTS = [ | |
| 128 | + ...MERCHANTS, | |
| 129 | + { | |
| 130 | + ...values, | |
| 131 | + id: code, | |
| 132 | + code, | |
| 133 | + createdAt: nowDate(), | |
| 134 | + updatedAt: nowDate(), | |
| 135 | + }, | |
| 136 | + ]; | |
| 137 | +} | |
| 138 | + | |
| 139 | +export async function updateMerchant(values: MerchantValues): Promise<void> { | |
| 140 | + const index = MERCHANTS.findIndex((merchant) => merchant.id === values.id); | |
| 141 | + if (index < 0) { | |
| 142 | + throw new Error('가맹점을 찾을 수 없습니다.'); | |
| 143 | + } | |
| 144 | + | |
| 145 | + MERCHANTS[index] = { | |
| 146 | + ...MERCHANTS[index], | |
| 147 | + ...values, | |
| 148 | + // 코드·등록일은 바뀌지 않는다. | |
| 149 | + id: MERCHANTS[index].id, | |
| 150 | + code: MERCHANTS[index].code, | |
| 151 | + createdAt: MERCHANTS[index].createdAt, | |
| 152 | + updatedAt: nowDate(), | |
| 153 | + }; | |
| 154 | +} | |
| 155 | + | |
| 156 | +export async function deleteMerchant(id: string): Promise<void> { | |
| 157 | + const next = MERCHANTS.filter((merchant) => merchant.id !== id); | |
| 158 | + if (next.length === MERCHANTS.length) { | |
| 159 | + throw new Error('가맹점을 찾을 수 없습니다.'); | |
| 160 | + } | |
| 161 | + MERCHANTS = next; | |
| 162 | +} |
+++ lib/domain/merchant-form.ts
... | ... | @@ -0,0 +1,91 @@ |
| 1 | +export const NAME_MAX_LENGTH = 100; | |
| 2 | +export const BRAND_MAX_LENGTH = 100; | |
| 3 | +export const OWNER_MAX_LENGTH = 50; | |
| 4 | +export const ADDRESS_DETAIL_MAX_LENGTH = 200; | |
| 5 | +export const MEMO_MAX_LENGTH = 500; | |
| 6 | + | |
| 7 | +export interface MerchantValues { | |
| 8 | + id: string; | |
| 9 | + brandName: string; | |
| 10 | + name: string; | |
| 11 | + categoryCode: string; | |
| 12 | + businessNumber: string; | |
| 13 | + ownerName: string; | |
| 14 | + phoneNumber: string; | |
| 15 | + address: string; | |
| 16 | + addressDetail: string; | |
| 17 | + latitude: string; | |
| 18 | + longitude: string; | |
| 19 | + isVisible: boolean; | |
| 20 | + memo: string; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export type MerchantFormErrors = Partial< | |
| 24 | + Record<'name' | 'categoryCode' | 'phoneNumber' | 'address' | 'businessNumber', string> | |
| 25 | +>; | |
| 26 | + | |
| 27 | +export type MerchantFormState = | |
| 28 | + | { status: 'idle' } | |
| 29 | + | { status: 'error'; message?: string; errors?: MerchantFormErrors } | |
| 30 | + | { status: 'success' }; | |
| 31 | + | |
| 32 | +export const INITIAL_MERCHANT_FORM_STATE: MerchantFormState = { status: 'idle' }; | |
| 33 | + | |
| 34 | +export const REQUIRED_MESSAGE = '필수입력 사항을 입력하세요.'; | |
| 35 | + | |
| 36 | +/** 사업자등록번호는 선택이지만, 넣었다면 형태를 지킨다(기획 예시 `000-00-00000`). */ | |
| 37 | +const BUSINESS_NUMBER_PATTERN = /^\d{3}-?\d{2}-?\d{5}$/; | |
| 38 | + | |
| 39 | +type ValidationResult = | |
| 40 | + | { ok: true; values: MerchantValues } | |
| 41 | + | { ok: false; errors: MerchantFormErrors }; | |
| 42 | + | |
| 43 | +export function validateMerchant(values: MerchantValues): ValidationResult { | |
| 44 | + const errors: MerchantFormErrors = {}; | |
| 45 | + | |
| 46 | + const name = values.name.trim(); | |
| 47 | + if (!name) { | |
| 48 | + errors.name = REQUIRED_MESSAGE; | |
| 49 | + } else if (name.length > NAME_MAX_LENGTH) { | |
| 50 | + errors.name = `가맹점명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`; | |
| 51 | + } | |
| 52 | + | |
| 53 | + if (!values.categoryCode.trim()) { | |
| 54 | + errors.categoryCode = REQUIRED_MESSAGE; | |
| 55 | + } | |
| 56 | + | |
| 57 | + const phoneNumber = values.phoneNumber.trim(); | |
| 58 | + if (!phoneNumber) { | |
| 59 | + errors.phoneNumber = REQUIRED_MESSAGE; | |
| 60 | + } else if (!/^[\d-]+$/.test(phoneNumber)) { | |
| 61 | + errors.phoneNumber = '전화번호는 숫자와 -만 입력할 수 있습니다.'; | |
| 62 | + } | |
| 63 | + | |
| 64 | + if (!values.address.trim()) { | |
| 65 | + errors.address = '주소 검색을 진행해 주세요.'; | |
| 66 | + } | |
| 67 | + | |
| 68 | + const businessNumber = values.businessNumber.trim(); | |
| 69 | + if (businessNumber && !BUSINESS_NUMBER_PATTERN.test(businessNumber)) { | |
| 70 | + errors.businessNumber = '사업자등록번호는 000-00-00000 형식으로 입력해 주세요.'; | |
| 71 | + } | |
| 72 | + | |
| 73 | + if (Object.keys(errors).length > 0) { | |
| 74 | + return { ok: false, errors }; | |
| 75 | + } | |
| 76 | + | |
| 77 | + return { | |
| 78 | + ok: true, | |
| 79 | + values: { | |
| 80 | + ...values, | |
| 81 | + name, | |
| 82 | + brandName: values.brandName.trim().slice(0, BRAND_MAX_LENGTH), | |
| 83 | + ownerName: values.ownerName.trim().slice(0, OWNER_MAX_LENGTH), | |
| 84 | + phoneNumber, | |
| 85 | + businessNumber, | |
| 86 | + address: values.address.trim(), | |
| 87 | + addressDetail: values.addressDetail.trim().slice(0, ADDRESS_DETAIL_MAX_LENGTH), | |
| 88 | + memo: values.memo.trim().slice(0, MEMO_MAX_LENGTH), | |
| 89 | + }, | |
| 90 | + }; | |
| 91 | +} |
+++ lib/domain/merchant-query.ts
... | ... | @@ -0,0 +1,116 @@ |
| 1 | +import { MERCHANT_CATEGORY_OPTIONS } from '@/lib/domain/merchant'; | |
| 2 | + | |
| 3 | +export const MERCHANTS_PATH = '/system/merchants'; | |
| 4 | +/** 엑셀 다운로드 — 파일 응답이라 라우트 핸들러가 받는다(기획 MCH_001 ②). */ | |
| 5 | +export const MERCHANTS_EXCEL_PATH = `${MERCHANTS_PATH}/excel`; | |
| 6 | + | |
| 7 | +/** 필터 셀렉트의 "전체" 값 — URL에서는 생략된다. */ | |
| 8 | +export const FILTER_ALL = ''; | |
| 9 | + | |
| 10 | +export type MerchantSearchField = 'name' | 'businessNumber' | 'address'; | |
| 11 | + | |
| 12 | +export const MERCHANT_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ | |
| 13 | + value: MerchantSearchField; | |
| 14 | + label: string; | |
| 15 | +}> = [ | |
| 16 | + { value: 'name', label: '가맹점명' }, | |
| 17 | + { value: 'businessNumber', label: '사업자번호' }, | |
| 18 | + { value: 'address', label: '주소' }, | |
| 19 | +]; | |
| 20 | + | |
| 21 | +export const MERCHANT_VISIBILITY_OPTIONS = [ | |
| 22 | + { value: 'visible', label: '노출' }, | |
| 23 | + { value: 'hidden', label: '미노출' }, | |
| 24 | +] as const; | |
| 25 | + | |
| 26 | +export const MERCHANT_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 27 | +export type MerchantPageSize = (typeof MERCHANT_PAGE_SIZE_OPTIONS)[number]; | |
| 28 | + | |
| 29 | +export const DEFAULT_MERCHANT_SEARCH_FIELD: MerchantSearchField = 'name'; | |
| 30 | +export const DEFAULT_MERCHANT_PAGE_SIZE: MerchantPageSize = 10; | |
| 31 | +const DEFAULT_PAGE = 1; | |
| 32 | +const MAX_KEYWORD_LENGTH = 100; | |
| 33 | + | |
| 34 | +export type MerchantQuery = { | |
| 35 | + category: string; | |
| 36 | + visibility: string; | |
| 37 | + searchField: MerchantSearchField; | |
| 38 | + keyword: string; | |
| 39 | + page: number; | |
| 40 | + pageSize: MerchantPageSize; | |
| 41 | +}; | |
| 42 | + | |
| 43 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 44 | + | |
| 45 | +function readParam(params: RawSearchParams, key: string): string { | |
| 46 | + const value = params[key]; | |
| 47 | + return (Array.isArray(value) ? value[0] : value) ?? ''; | |
| 48 | +} | |
| 49 | + | |
| 50 | +function readOneOf( | |
| 51 | + params: RawSearchParams, | |
| 52 | + key: string, | |
| 53 | + allowed: readonly { value: string }[] | |
| 54 | +): string { | |
| 55 | + const raw = readParam(params, key); | |
| 56 | + return allowed.some((option) => option.value === raw) ? raw : FILTER_ALL; | |
| 57 | +} | |
| 58 | + | |
| 59 | +function isSearchField(value: string): value is MerchantSearchField { | |
| 60 | + return MERCHANT_SEARCH_FIELD_OPTIONS.some((option) => option.value === value); | |
| 61 | +} | |
| 62 | + | |
| 63 | +function isPageSize(value: number): value is MerchantPageSize { | |
| 64 | + return (MERCHANT_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); | |
| 65 | +} | |
| 66 | + | |
| 67 | +export function parseMerchantQuery(searchParams: RawSearchParams): MerchantQuery { | |
| 68 | + const searchField = readParam(searchParams, 'searchField'); | |
| 69 | + const page = Number(readParam(searchParams, 'page')); | |
| 70 | + const pageSize = Number(readParam(searchParams, 'pageSize')); | |
| 71 | + | |
| 72 | + return { | |
| 73 | + category: readOneOf(searchParams, 'category', MERCHANT_CATEGORY_OPTIONS), | |
| 74 | + visibility: readOneOf( | |
| 75 | + searchParams, | |
| 76 | + 'visibility', | |
| 77 | + MERCHANT_VISIBILITY_OPTIONS | |
| 78 | + ), | |
| 79 | + searchField: isSearchField(searchField) | |
| 80 | + ? searchField | |
| 81 | + : DEFAULT_MERCHANT_SEARCH_FIELD, | |
| 82 | + keyword: readParam(searchParams, 'keyword').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 83 | + page: Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE, | |
| 84 | + pageSize: isPageSize(pageSize) ? pageSize : DEFAULT_MERCHANT_PAGE_SIZE, | |
| 85 | + }; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export function buildMerchantHref( | |
| 89 | + query: MerchantQuery, | |
| 90 | + overrides: Partial<MerchantQuery> = {} | |
| 91 | +): string { | |
| 92 | + const merged = { ...query, ...overrides }; | |
| 93 | + const params = new URLSearchParams(); | |
| 94 | + | |
| 95 | + if (merged.category) { | |
| 96 | + params.set('category', merged.category); | |
| 97 | + } | |
| 98 | + if (merged.visibility) { | |
| 99 | + params.set('visibility', merged.visibility); | |
| 100 | + } | |
| 101 | + if (merged.searchField !== DEFAULT_MERCHANT_SEARCH_FIELD) { | |
| 102 | + params.set('searchField', merged.searchField); | |
| 103 | + } | |
| 104 | + if (merged.keyword) { | |
| 105 | + params.set('keyword', merged.keyword); | |
| 106 | + } | |
| 107 | + if (merged.pageSize !== DEFAULT_MERCHANT_PAGE_SIZE) { | |
| 108 | + params.set('pageSize', String(merged.pageSize)); | |
| 109 | + } | |
| 110 | + if (merged.page !== DEFAULT_PAGE) { | |
| 111 | + params.set('page', String(merged.page)); | |
| 112 | + } | |
| 113 | + | |
| 114 | + const queryString = params.toString(); | |
| 115 | + return queryString ? `${MERCHANTS_PATH}?${queryString}` : MERCHANTS_PATH; | |
| 116 | +} |
+++ lib/domain/merchant.ts
... | ... | @@ -0,0 +1,66 @@ |
| 1 | +export interface Merchant { | |
| 2 | + id: string; | |
| 3 | + /** 등록 시 자동 생성 — 수정 불가(기획 MCH_003_p ①). */ | |
| 4 | + code: string; | |
| 5 | + brandName: string; | |
| 6 | + name: string; | |
| 7 | + categoryCode: string; | |
| 8 | + businessNumber: string; | |
| 9 | + ownerName: string; | |
| 10 | + phoneNumber: string; | |
| 11 | + address: string; | |
| 12 | + addressDetail: string; | |
| 13 | + latitude: string; | |
| 14 | + longitude: string; | |
| 15 | + isVisible: boolean; | |
| 16 | + memo: string; | |
| 17 | + createdAt: string; | |
| 18 | + updatedAt: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export const EMPTY_FIELD_PLACEHOLDER = '-'; | |
| 22 | + | |
| 23 | +export function formatOptionalValue( | |
| 24 | + value: string | number | null | undefined | |
| 25 | +): string { | |
| 26 | + return value === null || value === undefined || value === '' | |
| 27 | + ? EMPTY_FIELD_PLACEHOLDER | |
| 28 | + : String(value); | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * 업종 — 기획 MCH_002_p ②. | |
| 33 | + * | |
| 34 | + * TODO(기획): "제공받을 수 없는 데이터라면 추후 삭제 필요"로 적혀 있다. 코드표가 정해지면 | |
| 35 | + * 공통코드 조회로 바꾼다. | |
| 36 | + */ | |
| 37 | +export const MERCHANT_CATEGORY_OPTIONS: ReadonlyArray<{ | |
| 38 | + value: string; | |
| 39 | + label: string; | |
| 40 | +}> = [ | |
| 41 | + { value: 'CONVENIENCE', label: '편의점' }, | |
| 42 | + { value: 'CAFE', label: '카페' }, | |
| 43 | + { value: 'RESTAURANT', label: '음식점' }, | |
| 44 | + { value: 'BOOKSTORE', label: '서점' }, | |
| 45 | + { value: 'STATIONERY', label: '문구점' }, | |
| 46 | + { value: 'ETC', label: '기타' }, | |
| 47 | +]; | |
| 48 | + | |
| 49 | +export function formatCategoryLabel(code: string): string { | |
| 50 | + if (!code) { | |
| 51 | + return EMPTY_FIELD_PLACEHOLDER; | |
| 52 | + } | |
| 53 | + return ( | |
| 54 | + MERCHANT_CATEGORY_OPTIONS.find((option) => option.value === code)?.label ?? | |
| 55 | + code | |
| 56 | + ); | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function formatVisibilityLabel(isVisible: boolean): string { | |
| 60 | + return isVisible ? '노출' : '미노출'; | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** 목록의 주소 칸 — 상세주소까지 붙이면 한 줄을 넘겨 표가 밀린다. */ | |
| 64 | +export function formatAddressSummary(merchant: Merchant): string { | |
| 65 | + return formatOptionalValue(merchant.address); | |
| 66 | +} |
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?