File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'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 {
DEFAULT_MERCHANT_DIVISION_CODE,
formatOptionalValue,
type Merchant,
type MerchantCodeTables,
} from '@/lib/domain/merchant';
import {
ADDRESS_DETAIL_MAX_LENGTH,
INITIAL_MERCHANT_FORM_STATE,
MEMO_MAX_LENGTH,
NAME_MAX_LENGTH,
ACCOUNT_NUMBER_MAX_LENGTH,
OWNER_MAX_LENGTH,
} from '@/lib/domain/merchant-form';
import { saveMerchantAction } from '../_actions';
import { openPostcodeSearch } from '@/app/_components/postcode';
import styles from './merchant-form.module.scss';
interface MerchantFormModalProps {
merchant?: Merchant;
/** 업종·업태·구분·은행 코드표 — 선택지. */
codeTables: MerchantCodeTables;
onClose: () => void;
}
/**
* 가맹몰(가맹점) 등록·수정 모달 — 기획 MCH_002_p·MCH_003_p. 모든 항목을 고칠 수 있다(사용자 확정).
* 요청은 백엔드 `MngrVocInsertReqVo`/`MngrVocUpdateReqVo`대로 보낸다 — 지금은 BizPlay(웹캐시)로
* 위임되고 곧 우리 DB 저장으로 바뀐다.
*/
export function MerchantFormModal({ merchant, codeTables, onClose }: MerchantFormModalProps) {
const isEdit = Boolean(merchant);
const { showToast } = useFeedback();
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction, isPending] = useActionState(
saveMerchantAction,
INITIAL_MERCHANT_FORM_STATE
);
const [categoryCode, setCategoryCode] = useState(merchant?.categoryCode ?? '');
const [businessStatusCode, setBusinessStatusCode] = useState(merchant?.businessStatusCode ?? '');
const [divisionCode, setDivisionCode] = useState(
merchant?.divisionCode || DEFAULT_MERCHANT_DIVISION_CODE
);
const [bankCode, setBankCode] = useState('');
// 조회 응답에 useYn이 없어 기존 가맹점은 값을 모른다 — 그때는 어느 쪽도 고르지 않는다.
// 신규 등록만 「노출」로 시작한다.
const [isVisible, setIsVisible] = useState<boolean | null>(
merchant === undefined ? true : merchant.isVisible
);
// 주소·우편번호는 검색으로 채우거나 직접 고친다. 좌표는 검색 결과만 채운다.
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 (
<FoxModal
dismissible={false}
open
size="md"
title={isEdit ? '가맹몰 수정' : '가맹몰 등록'}
onClose={onClose}
actions={
<>
<FoxButton type="default" size="md" label="취소" onAction={onClose} />
<FoxButton
type="primary"
size="md"
label={isPending ? '저장 중...' : isEdit ? '수정' : '등록'}
disabled={isPending}
onAction={() => formRef.current?.requestSubmit()}
/>
</>
}
>
<form
ref={formRef}
onSubmit={(event) => submitFormAction(event, formAction)}
className={styles.fields}
>
<input type="hidden" name="id" value={merchant?.id ?? ''} />
<input type="hidden" name="categoryCode" value={categoryCode} />
<input type="hidden" name="businessStatusCode" value={businessStatusCode} />
<input type="hidden" name="divisionCode" value={divisionCode} />
<input type="hidden" name="bankCode" value={bankCode} />
<input type="hidden" name="isVisible" value={isVisible ? 'Y' : 'N'} />
<input type="hidden" name="latitude" value={latitude} />
<input type="hidden" name="longitude" value={longitude} />
{/* 등록 시에는 코드가 아직 없다 — 저장할 때 서버가 만든다. */}
{isEdit && (
<div className={styles.field}>
<FoxInput
size="md"
label="가맹점코드"
value={merchant?.code ?? ''}
readOnly
state="view"
/>
</div>
)}
{/* 메모·등록일은 백엔드에 컬럼이 없어 저장되지 않는다. 기획 시안에 있는 항목이라 화면에서는
지우지 않고, 값이 없으면 `-`로 보여준다(사용자 확정). 브랜드명은 폼에서 뺐다(사용자 요청). */}
<div className={styles.field}>
<FoxInput
size="md"
name="name"
label="가맹점명"
requirement="required"
defaultValue={merchant?.name ?? ''}
placeholder="가맹점명을 입력하세요."
maxLength={NAME_MAX_LENGTH}
invalid={Boolean(errors.name)}
message={errors.name}
/>
</div>
<div className={styles.field}>
<div className={styles.pair}>
<FoxSelect
size="md"
label="업종"
requirement="required"
options={codeTables.businessOptions.map((code) => ({ value: code.code, label: code.label }))}
value={categoryCode}
onValueChange={setCategoryCode}
placeholder="선택"
error={Boolean(errors.categoryCode)}
hint={errors.categoryCode}
/>
<FoxSelect
size="md"
label="업태"
options={codeTables.businessStatus.map((code) => ({ value: code.code, label: code.label }))}
value={businessStatusCode}
onValueChange={setBusinessStatusCode}
placeholder="선택"
/>
</div>
</div>
<div className={styles.field}>
<FoxSelect
size="md"
label="가맹점 구분"
options={codeTables.division.map((code) => ({ value: code.code, label: code.label }))}
value={divisionCode}
onValueChange={setDivisionCode}
placeholder="선택"
/>
</div>
<div className={styles.field}>
<div className={styles.pair}>
<FoxInput
size="md"
name="businessNumber"
label="사업자등록번호"
defaultValue={merchant?.businessNumber ?? ''}
placeholder="000-00-00000"
invalid={Boolean(errors.businessNumber)}
message={errors.businessNumber}
/>
<FoxInput
size="md"
name="ownerName"
label="대표자명"
defaultValue={merchant?.ownerName ?? ''}
placeholder="대표자명을 입력하세요."
maxLength={OWNER_MAX_LENGTH}
/>
</div>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="phoneNumber"
label="전화번호"
requirement="required"
defaultValue={merchant?.phoneNumber ?? ''}
placeholder="가게 전화번호를 입력하세요."
invalid={Boolean(errors.phoneNumber)}
message={errors.phoneNumber}
/>
</div>
<div className={styles.field}>
<FoxFormLabel as="span" requirement="required">
주소
</FoxFormLabel>
<div className={styles.addressRow}>
<FoxInput
size="md"
name="postalCode"
aria-label="우편번호"
value={postalCode}
onChange={setPostalCode}
placeholder="우편번호"
invalid={Boolean(errors.postalCode)}
message={errors.postalCode}
/>
<FoxButton
type="secondary"
size="md"
label={isSearching ? '검색 중...' : '주소 검색'}
disabled={isSearching}
onAction={searchAddress}
/>
</div>
<FoxInput
size="md"
name="address"
aria-label="주소"
value={address}
onChange={setAddress}
placeholder="주소 검색으로 채우거나 직접 입력하세요."
/>
<FoxInput
size="md"
name="addressDetail"
aria-label="상세주소"
defaultValue={merchant?.addressDetail ?? ''}
placeholder="상세주소를 입력하세요."
maxLength={ADDRESS_DETAIL_MAX_LENGTH}
/>
{errors.address && (
<FoxHelperText type="danger" message={errors.address} />
)}
</div>
<div className={styles.field}>
<FoxFormLabel as="span">위도 / 경도</FoxFormLabel>
<div className={styles.pair}>
<FoxInput
size="md"
aria-label="위도"
value={latitude}
readOnly
state="view"
placeholder="주소 검색 시 자동 입력"
/>
<FoxInput
size="md"
aria-label="경도"
value={longitude}
readOnly
state="view"
placeholder="주소 검색 시 자동 입력"
/>
</div>
</div>
<div className={styles.field}>
<FoxFormLabel as="span">정산 계좌</FoxFormLabel>
<div className={styles.pair}>
<FoxSelect
size="md"
aria-label="입금 은행"
options={codeTables.bank.map((code) => ({ value: code.code, label: code.label }))}
value={bankCode}
onValueChange={setBankCode}
placeholder="은행 선택"
/>
<FoxInput
size="md"
name="bankHolderName"
aria-label="예금주명"
placeholder="예금주명"
maxLength={OWNER_MAX_LENGTH}
/>
</div>
<FoxInput
size="md"
name="accountNumber"
aria-label="입금계좌번호"
placeholder="입금계좌번호 (숫자와 -)"
maxLength={ACCOUNT_NUMBER_MAX_LENGTH}
invalid={Boolean(errors.accountNumber)}
message={errors.accountNumber}
/>
</div>
<div className={styles.field}>
<FoxFormLabel as="span" requirement="required">
사용여부
</FoxFormLabel>
<FoxRadioGroup name="visibility" size="md" label="사용여부">
<FoxRadio
value="Y"
label="노출"
checked={isVisible === true}
onChange={() => setIsVisible(true)}
/>
<FoxRadio
value="N"
label="미노출"
checked={isVisible === false}
onChange={() => setIsVisible(false)}
/>
</FoxRadioGroup>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="memo"
label="메모"
defaultValue={merchant?.memo ?? ''}
placeholder="관리자 메모 (사용자에게 노출되지 않음)"
maxLength={MEMO_MAX_LENGTH}
/>
</div>
{isEdit && (
<div className={styles.field}>
<FoxFormLabel as="span">등록일 / 최종수정일</FoxFormLabel>
<div className={styles.pair}>
<FoxInput
size="md"
aria-label="등록일"
value={formatOptionalValue(merchant?.createdAt)}
readOnly
state="view"
/>
<FoxInput
size="md"
aria-label="최종수정일"
value={formatOptionalValue(merchant?.updatedAt)}
readOnly
state="view"
/>
</div>
</div>
)}
</form>
</FoxModal>
);
}