From 72cf5d86a247de5c7d4079ccd104de38961280b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Mar 2026 03:04:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EA=B2=B0=EC=9E=AC/=EA=B3=B5=ED=86=B5]?= =?UTF-8?q?=20=EA=B2=B0=EC=9E=AC=ED=95=A8=20+=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20+=20=EC=BA=98=EB=A6=B0=EB=8D=94=20+=20?= =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=B0=98=EC=9D=91=ED=98=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 결재함 검사성적서 템플릿 기반 렌더링 + 결재 상신 - Sidebar/HeaderFavoritesBar 개선 - AuthenticatedLayout 모바일 반응형 - SearchableSelectionModal HTML 유효성 수정 - VacationManagement, 사원관리 정렬 옵션 --- CLAUDE.md | 17 +- package.json | 2 +- .../dev/component-registry/previews.tsx | 133 ++++++++ .../approval/ApprovalBox/actions.ts | 3 + src/components/approval/ApprovalBox/index.tsx | 6 +- .../hr/VacationManagement/index.tsx | 4 +- src/components/layout/HeaderFavoritesBar.tsx | 315 +++++++++++++----- src/components/layout/Sidebar.tsx | 8 +- .../SearchableSelectionModal.tsx | 79 ++++- .../SearchableSelectionModal/types.ts | 6 +- src/layouts/AuthenticatedLayout.tsx | 12 +- 11 files changed, 458 insertions(+), 127 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bfc3edcf..d963b583 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,16 +326,19 @@ const [data, setData] = useState(() => { --- -## Backend API Analysis Policy +## Backend API Policy **Priority**: 🟡 -- Backend API 코드는 **분석만**, 직접 수정 안 함 -- 수정 필요 시 백엔드 요청 문서로 정리: +- **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리 +- **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능 +- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel) +- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수 +- 신규 API가 필요한 경우 요청 문서로 정리: ```markdown -## 백엔드 API 수정 요청 -### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX) -### 현재 문제: [설명] -### 수정 요청: [내용] +## 백엔드 API 신규 요청 +### 엔드포인트: [HTTP METHOD /api/v1/path] +### 목적: [설명] +### 요청/응답 구조: [내용] ``` --- diff --git a/package.json b/package.json index be5dfdd0..cc3323bd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbo", + "dev": "next dev", "build": "next build", "build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &", "start": "next start -H 0.0.0.0", diff --git a/src/app/[locale]/(protected)/dev/component-registry/previews.tsx b/src/app/[locale]/(protected)/dev/component-registry/previews.tsx index cb955ffe..49ed9b1a 100644 --- a/src/app/[locale]/(protected)/dev/component-registry/previews.tsx +++ b/src/app/[locale]/(protected)/dev/component-registry/previews.tsx @@ -96,6 +96,14 @@ import { DataTable } from '@/components/organisms/DataTable'; import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal'; // UI - 추가 import { VisuallyHidden } from '@/components/ui/visually-hidden'; +import { DateRangePicker } from '@/components/ui/date-range-picker'; +import { DateTimePicker } from '@/components/ui/date-time-picker'; +// Molecules - 추가 +import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover'; +import { GenericCRUDDialog } from '@/components/molecules/GenericCRUDDialog'; +import { ReorderButtons } from '@/components/molecules/ReorderButtons'; +// Organisms - 추가 +import { LineItemsTable } from '@/components/organisms/LineItemsTable/LineItemsTable'; // Lucide icons for demos import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react'; @@ -339,6 +347,89 @@ function SearchableSelectionDemo() { ); } +// ── 추가 Demo Wrappers ── + +function DateRangePickerDemo() { + const [start, setStart] = useState(); + const [end, setEnd] = useState(); + return ( +
+ +
+ ); +} + +function DateTimePickerDemo() { + const [v, setV] = useState(); + return ( +
+ +
+ ); +} + +function ColumnSettingsPopoverDemo() { + const [cols, setCols] = useState([ + { key: 'name', label: '품목명', visible: true, locked: true }, + { key: 'spec', label: '규격', visible: true, locked: false }, + { key: 'qty', label: '수량', visible: true, locked: false }, + { key: 'price', label: '단가', visible: false, locked: false }, + { key: 'note', label: '비고', visible: false, locked: false }, + ]); + return ( + setCols((prev) => prev.map((c) => (c.key === key && !c.locked ? { ...c, visible: !c.visible } : c)))} + onReset={() => setCols((prev) => prev.map((c) => ({ ...c, visible: true })))} + hasHiddenColumns={cols.some((c) => !c.visible)} + /> + ); +} + +function GenericCRUDDialogDemo() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + /> + + ); +} + +function LineItemsTableDemo() { + const [items, setItems] = useState([ + { id: '1', itemName: '볼트 M10x30', quantity: 100, unitPrice: 500, supplyAmount: 50000, vat: 5000, note: '' }, + { id: '2', itemName: '너트 M10', quantity: 200, unitPrice: 300, supplyAmount: 60000, vat: 6000, note: '' }, + ]); + return ( +
+ i.itemName} + getQuantity={(i) => i.quantity} + getUnitPrice={(i) => i.unitPrice} + getSupplyAmount={(i) => i.supplyAmount} + getVat={(i) => i.vat} + getNote={(i) => i.note} + onItemChange={(idx, field, value) => setItems((prev) => prev.map((item, i) => (i === idx ? { ...item, [field]: value } : item)))} + onAddItem={() => setItems((prev) => [...prev, { id: String(prev.length + 1), itemName: '', quantity: 1, unitPrice: 0, supplyAmount: 0, vat: 0, note: '' }])} + onRemoveItem={(idx) => setItems((prev) => prev.filter((_, i) => i !== idx))} + totals={{ supplyAmount: items.reduce((s, i) => s + i.supplyAmount, 0), vat: items.reduce((s, i) => s + i.vat, 0), total: items.reduce((s, i) => s + i.supplyAmount + i.vat, 0) }} + /> +
+ ); +} + // ── Preview Registry ── type PreviewEntry = { @@ -937,6 +1028,14 @@ export const UI_PREVIEWS: Record = { }, ], + 'date-range-picker.tsx': [ + { label: 'DateRangePicker', render: () => }, + ], + + 'date-time-picker.tsx': [ + { label: 'DateTimePicker', render: () => }, + ], + // ─── Atoms ─── 'BadgeSm.tsx': [ { @@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record = { { label: 'Filter', render: () => }, ], + 'ColumnSettingsPopover.tsx': [ + { label: 'Popover', render: () => }, + ], + + 'GenericCRUDDialog.tsx': [ + { label: 'CRUD Dialog', render: () => }, + ], + + 'ReorderButtons.tsx': [ + { + label: 'Sizes', + render: () => ( +
+
+ sm: + {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="sm" /> +
+
+ xs: + {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="xs" /> +
+
+ disabled: + {}} onMoveDown={() => {}} isFirst={true} isLast={true} size="sm" /> +
+
+ ), + }, + ], + // ─── Organisms ─── 'EmptyState.tsx': [ { @@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record = { 'SearchableSelectionModal.tsx': [ { label: 'Modal', render: () => }, ], + + 'LineItemsTable.tsx': [ + { label: 'Line Items', render: () => }, + ], }; diff --git a/src/components/approval/ApprovalBox/actions.ts b/src/components/approval/ApprovalBox/actions.ts index 711a4944..7d1805af 100644 --- a/src/components/approval/ApprovalBox/actions.ts +++ b/src/components/approval/ApprovalBox/actions.ts @@ -113,6 +113,7 @@ function transformApiToFrontend(data: InboxApiData): ApprovalRecord { export async function getInbox(params?: { page?: number; per_page?: number; search?: string; status?: string; approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc'; + start_date?: string; end_date?: string; }): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number; __authError?: boolean }> { const result = await executeServerAction>({ url: buildApiUrl('/api/v1/approvals/inbox', { @@ -123,6 +124,8 @@ export async function getInbox(params?: { approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined, sort_by: params?.sort_by, sort_dir: params?.sort_dir, + start_date: params?.start_date, + end_date: params?.end_date, }), errorMessage: '결재함 목록 조회에 실패했습니다.', }); diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx index 4139f0ab..1ff38345 100644 --- a/src/components/approval/ApprovalBox/index.tsx +++ b/src/components/approval/ApprovalBox/index.tsx @@ -158,6 +158,8 @@ export function ApprovalBox() { search: searchQuery || undefined, status: activeTab !== 'all' ? activeTab : undefined, approval_type: filterOption !== 'all' ? filterOption : undefined, + start_date: startDate || undefined, + end_date: endDate || undefined, ...sortConfig, }); @@ -172,7 +174,7 @@ export function ApprovalBox() { setIsLoading(false); isInitialLoadDone.current = true; } - }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]); + }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]); // ===== 초기 로드 ===== useEffect(() => { @@ -544,7 +546,7 @@ export function ApprovalBox() { dateRangeSelector: { enabled: true, - showPresets: false, + showPresets: true, startDate, endDate, onStartDateChange: setStartDate, diff --git a/src/components/hr/VacationManagement/index.tsx b/src/components/hr/VacationManagement/index.tsx index ef6cd17d..f67e9eb1 100644 --- a/src/components/hr/VacationManagement/index.tsx +++ b/src/components/hr/VacationManagement/index.tsx @@ -681,9 +681,9 @@ export function VacationManagement() { columns: tableColumns, - // 공통 패턴: dateRangeSelector + // 신청현황 탭에서만 날짜 필터 표시 (사용현황/부여현황은 연간 데이터) dateRangeSelector: { - enabled: true, + enabled: mainTab === 'request', startDate, endDate, onStartDateChange: setStartDate, diff --git a/src/components/layout/HeaderFavoritesBar.tsx b/src/components/layout/HeaderFavoritesBar.tsx index 669002f5..d4421391 100644 --- a/src/components/layout/HeaderFavoritesBar.tsx +++ b/src/components/layout/HeaderFavoritesBar.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Star } from 'lucide-react'; +import { Bookmark, MoreHorizontal } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, @@ -20,14 +20,68 @@ import { useFavoritesStore } from '@/stores/favoritesStore'; import { iconMap } from '@/lib/utils/menuTransform'; import type { FavoriteItem } from '@/stores/favoritesStore'; +// "시스템 대시보드" 기준 텍스트 폭 (7글자 ≈ 80px) +const TEXT_DEFAULT_MAX = 80; +const TEXT_EXPANDED_MAX = 200; +const TEXT_SHRUNK_MAX = 28; +const OVERFLOW_BTN_WIDTH = 56; +const GAP = 6; + interface HeaderFavoritesBarProps { isMobile: boolean; } +/** 별 아이콘 드롭다운 (공간 부족 / 모바일 / 태블릿) */ +function StarDropdown({ + favorites, + className, + onItemClick, +}: { + favorites: FavoriteItem[]; + className?: string; + onItemClick: (item: FavoriteItem) => void; +}) { + const getIcon = (name: string) => iconMap[name] || null; + return ( + + + + + + {favorites.map((item) => { + const Icon = getIcon(item.iconName); + return ( + onItemClick(item)} + className="flex items-center gap-2 cursor-pointer" + > + {Icon && } + {item.label} + + ); + })} + + + ); +} + export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) { const router = useRouter(); const { favorites } = useFavoritesStore(); const [isTablet, setIsTablet] = useState(false); + const containerRef = useRef(null); + const chipWidthsRef = useRef([]); + const measuredRef = useRef(false); + const [visibleCount, setVisibleCount] = useState(favorites.length); + const [hoveredId, setHoveredId] = useState(null); // 태블릿 감지 (768~1024) useEffect(() => { @@ -40,6 +94,70 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps return () => window.removeEventListener('resize', check); }, []); + // 즐겨찾기 변경 시 측정 리셋 + useEffect(() => { + measuredRef.current = false; + chipWidthsRef.current = []; + setVisibleCount(favorites.length); + }, [favorites.length]); + + // 모바일/태블릿 ↔ 데스크탑 전환 시 측정 리셋 + useEffect(() => { + if (!isMobile && !isTablet) { + measuredRef.current = false; + chipWidthsRef.current = []; + setVisibleCount(favorites.length); + } + }, [isMobile, isTablet, favorites.length]); + + // 데스크탑 동적 오버플로: 전체 chip 폭 측정 → 저장 → resize 시 재계산 + useEffect(() => { + if (isMobile || isTablet) return; + const container = containerRef.current; + if (!container) return; + + const calculate = () => { + // 최초: 전체 chip 렌더 상태에서 폭 저장 + if (!measuredRef.current) { + const chips = container.querySelectorAll('[data-chip]'); + if (chips.length === favorites.length && chips.length > 0) { + chipWidthsRef.current = Array.from(chips).map((c) => c.offsetWidth); + measuredRef.current = true; + } else { + return; + } + } + + const containerWidth = container.offsetWidth; + const widths = chipWidthsRef.current; + + // 공간 부족: chip 1개 + overflow 버튼도 안 들어가면 전부 드롭다운 + const minChipWidth = Math.min(...widths); + if (containerWidth < minChipWidth + OVERFLOW_BTN_WIDTH + GAP) { + setVisibleCount(0); + return; + } + + let totalWidth = 0; + let count = 0; + + for (let i = 0; i < widths.length; i++) { + const needed = totalWidth + widths[i] + (count > 0 ? GAP : 0); + const hasMore = i < widths.length - 1; + const reserve = hasMore ? OVERFLOW_BTN_WIDTH + GAP : 0; + if (needed + reserve > containerWidth && count > 0) break; + totalWidth = needed; + count++; + } + setVisibleCount(count); + }; + + requestAnimationFrame(calculate); + const observer = new ResizeObserver(calculate); + observer.observe(container); + return () => observer.disconnect(); + }, [isMobile, isTablet, favorites.length]); + const handleClick = useCallback( (item: FavoriteItem) => { router.push(item.path); @@ -49,106 +167,121 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps if (favorites.length === 0) return null; - const getIcon = (iconName: string) => { - return iconMap[iconName] || null; - }; + const getIcon = (iconName: string) => iconMap[iconName] || null; - // 모바일 & 태블릿: 별 아이콘 드롭다운 - if (isMobile || isTablet) { + // 모바일: 별 아이콘 드롭다운 (모바일 헤더용 - flex-1 불필요) + if (isMobile) { return ( - - - - - - {favorites.map((item) => { - const Icon = getIcon(item.iconName); - return ( - handleClick(item)} - className="flex items-center gap-2 cursor-pointer" - > - {Icon && } - {item.label} - - ); - })} - - + ); } - // 데스크톱: 8개 이하 → 아이콘 버튼, 9개 이상 → 별 드롭다운 - const DESKTOP_ICON_LIMIT = 8; - - if (favorites.length > DESKTOP_ICON_LIMIT) { + // 태블릿: 별 드롭다운 + flex-1 wrapper (데스크탑 헤더에서 오른쪽 정렬 유지) + if (isTablet) { return ( - - - - - - {favorites.map((item) => { - const Icon = getIcon(item.iconName); - return ( - handleClick(item)} - className="flex items-center gap-2 cursor-pointer" - > - {Icon && } - {item.label} - - ); - })} - - +
+ +
); } + // 데스크톱: containerRef를 항상 렌더 (ResizeObserver 안정성) + const visibleItems = favorites.slice(0, visibleCount); + const overflowItems = favorites.slice(visibleCount); + const showStarOnly = measuredRef.current && visibleCount === 0; + return ( -
- {favorites.map((item) => { - const Icon = getIcon(item.iconName); - if (!Icon) return null; - return ( - - - - - -

{item.label}

-
-
- ); - })} +
setHoveredId(null)} + > + {showStarOnly ? ( + + ) : ( + <> + {visibleItems.map((item) => { + const Icon = getIcon(item.iconName); + const isHovered = hoveredId === item.id; + const isOtherHovered = hoveredId !== null && !isHovered; + + const textMaxWidth = isHovered + ? TEXT_EXPANDED_MAX + : isOtherHovered + ? TEXT_SHRUNK_MAX + : TEXT_DEFAULT_MAX; + + return ( + + + + + +

{item.label}

+
+
+ ); + })} + {overflowItems.length > 0 && ( + + + + + + {overflowItems.map((item) => { + const Icon = getIcon(item.iconName); + return ( + handleClick(item)} + className="flex items-center gap-2 cursor-pointer" + > + {Icon && } + {item.label} + + ); + })} + + + )} + + )}
); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6df741aa..fe7212f7 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react'; +import { Bookmark, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react'; import type { MenuItem } from '@/stores/menuStore'; import { useEffect, useRef, useCallback } from 'react'; import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore'; @@ -159,7 +159,7 @@ function MenuItemComponent({ }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > - + )}
@@ -224,7 +224,7 @@ function MenuItemComponent({ }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > - + )} @@ -291,7 +291,7 @@ function MenuItemComponent({ }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > - + )} diff --git a/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx b/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx index 394625be..ece26258 100644 --- a/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx +++ b/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, cloneElement, isValidElement } from 'react'; import { Search, X, Loader2 } from 'lucide-react'; import { @@ -38,6 +38,7 @@ export function SearchableSelectionModal(props: SearchableSelectionModalProps listWrapper, infoText, mode, + isItemDisabled, } = props; const { @@ -88,15 +89,20 @@ export function SearchableSelectionModal(props: SearchableSelectionModalProps }); }, []); - // 전체선택 토글 + // 전체선택 토글 (비활성 아이템 제외) const handleToggleAll = useCallback(() => { + const targetItems = isItemDisabled + ? items.filter((item) => !isItemDisabled(item, items.filter((i) => selectedIds.has(keyExtractor(i))))) + : items; setSelectedIds((prev) => { - if (prev.size === items.length) { + const targetIds = targetItems.map((item) => keyExtractor(item)); + const allSelected = targetIds.every((id) => prev.has(id)); + if (allSelected) { return new Set(); } - return new Set(items.map((item) => keyExtractor(item))); + return new Set(targetIds); }); - }, [items, keyExtractor]); + }, [items, keyExtractor, isItemDisabled, selectedIds]); // 다중선택 확인 const handleConfirm = useCallback(() => { @@ -107,16 +113,34 @@ export function SearchableSelectionModal(props: SearchableSelectionModalProps } }, [mode, items, selectedIds, keyExtractor, props, onOpenChange]); + // 선택된 아이템 목록 (isItemDisabled 콜백용) + const selectedItems = useCallback(() => { + return items.filter((item) => selectedIds.has(keyExtractor(item))); + }, [items, selectedIds, keyExtractor]); + + // 비활성 판정 + const checkDisabled = useCallback((item: T) => { + if (!isItemDisabled) return false; + // 이미 선택된 아이템은 disabled가 아님 (해제 가능해야 함) + if (selectedIds.has(keyExtractor(item))) return false; + return isItemDisabled(item, selectedItems()); + }, [isItemDisabled, selectedIds, keyExtractor, selectedItems]); + // 클릭 핸들러: 모드에 따라 분기 const handleItemClick = useCallback((item: T) => { + if (checkDisabled(item)) return; if (mode === 'single') { handleSingleSelect(item); } else { handleToggle(keyExtractor(item)); } - }, [mode, handleSingleSelect, handleToggle, keyExtractor]); + }, [mode, handleSingleSelect, handleToggle, keyExtractor, checkDisabled]); - const isAllSelected = items.length > 0 && selectedIds.size === items.length; + // 전체선택 (비활성 아이템 제외) + const enabledItems = isItemDisabled + ? items.filter((item) => !checkDisabled(item)) + : items; + const isAllSelected = enabledItems.length > 0 && enabledItems.every((item) => selectedIds.has(keyExtractor(item))); const isSelected = (item: T) => selectedIds.has(keyExtractor(item)); // 빈 상태 메시지 결정 @@ -156,11 +180,42 @@ export function SearchableSelectionModal(props: SearchableSelectionModalProps ); } - const itemElements = items.map((item) => ( -
handleItemClick(item)} className="cursor-pointer"> - {renderItem(item, isSelected(item))} -
- )); + const itemElements = items.map((item) => { + const key = keyExtractor(item); + const disabled = checkDisabled(item); + const rendered = renderItem(item, isSelected(item), disabled); + + // renderItem이 유효한 React 엘리먼트를 반환하면 key와 onClick을 직접 주입 (div 래핑 없이) + // 이렇게 하면 등 테이블 요소를
로 감싸는 HTML 유효성 에러를 방지 + if (isValidElement(rendered)) { + return cloneElement(rendered as React.ReactElement>, { + key, + onClick: (e: React.MouseEvent) => { + if (disabled) return; + const existingOnClick = (rendered.props as Record)?.onClick; + if (typeof existingOnClick === 'function') { + (existingOnClick as (e: React.MouseEvent) => void)(e); + } + handleItemClick(item); + }, + className: [ + disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer', + (rendered.props as Record)?.className || '', + ].filter(Boolean).join(' '), + }); + } + + // 일반 텍스트/fragment인 경우 기존 div 래핑 유지 + return ( +
handleItemClick(item)} + className={disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'} + > + {rendered} +
+ ); + }); if (listWrapper) { const selectState = mode === 'multiple' diff --git a/src/components/organisms/SearchableSelectionModal/types.ts b/src/components/organisms/SearchableSelectionModal/types.ts index 8e42df8c..e5bfcb8c 100644 --- a/src/components/organisms/SearchableSelectionModal/types.ts +++ b/src/components/organisms/SearchableSelectionModal/types.ts @@ -17,8 +17,10 @@ interface BaseProps { fetchData: (query: string) => Promise; /** 고유 키 추출 */ keyExtractor: (item: T) => string; - /** 아이템 렌더링 */ - renderItem: (item: T, isSelected: boolean) => ReactNode; + /** 아이템 렌더링 (isDisabled: 비활성 상태) */ + renderItem: (item: T, isSelected: boolean, isDisabled?: boolean) => ReactNode; + /** 아이템 비활성 조건 (선택된 아이템 목록 기반) */ + isItemDisabled?: (item: T, selectedItems: T[]) => boolean; // 검색 설정 /** 검색 모드: debounce(자동) vs enter(수동) */ diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index eb5e6e03..983c6e18 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -1019,8 +1019,8 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
{/* 헤더 - 전체 너비 상단 고정 */}
-
-
+
+
{/* SAM 로고 섹션 - 클릭 시 대시보드로 이동 */}
-
- {/* 즐겨찾기 바로가기 */} - + {/* 즐겨찾기 바로가기 - 남은 공간 채움 */} + +
{/* 알림 버튼 - 드롭다운 */} @@ -1160,7 +1160,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro {/* 유저 프로필 드롭다운 */} -