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 {/* ์œ ์ € ํ”„๋กœํ•„ ๋“œ๋กญ๋‹ค์šด */} -