feat: [결재/공통] 결재함 + 레이아웃 + 캘린더 + 모바일 반응형
- 결재함 검사성적서 템플릿 기반 렌더링 + 결재 상신 - Sidebar/HeaderFavoritesBar 개선 - AuthenticatedLayout 모바일 반응형 - SearchableSelectionModal HTML 유효성 수정 - VacationManagement, 사원관리 정렬 옵션
This commit is contained in:
17
CLAUDE.md
17
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]
|
||||
### 목적: [설명]
|
||||
### 요청/응답 구조: [내용]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | undefined>();
|
||||
const [end, setEnd] = useState<string | undefined>();
|
||||
return (
|
||||
<div className="max-w-sm">
|
||||
<DateRangePicker startDate={start} endDate={end} onStartDateChange={setStart} onEndDateChange={setEnd} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DateTimePickerDemo() {
|
||||
const [v, setV] = useState<string | undefined>();
|
||||
return (
|
||||
<div className="max-w-sm">
|
||||
<DateTimePicker value={v} onChange={setV} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ColumnSettingsPopover
|
||||
columns={cols}
|
||||
onToggle={(key) => 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 (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>CRUD 다이얼로그 열기</Button>
|
||||
<GenericCRUDDialog
|
||||
isOpen={open}
|
||||
onOpenChange={setOpen}
|
||||
mode="add"
|
||||
entityName="직급"
|
||||
fields={[
|
||||
{ key: 'name', label: '직급명', type: 'text', placeholder: '직급명 입력' },
|
||||
{ key: 'status', label: '상태', type: 'select', options: [{ value: 'active', label: '활성' }, { value: 'inactive', label: '비활성' }], defaultValue: 'active' },
|
||||
]}
|
||||
onSubmit={() => 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 (
|
||||
<div className="max-w-3xl overflow-x-auto">
|
||||
<LineItemsTable
|
||||
items={items}
|
||||
getItemName={(i) => 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) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview Registry ──
|
||||
|
||||
type PreviewEntry = {
|
||||
@@ -937,6 +1028,14 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
},
|
||||
],
|
||||
|
||||
'date-range-picker.tsx': [
|
||||
{ label: 'DateRangePicker', render: () => <DateRangePickerDemo /> },
|
||||
],
|
||||
|
||||
'date-time-picker.tsx': [
|
||||
{ label: 'DateTimePicker', render: () => <DateTimePickerDemo /> },
|
||||
],
|
||||
|
||||
// ─── Atoms ───
|
||||
'BadgeSm.tsx': [
|
||||
{
|
||||
@@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
{ label: 'Filter', render: () => <MobileFilterDemo /> },
|
||||
],
|
||||
|
||||
'ColumnSettingsPopover.tsx': [
|
||||
{ label: 'Popover', render: () => <ColumnSettingsPopoverDemo /> },
|
||||
],
|
||||
|
||||
'GenericCRUDDialog.tsx': [
|
||||
{ label: 'CRUD Dialog', render: () => <GenericCRUDDialogDemo /> },
|
||||
],
|
||||
|
||||
'ReorderButtons.tsx': [
|
||||
{
|
||||
label: 'Sizes',
|
||||
render: () => (
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">sm:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">xs:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="xs" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">disabled:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={true} isLast={true} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
// ─── Organisms ───
|
||||
'EmptyState.tsx': [
|
||||
{
|
||||
@@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
'SearchableSelectionModal.tsx': [
|
||||
{ label: 'Modal', render: () => <SearchableSelectionDemo /> },
|
||||
],
|
||||
|
||||
'LineItemsTable.tsx': [
|
||||
{ label: 'Line Items', render: () => <LineItemsTableDemo /> },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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<PaginatedApiResponse<InboxApiData>>({
|
||||
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: '결재함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -681,9 +681,9 @@ export function VacationManagement() {
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
// 공통 패턴: dateRangeSelector
|
||||
// 신청현황 탭에서만 날짜 필터 표시 (사용현황/부여현황은 연간 데이터)
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
enabled: mainTab === 'request',
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
|
||||
@@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${className ?? 'w-10 h-10'}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Bookmark className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => onItemClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
|
||||
const router = useRouter();
|
||||
const { favorites } = useFavoritesStore();
|
||||
const [isTablet, setIsTablet] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chipWidthsRef = useRef<number[]>([]);
|
||||
const measuredRef = useRef(false);
|
||||
const [visibleCount, setVisibleCount] = useState(favorites.length);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(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<HTMLElement>('[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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${
|
||||
isMobile
|
||||
? 'min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px]'
|
||||
: 'w-10 h-10'
|
||||
}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<StarDropdown
|
||||
favorites={favorites}
|
||||
onItemClick={handleClick}
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] rounded-lg min-[320px]:rounded-xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: 8개 이하 → 아이콘 버튼, 9개 이상 → 별 드롭다운
|
||||
const DESKTOP_ICON_LIMIT = 8;
|
||||
|
||||
if (favorites.length > DESKTOP_ICON_LIMIT) {
|
||||
// 태블릿: 별 드롭다운 + flex-1 wrapper (데스크탑 헤더에서 오른쪽 정렬 유지)
|
||||
if (isTablet) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-10 h-10 p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex-1 min-w-0 flex items-center justify-end">
|
||||
<StarDropdown favorites={favorites} onItemClick={handleClick} className="w-10 h-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: containerRef를 항상 렌더 (ResizeObserver 안정성)
|
||||
const visibleItems = favorites.slice(0, visibleCount);
|
||||
const overflowItems = favorites.slice(visibleCount);
|
||||
const showStarOnly = measuredRef.current && visibleCount === 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex items-center gap-2">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 p-0 flex items-center justify-center transition-all duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 min-w-0 flex items-center justify-end gap-1.5"
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
{showStarOnly ? (
|
||||
<StarDropdown favorites={favorites} onItemClick={handleClick} />
|
||||
) : (
|
||||
<>
|
||||
{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 (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-chip
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
onMouseEnter={() => setHoveredId(item.id)}
|
||||
className={`rounded-full text-white h-8 flex items-center overflow-hidden ${
|
||||
isOtherHovered ? 'px-2 gap-1 bg-blue-400/70' : 'px-3 gap-1.5 bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
style={{
|
||||
transition: 'all 500ms cubic-bezier(0.25, 0.8, 0.25, 1)',
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5 shrink-0" />}
|
||||
<span
|
||||
className="text-xs whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
style={{
|
||||
maxWidth: textMaxWidth,
|
||||
transition: 'max-width 500ms cubic-bezier(0.25, 0.8, 0.25, 1), opacity 400ms ease',
|
||||
opacity: isOtherHovered ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{overflowItems.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="rounded-full bg-blue-500/80 hover:bg-blue-600 text-white h-8 px-2.5 gap-1 flex items-center shrink-0"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">+{overflowItems.length}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflowItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -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 ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,7 +224,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -291,7 +291,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<T>(props: SearchableSelectionModalProps
|
||||
listWrapper,
|
||||
infoText,
|
||||
mode,
|
||||
isItemDisabled,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
@@ -88,15 +89,20 @@ export function SearchableSelectionModal<T>(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<T>(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<T>(props: SearchableSelectionModalProps
|
||||
);
|
||||
}
|
||||
|
||||
const itemElements = items.map((item) => (
|
||||
<div key={keyExtractor(item)} onClick={() => handleItemClick(item)} className="cursor-pointer">
|
||||
{renderItem(item, isSelected(item))}
|
||||
</div>
|
||||
));
|
||||
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 래핑 없이)
|
||||
// 이렇게 하면 <TableRow> 등 테이블 요소를 <div>로 감싸는 HTML 유효성 에러를 방지
|
||||
if (isValidElement(rendered)) {
|
||||
return cloneElement(rendered as React.ReactElement<Record<string, unknown>>, {
|
||||
key,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
if (disabled) return;
|
||||
const existingOnClick = (rendered.props as Record<string, unknown>)?.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<string, unknown>)?.className || '',
|
||||
].filter(Boolean).join(' '),
|
||||
});
|
||||
}
|
||||
|
||||
// 일반 텍스트/fragment인 경우 기존 div 래핑 유지
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={disabled ? undefined : () => handleItemClick(item)}
|
||||
className={disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (listWrapper) {
|
||||
const selectState = mode === 'multiple'
|
||||
|
||||
@@ -17,8 +17,10 @@ interface BaseProps<T> {
|
||||
fetchData: (query: string) => Promise<T[]>;
|
||||
/** 고유 키 추출 */
|
||||
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(수동) */
|
||||
|
||||
@@ -1019,8 +1019,8 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
<div className="min-h-screen flex flex-col w-full">
|
||||
{/* 헤더 - 전체 너비 상단 고정 */}
|
||||
<header className="clean-glass px-8 py-3 mx-3 mt-3 mb-0 rounded-2xl clean-shadow relative overflow-hidden flex-shrink-0 sticky top-3 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex items-center gap-4 relative z-10">
|
||||
<div className="flex items-center space-x-6 shrink-0">
|
||||
{/* SAM 로고 섹션 - 클릭 시 대시보드로 이동 */}
|
||||
<div
|
||||
className="flex items-center space-x-4 pr-6 border-r border-border/30 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
@@ -1061,10 +1061,10 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 즐겨찾기 바로가기 */}
|
||||
<HeaderFavoritesBar isMobile={false} />
|
||||
{/* 즐겨찾기 바로가기 - 남은 공간 채움 */}
|
||||
<HeaderFavoritesBar isMobile={false} />
|
||||
|
||||
<div className="flex items-center space-x-3 shrink-0 ml-auto">
|
||||
{/* 알림 버튼 - 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1160,7 +1160,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
{/* 유저 프로필 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="hidden lg:flex items-center space-x-3 pl-3 border-l border-border/30 h-auto py-2 px-3 rounded-xl hover:bg-accent transition-all duration-200">
|
||||
<Button variant="ghost" size="sm" className="hidden md:flex items-center space-x-3 pl-3 border-l border-border/30 h-auto py-2 px-3 rounded-xl hover:bg-accent transition-all duration-200">
|
||||
<div className="w-11 h-11 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user