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 } from 'react';
import { FoxButton } from '@fox/core/components/fox-button';
import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
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 { FoxSelect } from '@fox/core/components/fox-select';
import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
import { submitFormAction } from '@/app/_hooks/submit-form-action';
import { useFeedback } from '@/app/_hooks/use-feedback';
import { fileModuleAccept, FILE_MODULE } from '@/lib/domain/file-module';
import {
buildChildIdPath,
formatParentLabel,
type SystemMenu,
type SystemMenuNode,
} from '@/lib/domain/system-menu';
import {
DESCRIPTION_MAX_LENGTH,
INITIAL_SYSTEM_MENU_FORM_STATE,
NAME_MAX_LENGTH,
URL_MAX_LENGTH,
} from '@/lib/domain/system-menu-form';
import { saveSystemMenuAction } from '../_actions';
import styles from './menu-form.module.scss';
interface MenuFormModalProps {
mode: 'create' | 'edit';
/** 수정 대상. 추가일 때는 비어 있다. */
menu?: SystemMenuNode;
/** 추가일 때의 상위 메뉴. 수정일 때는 대상의 상위다. */
parent: SystemMenu | null;
roleOptions: { value: string; label: string }[];
onClose: () => void;
}
const ICON_ACCEPT = fileModuleAccept(FILE_MODULE.menuIcon);
const ICON_HINT =
'이미지 파일만 업로드 가능 (jpg, png, svg 등). 앱 화면에서 메뉴 표시 시 사용됩니다.';
export function MenuFormModal({
mode,
menu,
parent,
roleOptions,
onClose,
}: MenuFormModalProps) {
const isCreate = mode === 'create';
const { showToast } = useFeedback();
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction, isPending] = useActionState(
saveSystemMenuAction,
INITIAL_SYSTEM_MENU_FORM_STATE
);
const [roleId, setRoleId] = useState(menu?.roleId ?? roleOptions[0]?.value ?? '');
const [isTopActive, setIsTopActive] = useState(menu?.isTopActive ?? false);
const [isActive, setIsActive] = useState(menu?.isActive ?? true);
// 새창여부는 저장할 컬럼이 없어 화면에만 둔다(사용자 확정).
const [opensNewWindow, setOpensNewWindow] = useState(false);
const [largeIconId, setLargeIconId] = useState(menu?.largeIconId ?? '');
const [smallIconId, setSmallIconId] = useState(menu?.smallIconId ?? '');
useEffect(() => {
if (state.status === 'success') {
showToast({
variant: 'success',
message: isCreate ? '메뉴를 등록했습니다.' : '메뉴를 수정했습니다.',
});
onClose();
}
}, [state, showToast, onClose, isCreate]);
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
const parentLabel = formatParentLabel(parent);
function iconFiles(id: string, name: string | null, url: string | null) {
return id
? [{ id, name: name ?? '등록된 아이콘', previewUrl: url ?? undefined }]
: [];
}
return (
<FoxModal
open
size="md"
title={isCreate ? '신규 등록' : '메뉴 수정'}
onClose={onClose}
actions={
<>
<FoxButton type="default" size="md" label="취소" onAction={onClose} />
<FoxButton
type="primary"
size="md"
label={isPending ? '저장 중...' : isCreate ? '저장' : '수정'}
disabled={isPending}
onAction={() => formRef.current?.requestSubmit()}
/>
</>
}
>
<form
ref={formRef}
onSubmit={(event) => submitFormAction(event, formAction)}
className={styles.fields}
>
<input type="hidden" name="id" value={menu?.id ?? ''} />
<input type="hidden" name="parentId" value={parent?.id ?? ''} />
<input type="hidden" name="parentIdPath" value={buildChildIdPath(parent)} />
<input type="hidden" name="roleId" value={roleId} />
<input type="hidden" name="isTopActive" value={isTopActive ? 'Y' : 'N'} />
<input type="hidden" name="isActive" value={isActive ? 'Y' : 'N'} />
<input type="hidden" name="largeIconId" value={largeIconId} />
<input type="hidden" name="smallIconId" value={smallIconId} />
{/* 기획 ① 상위 메뉴가 없으면(최상위 등록) 이 칸을 감춘다. */}
{parentLabel && (
<div className={styles.field}>
<FoxInput
size="md"
label="상위메뉴명"
requirement="required"
value={parentLabel}
readOnly
state="view"
/>
</div>
)}
<div className={styles.field}>
<FoxInput
size="md"
name="name"
label="메뉴명"
requirement="required"
defaultValue={menu?.name ?? ''}
placeholder="메뉴명을 입력하세요."
maxLength={NAME_MAX_LENGTH}
invalid={Boolean(errors.name)}
message={errors.name}
/>
</div>
<div className={styles.field}>
<div className={styles.narrow}>
<FoxInput
size="md"
name="sortOrder"
type="number"
inputMode="numeric"
min={1}
label="메뉴순번"
requirement="required"
defaultValue={String(menu?.sortOrder ?? 1)}
invalid={Boolean(errors.sortOrder)}
message={errors.sortOrder}
/>
</div>
</div>
<div className={styles.field}>
<FoxSelect
size="md"
label="메뉴권한"
requirement="required"
options={roleOptions}
value={roleId}
onValueChange={setRoleId}
error={Boolean(errors.roleId)}
hint={errors.roleId}
/>
</div>
<div className={styles.field}>
<FoxFormLabel as="span" requirement="required">
최상위권한활성
</FoxFormLabel>
<FoxToggleSwitch
size="md"
label={isTopActive ? '활성' : '비활성'}
checked={isTopActive}
onChange={setIsTopActive}
/>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="url"
label="메뉴링크"
requirement="required"
defaultValue={menu?.url ?? ''}
placeholder="메뉴링크를 입력하세요."
maxLength={URL_MAX_LENGTH}
invalid={Boolean(errors.url)}
message={errors.url}
/>
</div>
<div className={styles.field}>
<FoxFormLabel as="span" requirement="required">
새창여부
</FoxFormLabel>
<FoxToggleSwitch
size="md"
label={opensNewWindow ? '예' : '아니오'}
checked={opensNewWindow}
onChange={setOpensNewWindow}
/>
{/* TODO(백엔드): TB_SYS_MENU에 새창여부 컬럼이 없어 저장되지 않는다. */}
<FoxHelperText
type="information"
message="저장되지 않는 항목입니다. 백엔드에 컬럼이 추가되면 연결됩니다."
/>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="description"
label="메뉴설명"
requirement="required"
defaultValue={menu?.description ?? ''}
placeholder="메뉴설명을 입력하세요."
maxLength={DESCRIPTION_MAX_LENGTH}
invalid={Boolean(errors.description)}
message={errors.description}
/>
</div>
<div className={styles.field}>
<FoxFileUpload
mode="image"
name="largeIcon"
label="큰 아이콘"
selectLabel="파일선택"
accept={ICON_ACCEPT}
defaultFiles={iconFiles(
largeIconId,
menu?.largeIconName ?? null,
menu?.largeIconUrl ?? null
)}
onRemove={() => setLargeIconId('')}
/>
<span className={styles.iconHint}>{ICON_HINT}</span>
</div>
<div className={styles.field}>
<FoxFileUpload
mode="image"
name="smallIcon"
label="작은 아이콘"
selectLabel="파일선택"
accept={ICON_ACCEPT}
defaultFiles={iconFiles(
smallIconId,
menu?.smallIconName ?? null,
menu?.smallIconUrl ?? null
)}
onRemove={() => setSmallIconId('')}
/>
<span className={styles.iconHint}>{ICON_HINT}</span>
</div>
<div className={styles.field}>
<FoxFormLabel as="span" requirement="required">
활성여부
</FoxFormLabel>
<FoxToggleSwitch
size="md"
label={isActive ? '사용' : '미사용'}
checked={isActive}
onChange={setIsActive}
/>
</div>
{state.status === 'error' && state.message && (
<FoxHelperText type="danger" message={state.message} />
)}
</form>
</FoxModal>
);
}