diff --git a/apps/client/src/shared/components/optionsMenuButton/OptionsMenuButton.tsx b/apps/client/src/shared/components/optionsMenuButton/OptionsMenuButton.tsx
index 9c7ddc86..ab30056e 100644
--- a/apps/client/src/shared/components/optionsMenuButton/OptionsMenuButton.tsx
+++ b/apps/client/src/shared/components/optionsMenuButton/OptionsMenuButton.tsx
@@ -9,7 +9,7 @@ export interface OptionsMenuButtonProps {
const ITEM_STYLE =
'body4-r text-font-black-1 h-[3.6rem] w-full ' +
- 'flex items-center pl-[0.8rem] ' +
+ 'flex items-center justify-center ' +
'hover:bg-gray100 focus-visible:bg-gray100 active:bg-gray200 ' +
'outline-none transition-colors';
diff --git a/apps/client/src/shared/components/sidebar/PopupPortal.tsx b/apps/client/src/shared/components/sidebar/PopupPortal.tsx
index a5e30df1..636ba598 100644
--- a/apps/client/src/shared/components/sidebar/PopupPortal.tsx
+++ b/apps/client/src/shared/components/sidebar/PopupPortal.tsx
@@ -1,16 +1,24 @@
import { createPortal } from 'react-dom';
-import { Popup } from '@pinback/design-system/ui';
+import { useEffect, useState } from 'react';
+import { AutoDismissToast, Popup, Toast } from '@pinback/design-system/ui';
import type { PopupState } from '@shared/hooks/useCategoryPopups';
interface Props {
- popup: PopupState;
+ popup: PopupState | null;
onClose: () => void;
onChange?: (value: string) => void;
onCreateConfirm?: () => void;
onEditConfirm?: (id: number, draft?: string) => void;
onDeleteConfirm?: (id: number) => void;
+ categoryList?: { id: number; name: string }[];
+ isToastOpen?: boolean;
+ onToastClose?: () => void;
+ toastKey?: number;
+ toastAction?: 'create' | 'edit' | 'delete';
}
+const MAX_LEN = 10;
+
export default function PopupPortal({
popup,
onClose,
@@ -18,9 +26,74 @@ export default function PopupPortal({
onCreateConfirm,
onEditConfirm,
onDeleteConfirm,
+ categoryList,
+ isToastOpen,
+ onToastClose,
+ toastKey,
+ toastAction,
}: Props) {
+ const [draft, setDraft] = useState('');
+
+ useEffect(() => {
+ if (!popup) return;
+ setDraft(popup.kind === 'edit' ? (popup.name ?? '') : '');
+ }, [popup]);
+
if (!popup) return null;
+ const value = draft.trim();
+ const len = value.length;
+
+ const isEmpty = popup.kind !== 'delete' && len === 0;
+ const isDuplicate =
+ popup.kind !== 'delete' &&
+ !!categoryList?.some(
+ (c) => c.name === value && (popup.kind === 'create' || c.id !== popup.id)
+ );
+
+ let helperText = '';
+ let isErrorUI = false;
+
+ if (!isEmpty && popup.kind !== 'delete') {
+ if (isDuplicate) {
+ helperText = '이미 존재하는 카테고리 이름입니다.';
+ isErrorUI = true;
+ } else if (len > MAX_LEN) {
+ helperText = `카테고리 이름은 ${MAX_LEN}자 이내로 입력해주세요.`;
+ isErrorUI = true;
+ } else if (len === MAX_LEN) {
+ helperText = `최대 ${MAX_LEN}자까지 입력할 수 있어요.`;
+ isErrorUI = false;
+ }
+ }
+
+ const handleInputChange = (next: string) => {
+ setDraft(next);
+ onChange?.(next);
+ };
+
+ const blocked =
+ popup.kind !== 'delete' && (isEmpty || isDuplicate || len > MAX_LEN);
+
+ const handleCreate = () => {
+ if (blocked) return;
+ onCreateConfirm?.();
+ };
+
+ const handleEdit = () => {
+ if (blocked || popup.kind !== 'edit') return;
+ onEditConfirm?.(popup.id, value);
+ };
+
+ const handleDelete = () => {
+ if (popup.kind !== 'delete') return;
+ onDeleteConfirm?.(popup.id);
+ };
+
+ const action = toastAction ?? (popup.kind as 'create' | 'edit' | 'delete');
+ const actionLabel =
+ action === 'create' ? '추가' : action === 'edit' ? '수정' : '삭제';
+
return createPortal(
@@ -31,10 +104,13 @@ export default function PopupPortal({
title="카테고리 추가하기"
left="취소"
right="추가"
- onInputChange={onChange}
+ isError={isErrorUI}
+ helperText={helperText}
+ inputValue={draft}
+ onInputChange={handleInputChange}
placeholder="카테고리 제목을 입력해주세요"
onLeftClick={onClose}
- onRightClick={() => onCreateConfirm?.()}
+ onRightClick={handleCreate}
/>
)}
@@ -44,10 +120,12 @@ export default function PopupPortal({
title="카테고리 수정하기"
left="취소"
right="확인"
- onInputChange={onChange}
- defaultValue={popup.name}
+ isError={isErrorUI}
+ helperText={helperText}
+ inputValue={draft}
+ onInputChange={handleInputChange}
onLeftClick={onClose}
- onRightClick={() => onEditConfirm?.(popup.id)}
+ onRightClick={handleEdit}
/>
)}
@@ -59,9 +137,22 @@ export default function PopupPortal({
left="취소"
right="삭제"
onLeftClick={onClose}
- onRightClick={() => onDeleteConfirm?.(popup.id)}
+ onRightClick={handleDelete}
/>
)}
+
+ {isToastOpen && (
+
+ )}
,
document.body
diff --git a/apps/client/src/shared/components/sidebar/Sidebar.tsx b/apps/client/src/shared/components/sidebar/Sidebar.tsx
index 103cfa33..d9ccc71f 100644
--- a/apps/client/src/shared/components/sidebar/Sidebar.tsx
+++ b/apps/client/src/shared/components/sidebar/Sidebar.tsx
@@ -17,11 +17,13 @@ import {
usePutCategory,
useDeleteCategory,
} from '@shared/apis/queries';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
export function Sidebar() {
const [newCategoryName, setNewCategoryName] = useState('');
+ const [toastIsOpen, setToastIsOpen] = useState(false);
+
const queryClient = useQueryClient();
const { data: categories } = useGetDashboardCategories();
@@ -58,6 +60,10 @@ export function Sidebar() {
setNewCategoryName(name);
};
+ useEffect(() => {
+ setToastIsOpen(false);
+ }, [popup]);
+
const handleCreateCategory = () => {
createCategory(newCategoryName, {
onSuccess: () => {
@@ -65,11 +71,12 @@ export function Sidebar() {
queryClient.invalidateQueries({ queryKey: ['dashboardCategories'] });
close();
},
- onError: (error) => {
- console.error('카테고리 생성 실패:', error);
+ onError: () => {
+ setToastIsOpen(true);
},
});
};
+
const handlePatchCategory = (id: number) => {
patchCategory(
{ id, categoryName: newCategoryName },
@@ -79,7 +86,9 @@ export function Sidebar() {
queryClient.invalidateQueries({ queryKey: ['dashboardCategories'] });
close();
},
- onError: (error) => console.error('카테고리 수정 실패:', error),
+ onError: () => {
+ setToastIsOpen(true);
+ },
}
);
};
@@ -90,12 +99,17 @@ export function Sidebar() {
queryClient.invalidateQueries({ queryKey: ['dashboardCategories'] });
close();
},
- onError: (error) => {
- console.error('카테고리 삭제 실패:', error);
+ onError: () => {
+ setToastIsOpen(true);
},
});
};
+ const handlePopupClose = () => {
+ setToastIsOpen(false);
+ close();
+ };
+
if (isPending) return ;
if (isError) return ;
const acornCount = data.acornCount;
@@ -149,7 +163,12 @@ export function Sidebar() {
/>
))}
-
+ {
+ setToastIsOpen(false);
+ openCreate();
+ }}
+ />
@@ -158,8 +177,14 @@ export function Sidebar() {
style={style ?? undefined}
categoryId={menu.categoryId}
getCategoryName={getCategoryName}
- onEdit={(id, name) => openEdit(id, name)}
- onDelete={(id, name) => openDelete(id, name)}
+ onEdit={(id, name) => {
+ setToastIsOpen(false);
+ openEdit(id, name);
+ }}
+ onDelete={(id, name) => {
+ setToastIsOpen(false);
+ openDelete(id, name);
+ }}
onClose={closeMenu}
containerRef={containerRef}
/>
@@ -180,11 +205,14 @@ export function Sidebar() {
handlePatchCategory(id)}
onDeleteConfirm={(id) => handleDeleteCategory(id)}
+ categoryList={categories?.categories ?? []}
+ isToastOpen={toastIsOpen}
+ onToastClose={() => setToastIsOpen(false)}
/>
);