feat(WEB): 스켈레톤 구조 개선 및 달력 UI 개선

스켈레톤 시스템:
- 타이틀은 항상 표시, 나머지 영역만 스켈레톤 처리
- 헤더 액션, 검색, 테이블 영역 개별 스켈레톤 적용

달력 UI:
- 경계선 색상 border-gray-200으로 통일
- 지난 일자 배경색 bg-gray-300으로 더 어둡게
- 선택/오늘 날짜 색상 보라색으로 변경 (이벤트 바와 구분)
- 날짜-이벤트 바 간격 8px 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-22 22:31:40 +09:00
parent 208f4d08e5
commit 1575f9e680
5 changed files with 79 additions and 71 deletions

View File

@@ -52,10 +52,10 @@ export function DayCell({
isWeekend && isCurrentMonth && !isPast && 'text-red-500',
// 지난 주말 - 연한 색상
isWeekend && isCurrentMonth && isPast && !isToday && !isSelected && 'text-red-300',
// 오늘 - 굵은 글씨 (외곽선은 부모 셀에 적용)
isToday && !isSelected && 'font-bold text-primary',
// 선택됨 - 배경색 하이라이트
isSelected && 'bg-primary text-primary-foreground hover:bg-primary'
// 오늘 - 굵은 글씨 (외곽선은 부모 셀에 적용) - 보라색
isToday && !isSelected && 'font-bold text-purple-600',
// 선택됨 - 배경색 하이라이트 - 보라색
isSelected && 'bg-purple-500 text-white hover:bg-purple-600'
)}
>
{/* 날짜 숫자 */}

View File

@@ -158,11 +158,11 @@ function WeekRow({
// 셀 최소 높이 계산 (이벤트 행 수에 따라) - 더 넉넉하게 확보
const segmentRowIndices = eventSegments.map(s => globalRowAssignments.get(s.event.id) || 0);
const maxRowIndex = Math.max(0, ...segmentRowIndices);
const rowHeight = Math.max(120, 40 + Math.min(maxRowIndex + 1, visibleRows) * 28 + 24);
const rowHeight = Math.max(120, 48 + Math.min(maxRowIndex + 1, visibleRows) * 28 + 24);
return (
<div
className="relative grid grid-cols-7 border-b last:border-b-0"
className="relative grid grid-cols-7 border-b border-gray-200 last:border-b-0"
style={{ minHeight: `${rowHeight}px` }}
>
{/* 날짜 셀들 */}
@@ -182,15 +182,15 @@ function WeekRow({
<div
key={date.toISOString()}
className={cn(
'relative p-1 border-r last:border-r-0',
'relative p-1 border-r border-gray-200 last:border-r-0',
'flex flex-col cursor-pointer transition-colors',
// 기본 배경
!isCurrMonth && 'bg-muted/30',
// 지난 일자 - 회색 배경 (현재 월, 오늘/선택 제외)
isPast && isCurrMonth && !isToday && !isSelected && 'bg-gray-200 dark:bg-gray-700',
isPast && isCurrMonth && !isToday && !isSelected && 'bg-gray-300 dark:bg-gray-700',
// 오늘 - 셀 전체 외곽선 하이라이트
isToday && !isSelected && 'ring-2 ring-primary ring-inset',
// 선택된 날짜 - 셀 전체 배경색 변경 (테두리 없이)
// 선택된 날짜 - 셀 전체 배경색 변경
isSelected && 'bg-primary/15'
)}
onClick={() => onDateClick(date)}

View File

@@ -76,7 +76,7 @@ export function ScheduleBar({
style={{
width: `calc(${widthPercent}% - 4px)`,
left: `calc(${leftPercent}% + 2px)`,
top: `${rowIndex * 24 + 32}px`, // 날짜 영역(32px) 아래부터 시작
top: `${rowIndex * 24 + 40}px`, // 날짜 영역(40px) 아래부터 시작 (간격 8px 추가)
}}
>
{isStart && <span className="truncate">{event.title}</span>}

View File

@@ -483,30 +483,35 @@ export function IntegratedListTemplateV2<T = any>({
setShowDeleteDialog(false);
};
// 로딩 시 전체 페이지 스켈레톤 표시
// - isLoading이 true면 전체 스켈레톤 표시 (헤더, 달력, 버튼, 카드, 테이블 모두)
// - 이렇게 하면 "따닥" 현상 없이 매끄러운 로딩 경험 제공
if (isLoading) {
return (
<PageLayout>
<ListPageSkeleton
showHeader={true}
showDateRange={dateRangeSelector?.enabled ?? false}
showCreateButton={!!createButton}
showFilters={!hideSearch}
showStats={stats !== undefined && stats.length > 0}
statsCount={stats?.length || 4}
tableRows={pagination.itemsPerPage || 10}
tableColumns={tableColumns.length || 6}
mobileCards={6}
/>
</PageLayout>
);
}
// 헤더 액션 스켈레톤 (달력 + 프리셋 버튼 + 등록 버튼)
const renderHeaderActionSkeleton = () => (
<div className="flex items-center gap-2 flex-wrap w-full">
{dateRangeSelector?.enabled && (
<>
<div className="flex items-center gap-2">
<div className="h-10 w-[140px] rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
<div className="h-4 w-4 bg-gray-300 rounded animate-pulse" />
<div className="h-10 w-[140px] rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
</div>
<div className="hidden md:flex items-center gap-1.5">
<div className="h-8 w-16 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
<div className="h-8 w-14 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
<div className="h-8 w-12 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
<div className="h-8 w-12 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
<div className="h-8 w-10 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
<div className="h-8 w-10 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
</div>
</>
)}
{createButton && (
<div className="h-10 w-28 rounded-md border border-gray-200 bg-gray-100 animate-pulse ml-auto" />
)}
</div>
);
return (
<PageLayout>
{/* 페이지 헤더 */}
{/* 페이지 헤더 - 항상 표시 */}
<PageHeader
title={title}
description={description}
@@ -516,31 +521,33 @@ export function IntegratedListTemplateV2<T = any>({
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
{/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
{(dateRangeSelector?.enabled || createButton || headerActions) && (
<div className="flex items-center gap-2 flex-wrap w-full">
{/* 날짜 범위 선택기 (왼쪽) */}
{dateRangeSelector?.enabled && (
<DateRangeSelector
startDate={dateRangeSelector.startDate || ''}
endDate={dateRangeSelector.endDate || ''}
onStartDateChange={dateRangeSelector.onStartDateChange}
onEndDateChange={dateRangeSelector.onEndDateChange}
hidePresets={dateRangeSelector.showPresets === false}
/>
)}
{/* 레거시 헤더 액션 (기존 호환성 유지) */}
{headerActions}
{/* 등록 버튼 (오른쪽 끝) */}
{createButton && (
<Button className="ml-auto" onClick={createButton.onClick}>
{createButton.icon ? (
<createButton.icon className="h-4 w-4 mr-2" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{createButton.label}
</Button>
)}
</div>
isLoading ? renderHeaderActionSkeleton() : (
<div className="flex items-center gap-2 flex-wrap w-full">
{/* 날짜 범위 선택기 (왼쪽) */}
{dateRangeSelector?.enabled && (
<DateRangeSelector
startDate={dateRangeSelector.startDate || ''}
endDate={dateRangeSelector.endDate || ''}
onStartDateChange={dateRangeSelector.onStartDateChange}
onEndDateChange={dateRangeSelector.onEndDateChange}
hidePresets={dateRangeSelector.showPresets === false}
/>
)}
{/* 레거시 헤더 액션 (기존 호환성 유지) */}
{headerActions}
{/* 등록 버튼 (오른쪽 끝) */}
{createButton && (
<Button className="ml-auto" onClick={createButton.onClick}>
{createButton.icon ? (
<createButton.icon className="h-4 w-4 mr-2" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{createButton.label}
</Button>
)}
</div>
)
)}
{/* 커스텀 탭 콘텐츠 (헤더 아래, 검색 위) */}
@@ -576,13 +583,20 @@ export function IntegratedListTemplateV2<T = any>({
{!hideSearch && (
<Card>
<CardContent className="p-6">
<SearchFilter
searchValue={searchValue || ''}
onSearchChange={onSearchChange || (() => {})}
searchPlaceholder={searchPlaceholder}
filterButton={false}
extraActions={extraFilters}
/>
{isLoading ? (
<div className="flex flex-wrap gap-4">
<div className="h-10 w-64 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
<div className="h-10 w-32 rounded-md border border-gray-200 bg-gray-100 animate-pulse" />
</div>
) : (
<SearchFilter
searchValue={searchValue || ''}
onSearchChange={onSearchChange || (() => {})}
searchPlaceholder={searchPlaceholder}
filterButton={false}
extraActions={extraFilters}
/>
)}
</CardContent>
</Card>
)}

View File

@@ -403,15 +403,9 @@ function ListPageSkeleton({
}: ListPageSkeletonProps) {
return (
<div className="space-y-6">
{/* 페이지 헤더 (타이틀 + 설명) */}
{/* 페이지 헤더 (타이틀 + 설명) - 스켈레톤 없이 빈 공간만 */}
{showHeader && (
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-gray-200 animate-pulse" />
<div className="space-y-1.5">
<div className="h-7 w-40 rounded bg-gray-200 animate-pulse" />
<div className="h-4 w-56 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="h-[52px]" />
)}
{/* 헤더 액션 영역: 날짜 범위 선택기 + 프리셋 버튼 + 등록 버튼 */}