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:
@@ -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'
|
||||
)}
|
||||
>
|
||||
{/* 날짜 숫자 */}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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]" />
|
||||
)}
|
||||
|
||||
{/* 헤더 액션 영역: 날짜 범위 선택기 + 프리셋 버튼 + 등록 버튼 */}
|
||||
|
||||
Reference in New Issue
Block a user