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 { FoxAlert } from '@fox/core/components/fox-alert';
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<HTMLFormElement>(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 (
<FoxModal
dismissible={false}
open
size="md"
title={isReadOnly ? '가맹점 정보' : isEdit ? '가맹점 수정' : '가맹점 등록'}
onClose={onClose}
actions={
isReadOnly ? (
<FoxButton type="default" size="md" label="닫기" onAction={onClose} />
) : (
<>
<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}
>
{isReadOnly && (
<FoxAlert
type="information"
message="가맹점 수정은 백엔드 준비 중입니다. 지금은 등록된 내용만 확인할 수 있습니다."
/>
)}
<input type="hidden" name="id" value={merchant?.id ?? ''} />
<input type="hidden" name="categoryCode" value={categoryCode} />
<input type="hidden" name="isVisible" value={isVisible ? 'Y' : 'N'} />
<input type="hidden" name="address" value={address} />
<input type="hidden" name="postalCode" value={postalCode} />
<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
{...viewProps}
size="md"
name="brandName"
label="브랜드명"
defaultValue={merchant?.brandName ?? ''}
placeholder="브랜드명을 입력하세요."
maxLength={BRAND_MAX_LENGTH}
/>
</div>
<div className={styles.field}>
<FoxInput
{...viewProps}
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}>
<FoxSelect
readOnly={isReadOnly}
size="md"
label="업종"
requirement="required"
options={MERCHANT_CATEGORY_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={categoryCode}
onValueChange={setCategoryCode}
placeholder="선택"
error={Boolean(errors.categoryCode)}
hint={errors.categoryCode}
/>
</div>
<div className={styles.field}>
<div className={styles.pair}>
<FoxInput
{...viewProps}
size="md"
name="businessNumber"
label="사업자등록번호"
defaultValue={merchant?.businessNumber ?? ''}
placeholder="000-00-00000"
invalid={Boolean(errors.businessNumber)}
message={errors.businessNumber}
/>
<FoxInput
{...viewProps}
size="md"
name="ownerName"
label="대표자명"
defaultValue={merchant?.ownerName ?? ''}
placeholder="대표자명을 입력하세요."
maxLength={OWNER_MAX_LENGTH}
/>
</div>
</div>
<div className={styles.field}>
<FoxInput
{...viewProps}
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"
aria-label="주소"
value={address}
readOnly
state="view"
placeholder="주소 검색을 진행해주세요."
/>
{!isReadOnly && (
<FoxButton
type="secondary"
size="md"
label={isSearching ? '검색 중...' : '주소 검색'}
disabled={isSearching}
onAction={searchAddress}
/>
)}
</div>
<FoxInput
{...viewProps}
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" requirement="required">
사용여부
</FoxFormLabel>
<FoxRadioGroup name="visibility" size="md" label="사용여부">
<FoxRadio
disabled={isReadOnly}
value="Y"
label="노출"
checked={isVisible}
onChange={() => setIsVisible(true)}
/>
<FoxRadio
disabled={isReadOnly}
value="N"
label="미노출"
checked={!isVisible}
onChange={() => setIsVisible(false)}
/>
</FoxRadioGroup>
</div>
<div className={styles.field}>
<FoxInput
{...viewProps}
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>
);
}