feat(ui): 스케줄 화면 검색/정렬/필터 기능 추가 및 UI 구조 개선 (#54) #56
@ -32,6 +32,7 @@
|
|||||||
- Job 한글 표시명 DB 관리 및 전체 화면 통합 (#45)
|
- Job 한글 표시명 DB 관리 및 전체 화면 통합 (#45)
|
||||||
- 배치 모니터링 UI 최적화: 대시보드 퀵 네비 제거, AIS 필터 프리셋, 스케줄 뷰 토글 및 폼 모달 전환 (#46)
|
- 배치 모니터링 UI 최적화: 대시보드 퀵 네비 제거, AIS 필터 프리셋, 스케줄 뷰 토글 및 폼 모달 전환 (#46)
|
||||||
- 각 화면별 사용자 가이드 추가 (#41)
|
- 각 화면별 사용자 가이드 추가 (#41)
|
||||||
|
- 스케줄 화면 검색/정렬/필터 기능 추가 및 UI 구조 개선 (#54)
|
||||||
|
|
||||||
### 수정
|
### 수정
|
||||||
- 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결)
|
- 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결)
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { useThemeContext } from '../contexts/ThemeContext';
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: '대시보드', icon: '📊' },
|
{ path: '/', label: '대시보드', icon: '📊' },
|
||||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
|
||||||
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
||||||
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
||||||
|
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -10,6 +10,19 @@ import GuideModal, { HelpButton } from '../components/GuideModal';
|
|||||||
|
|
||||||
type ScheduleMode = 'new' | 'existing';
|
type ScheduleMode = 'new' | 'existing';
|
||||||
type ScheduleViewMode = 'card' | 'table';
|
type ScheduleViewMode = 'card' | 'table';
|
||||||
|
type ActiveFilterKey = 'ALL' | 'ACTIVE' | 'INACTIVE';
|
||||||
|
type ScheduleSortKey = 'name' | 'nextFire' | 'active';
|
||||||
|
|
||||||
|
interface ActiveTabConfig {
|
||||||
|
key: ActiveFilterKey;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_TABS: ActiveTabConfig[] = [
|
||||||
|
{ key: 'ALL', label: '전체' },
|
||||||
|
{ key: 'ACTIVE', label: '활성' },
|
||||||
|
{ key: 'INACTIVE', label: '비활성' },
|
||||||
|
];
|
||||||
|
|
||||||
interface ConfirmAction {
|
interface ConfirmAction {
|
||||||
type: 'toggle' | 'delete';
|
type: 'toggle' | 'delete';
|
||||||
@ -122,6 +135,11 @@ export default function Schedules() {
|
|||||||
// View mode state
|
// View mode state
|
||||||
const [viewMode, setViewMode] = useState<ScheduleViewMode>('table');
|
const [viewMode, setViewMode] = useState<ScheduleViewMode>('table');
|
||||||
|
|
||||||
|
// Search / filter / sort state
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [activeFilter, setActiveFilter] = useState<ActiveFilterKey>('ALL');
|
||||||
|
const [sortKey, setSortKey] = useState<ScheduleSortKey>('name');
|
||||||
|
|
||||||
// Confirm modal state
|
// Confirm modal state
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
|
||||||
@ -164,6 +182,75 @@ export default function Schedules() {
|
|||||||
return map;
|
return map;
|
||||||
}, [displayNames]);
|
}, [displayNames]);
|
||||||
|
|
||||||
|
const activeCounts = useMemo(() => {
|
||||||
|
const searchFiltered = searchTerm.trim()
|
||||||
|
? schedules.filter((s) => {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return s.jobName.toLowerCase().includes(term)
|
||||||
|
|| (displayNameMap[s.jobName]?.toLowerCase().includes(term) ?? false)
|
||||||
|
|| (s.description?.toLowerCase().includes(term) ?? false);
|
||||||
|
})
|
||||||
|
: schedules;
|
||||||
|
|
||||||
|
return ACTIVE_TABS.reduce<Record<ActiveFilterKey, number>>(
|
||||||
|
(acc, tab) => {
|
||||||
|
acc[tab.key] = searchFiltered.filter((s) => {
|
||||||
|
if (tab.key === 'ALL') return true;
|
||||||
|
if (tab.key === 'ACTIVE') return s.active;
|
||||||
|
return !s.active;
|
||||||
|
}).length;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ ALL: 0, ACTIVE: 0, INACTIVE: 0 },
|
||||||
|
);
|
||||||
|
}, [schedules, searchTerm, displayNameMap]);
|
||||||
|
|
||||||
|
const filteredSchedules = useMemo(() => {
|
||||||
|
let result = schedules;
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchTerm.trim()) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
result = result.filter((s) =>
|
||||||
|
s.jobName.toLowerCase().includes(term)
|
||||||
|
|| (displayNameMap[s.jobName]?.toLowerCase().includes(term) ?? false)
|
||||||
|
|| (s.description?.toLowerCase().includes(term) ?? false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성/비활성 필터
|
||||||
|
if (activeFilter === 'ACTIVE') {
|
||||||
|
result = result.filter((s) => s.active);
|
||||||
|
} else if (activeFilter === 'INACTIVE') {
|
||||||
|
result = result.filter((s) => !s.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
if (sortKey === 'name') {
|
||||||
|
const aName = displayNameMap[a.jobName] || a.jobName;
|
||||||
|
const bName = displayNameMap[b.jobName] || b.jobName;
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
}
|
||||||
|
if (sortKey === 'nextFire') {
|
||||||
|
const aTime = a.nextFireTime ? new Date(a.nextFireTime).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
|
const bTime = b.nextFireTime ? new Date(b.nextFireTime).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
|
return aTime - bTime;
|
||||||
|
}
|
||||||
|
if (sortKey === 'active') {
|
||||||
|
if (a.active === b.active) {
|
||||||
|
const aName = displayNameMap[a.jobName] || a.jobName;
|
||||||
|
const bName = displayNameMap[b.jobName] || b.jobName;
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
}
|
||||||
|
return a.active ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [schedules, searchTerm, activeFilter, sortKey, displayNameMap]);
|
||||||
|
|
||||||
const handleJobSelect = async (jobName: string) => {
|
const handleJobSelect = async (jobName: string) => {
|
||||||
setSelectedJob(jobName);
|
setSelectedJob(jobName);
|
||||||
setCronExpression('');
|
setCronExpression('');
|
||||||
@ -275,6 +362,11 @@ export default function Schedules() {
|
|||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getScheduleLabel = (schedule: ScheduleResponse) =>
|
||||||
|
displayNameMap[schedule.jobName] || schedule.jobName;
|
||||||
|
|
||||||
|
if (listLoading) return <LoadingSpinner />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Form Modal */}
|
{/* Form Modal */}
|
||||||
@ -409,179 +501,262 @@ export default function Schedules() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Schedule List */}
|
{/* Header */}
|
||||||
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-wing-text">스케줄 관리</h1>
|
||||||
<h2 className="text-lg font-bold text-wing-text">
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
||||||
등록된 스케줄
|
</div>
|
||||||
{schedules.length > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<span className="ml-2 text-sm font-normal text-wing-muted">
|
<button
|
||||||
({schedules.length}개)
|
onClick={handleNewSchedule}
|
||||||
</span>
|
className="px-3 py-1.5 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors"
|
||||||
)}
|
>
|
||||||
</h2>
|
+ 새 스케줄
|
||||||
<HelpButton onClick={() => setGuideOpen(true)} />
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={loadSchedules}
|
||||||
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-wing-muted">
|
||||||
|
총 {schedules.length}개 스케줄
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filter Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ACTIVE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveFilter(tab.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
activeFilter === tab.key
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold ${
|
||||||
|
activeFilter === tab.key
|
||||||
|
? 'bg-white/25 text-white'
|
||||||
|
: 'bg-wing-border text-wing-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activeCounts[tab.key]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Sort + View Toggle */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="작업명 또는 설명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
{/* Sort dropdown */}
|
||||||
|
<select
|
||||||
|
value={sortKey}
|
||||||
|
onChange={(e) => setSortKey(e.target.value as ScheduleSortKey)}
|
||||||
|
className="px-3 py-2 border border-wing-border rounded-lg text-sm bg-wing-surface text-wing-text
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
>
|
||||||
|
<option value="name">작업명순</option>
|
||||||
|
<option value="nextFire">다음 실행순</option>
|
||||||
|
<option value="active">활성 우선</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={handleNewSchedule}
|
onClick={() => setViewMode('table')}
|
||||||
className="px-3 py-1.5 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors"
|
title="테이블 보기"
|
||||||
|
className={`px-3 py-2 transition-colors ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
+ 새 스케줄
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('table')}
|
|
||||||
className={`p-1.5 ${viewMode === 'table' ? 'bg-wing-accent text-white' : 'bg-wing-surface text-wing-muted hover:bg-wing-hover'}`}
|
|
||||||
title="테이블 보기"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('card')}
|
|
||||||
className={`p-1.5 ${viewMode === 'card' ? 'bg-wing-accent text-white' : 'bg-wing-surface text-wing-muted hover:bg-wing-hover'}`}
|
|
||||||
title="카드 보기"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={loadSchedules}
|
onClick={() => setViewMode('card')}
|
||||||
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
title="카드 보기"
|
||||||
|
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
새로고침
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{listLoading ? (
|
{searchTerm && (
|
||||||
<LoadingSpinner />
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
) : schedules.length === 0 ? (
|
{filteredSchedules.length}개 스케줄 검색됨
|
||||||
<EmptyState message="등록된 스케줄이 없습니다" sub="'+ 새 스케줄' 버튼을 클릭하여 등록하세요" />
|
</p>
|
||||||
) : viewMode === 'card' ? (
|
)}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
</div>
|
||||||
{schedules.map((schedule) => (
|
|
||||||
<div
|
|
||||||
key={schedule.id}
|
|
||||||
className="border border-wing-border rounded-xl p-4 hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<h3 className="text-sm font-bold text-wing-text truncate" title={schedule.jobName}>
|
|
||||||
{displayNameMap[schedule.jobName] || schedule.jobName}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
|
||||||
schedule.active
|
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
|
||||||
: 'bg-wing-card text-wing-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{schedule.active ? '활성' : '비활성'}
|
|
||||||
</span>
|
|
||||||
{schedule.triggerState && (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getTriggerStateStyle(schedule.triggerState)}`}
|
|
||||||
>
|
|
||||||
{schedule.triggerState}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cron Expression */}
|
{/* Schedule List */}
|
||||||
<div className="mb-2">
|
{filteredSchedules.length === 0 ? (
|
||||||
<span className="inline-block bg-wing-card text-wing-text font-mono text-xs px-2 py-1 rounded">
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon="🔍"
|
||||||
|
message={searchTerm || activeFilter !== 'ALL' ? '검색 결과가 없습니다.' : '등록된 스케줄이 없습니다.'}
|
||||||
|
sub={searchTerm || activeFilter !== 'ALL' ? '다른 검색어나 필터를 사용해 보세요.' : "'+ 새 스케줄' 버튼을 클릭하여 등록하세요"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'card' ? (
|
||||||
|
/* Card View */
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredSchedules.map((schedule) => (
|
||||||
|
<div
|
||||||
|
key={schedule.id}
|
||||||
|
className={`bg-wing-surface rounded-xl shadow-md p-6
|
||||||
|
hover:shadow-lg hover:-translate-y-0.5 transition-all
|
||||||
|
${!schedule.active ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
||||||
|
{getScheduleLabel(schedule)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||||
|
schedule.active
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{schedule.active ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
{schedule.triggerState && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getTriggerStateStyle(schedule.triggerState)}`}
|
||||||
|
>
|
||||||
|
{schedule.triggerState}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Info */}
|
||||||
|
<div className="mb-4 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded text-wing-muted">
|
||||||
{schedule.cronExpression}
|
{schedule.cronExpression}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
{/* Description */}
|
다음 실행: {formatDateTime(schedule.nextFireTime)}
|
||||||
{schedule.description && (
|
</p>
|
||||||
<p className="text-sm text-wing-muted mb-3">{schedule.description}</p>
|
{schedule.previousFireTime && (
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
이전 실행: {formatDateTime(schedule.previousFireTime)}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time Info */}
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs text-wing-muted mb-3">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-wing-muted">다음 실행:</span>{' '}
|
|
||||||
{formatDateTime(schedule.nextFireTime)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-wing-muted">이전 실행:</span>{' '}
|
|
||||||
{formatDateTime(schedule.previousFireTime)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-wing-muted">등록일:</span>{' '}
|
|
||||||
{formatDateTime(schedule.createdAt)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-wing-muted">수정일:</span>{' '}
|
|
||||||
{formatDateTime(schedule.updatedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-2 pt-2 border-t border-wing-border/50">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditFromCard(schedule)}
|
|
||||||
className="flex-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/20 transition-colors"
|
|
||||||
>
|
|
||||||
편집
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setConfirmAction({ type: 'toggle', schedule })
|
|
||||||
}
|
|
||||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
|
||||||
schedule.active
|
|
||||||
? 'text-amber-700 bg-amber-50 hover:bg-amber-100'
|
|
||||||
: 'text-emerald-700 bg-emerald-50 hover:bg-emerald-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{schedule.active ? '비활성화' : '활성화'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setConfirmAction({ type: 'delete', schedule })
|
|
||||||
}
|
|
||||||
className="flex-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
{/* Action Buttons */}
|
||||||
) : (
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditFromCard(schedule)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
|
||||||
|
hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
|
||||||
|
className={`flex-1 px-3 py-2 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
schedule.active
|
||||||
|
? 'text-amber-700 bg-amber-50 hover:bg-amber-100'
|
||||||
|
: 'text-emerald-700 bg-emerald-50 hover:bg-emerald-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{schedule.active ? '비활성화' : '활성화'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', schedule })}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium text-red-700 bg-red-50 rounded-lg
|
||||||
|
hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Table View */
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-wing-border text-left text-wing-muted">
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
<th className="px-4 py-3 font-medium">작업명</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">작업명</th>
|
||||||
<th className="px-4 py-3 font-medium">Cron 표현식</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Cron 표현식</th>
|
||||||
<th className="px-4 py-3 font-medium">상태</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">상태</th>
|
||||||
<th className="px-4 py-3 font-medium">다음 실행</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">다음 실행</th>
|
||||||
<th className="px-4 py-3 font-medium">이전 실행</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">이전 실행</th>
|
||||||
<th className="px-4 py-3 font-medium text-right">액션</th>
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">액션</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-wing-border/50">
|
<tbody className="divide-y divide-wing-border">
|
||||||
{schedules.map((schedule) => (
|
{filteredSchedules.map((schedule) => (
|
||||||
<tr key={schedule.id} className="hover:bg-wing-hover transition-colors">
|
<tr
|
||||||
<td className="px-4 py-3">
|
key={schedule.id}
|
||||||
<div className="font-medium text-wing-text" title={schedule.jobName}>
|
className={`hover:bg-wing-hover transition-colors ${!schedule.active ? 'opacity-60' : ''}`}
|
||||||
{displayNameMap[schedule.jobName] || schedule.jobName}
|
>
|
||||||
</div>
|
<td className="px-4 py-3 font-medium text-wing-text break-all">
|
||||||
{schedule.description && (
|
{getScheduleLabel(schedule)}
|
||||||
<div className="text-xs text-wing-muted mt-0.5">{schedule.description}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">{schedule.cronExpression}</span>
|
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">{schedule.cronExpression}</span>
|
||||||
@ -593,19 +768,20 @@ export default function Schedules() {
|
|||||||
{schedule.active ? '활성' : '비활성'}
|
{schedule.active ? '활성' : '비활성'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs text-wing-muted">{formatDateTime(schedule.nextFireTime)}</td>
|
<td className="px-4 py-3 text-wing-muted">{formatDateTime(schedule.nextFireTime)}</td>
|
||||||
<td className="px-4 py-3 text-xs text-wing-muted">{schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'}</td>
|
<td className="px-4 py-3 text-wing-muted">{schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditFromCard(schedule)}
|
onClick={() => handleEditFromCard(schedule)}
|
||||||
className="px-2 py-1 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded hover:bg-wing-accent/20 transition-colors"
|
className="px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
|
||||||
|
hover:bg-wing-accent/15 transition-colors"
|
||||||
>
|
>
|
||||||
편집
|
편집
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
|
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
|
||||||
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
schedule.active
|
schedule.active
|
||||||
? 'text-amber-600 bg-amber-50 hover:bg-amber-100'
|
? 'text-amber-600 bg-amber-50 hover:bg-amber-100'
|
||||||
: 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100'
|
: 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100'
|
||||||
@ -615,7 +791,8 @@ export default function Schedules() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'delete', schedule })}
|
onClick={() => setConfirmAction({ type: 'delete', schedule })}
|
||||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded hover:bg-red-100 transition-colors"
|
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg
|
||||||
|
hover:bg-red-100 transition-colors"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
@ -626,8 +803,8 @@ export default function Schedules() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Confirm Modal */}
|
{/* Confirm Modal */}
|
||||||
{confirmAction?.type === 'toggle' && (
|
{confirmAction?.type === 'toggle' && (
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user