'use client'; import { useActionState, useEffect, useRef, useState, useTransition, } from 'react'; import { FoxButton } from '@fox/core/components/fox-button'; import { FoxFormLabel } from '@fox/core/components/fox-form-label'; import { FoxHelperText } from '@fox/core/components/fox-helper-text'; import { FoxInput } from '@fox/core/components/fox-input'; import { FoxModal } from '@fox/core/components/fox-modal'; import { FoxRadio } from '@fox/core/components/fox-radio'; import { FoxRadioGroup } from '@fox/core/components/fox-radio-group'; import { FoxSelect } from '@fox/core/components/fox-select'; import { submitFormAction } from '@/app/_hooks/submit-form-action'; import { useFeedback } from '@/app/_hooks/use-feedback'; import { MERCHANT_CATEGORY_OPTIONS, formatOptionalValue, type Merchant, } from '@/lib/domain/merchant'; import { ADDRESS_DETAIL_MAX_LENGTH, BRAND_MAX_LENGTH, INITIAL_MERCHANT_FORM_STATE, MEMO_MAX_LENGTH, NAME_MAX_LENGTH, OWNER_MAX_LENGTH, } from '@/lib/domain/merchant-form'; import { saveMerchantAction } from '../_actions'; import { openPostcodeSearch } from './postcode'; import styles from './merchant-form.module.scss'; /** * 백엔드에 가맹점 수정 API가 없어(`/api/v1/mngr/voc`는 목록·등록뿐) 수정 모달을 조회 * 전용으로 연다. **API가 생기면 이 한 줄만 `true`로 바꾸면 된다** — 입력칸·저장 버튼이 * 전부 이 값에 걸려 있고 폼 자체는 그대로 살아 있다. */ const IS_EDIT_ENABLED = false; interface MerchantFormModalProps { merchant?: Merchant; onClose: () => void; } export function MerchantFormModal({ merchant, onClose }: MerchantFormModalProps) { const isEdit = Boolean(merchant); const isReadOnly = isEdit && !IS_EDIT_ENABLED; // 조회 전용일 때 입력칸마다 얹는다. `state="view"`는 시안의 조회 상태로, 잠긴 것(disabled)과 // 달리 "볼 수만 있는 값"으로 보이게 한다. const viewProps = isReadOnly ? ({ readOnly: true, state: 'view' } as const) : null; const { showToast } = useFeedback(); const formRef = useRef(null); const [state, formAction, isPending] = useActionState( saveMerchantAction, INITIAL_MERCHANT_FORM_STATE ); const [categoryCode, setCategoryCode] = useState(merchant?.categoryCode ?? ''); const [isVisible, setIsVisible] = useState(merchant?.isVisible ?? true); // 주소·좌표는 검색 결과가 채운다(기획 ③ Read Only). const [address, setAddress] = useState(merchant?.address ?? ''); const [postalCode, setPostalCode] = useState(merchant?.postalCode ?? ''); const [latitude, setLatitude] = useState(merchant?.latitude ?? ''); const [longitude, setLongitude] = useState(merchant?.longitude ?? ''); const [isSearching, startSearching] = useTransition(); useEffect(() => { if (state.status === 'success') { showToast({ variant: 'success', message: isEdit ? '가맹점을 수정했습니다.' : '가맹점을 등록했습니다.', }); onClose(); return; } // 칸별 오류는 해당 입력칸에 그대로 두고, 폼 전체에 걸린 사유만 토스트로 알린다 — // 긴 폼이라 맨 아래 문구는 스크롤 밖에 있으면 보이지 않는다. if (state.status === 'error' && state.message) { showToast({ variant: 'danger', message: state.message }); } }, [state, showToast, onClose, isEdit]); const errors = state.status === 'error' ? (state.errors ?? {}) : {}; /** 기획 ③ 주소 검색 연동 — 고른 주소로 우편번호·좌표까지 채운다. */ function searchAddress() { startSearching(async () => { let picked; try { picked = await openPostcodeSearch(); } catch { showToast({ variant: 'danger', message: '주소 검색을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.', }); return; } if (!picked) { return; } setAddress(picked.address); setPostalCode(picked.zonecode); setLatitude(picked.latitude); setLongitude(picked.longitude); }); } return ( ) : ( <> formRef.current?.requestSubmit()} /> ) } >
submitFormAction(event, formAction)} className={styles.fields} > {/* 등록 시에는 코드가 아직 없다 — 저장할 때 서버가 만든다. */} {isEdit && (
)} {/* 브랜드명·메모·등록일은 백엔드에 컬럼이 없어 저장되지 않는다. 기획 시안에 있는 항목이라 화면에서는 지우지 않고, 값이 없으면 `-`로 보여준다(사용자 확정). */}
({ value: option.value, label: option.label, }))} value={categoryCode} onValueChange={setCategoryCode} placeholder="선택" error={Boolean(errors.categoryCode)} hint={errors.categoryCode} />
주소
{!isReadOnly && ( )}
{errors.address && ( )}
위도 / 경도
사용여부 setIsVisible(true)} /> setIsVisible(false)} />
{isEdit && (
등록일 / 최종수정일
)}
); }