Merge pull request 'feat(ui): 배치 모니터링 UI 최적화 (#46)' (#48) from feature/ISSUE-46-ui-optimization into develop
This commit is contained in:
커밋
98e67def93
@ -30,6 +30,7 @@
|
||||
- IMO 기반 Risk 상세 조회 bypass API 추가 (#39)
|
||||
- 배치 작업 목록 한글 표시명 추가 (#40)
|
||||
- Job 한글 표시명 DB 관리 및 전체 화면 통합 (#45)
|
||||
- 배치 모니터링 UI 최적화: 대시보드 퀵 네비 제거, AIS 필터 프리셋, 스케줄 뷰 토글 및 폼 모달 전환 (#46)
|
||||
|
||||
### 수정
|
||||
- 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결)
|
||||
|
||||
@ -193,34 +193,6 @@ export default function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Navigation */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to="/jobs"
|
||||
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||
>
|
||||
작업 관리
|
||||
</Link>
|
||||
<Link
|
||||
to="/executions"
|
||||
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||
>
|
||||
실행 이력
|
||||
</Link>
|
||||
<Link
|
||||
to="/schedules"
|
||||
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||
>
|
||||
스케줄 관리
|
||||
</Link>
|
||||
<Link
|
||||
to="/schedule-timeline"
|
||||
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
|
||||
>
|
||||
타임라인
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Running Jobs */}
|
||||
<section className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||
<h2 className="text-lg font-semibold text-wing-text mb-4">
|
||||
|
||||
@ -67,6 +67,9 @@ export default function Executions() {
|
||||
return map;
|
||||
}, [displayNames]);
|
||||
|
||||
const aisJobs = useMemo(() => jobs.filter(j => j.toLowerCase().startsWith('ais')), [jobs]);
|
||||
const nonAisJobs = useMemo(() => jobs.filter(j => !j.toLowerCase().startsWith('ais')), [jobs]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
const data = await batchApi.getJobs();
|
||||
@ -287,6 +290,38 @@ export default function Executions() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => setSelectedJobs([])}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedJobs.length === 0
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedJobs(nonAisJobs)}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedJobs.length > 0 && selectedJobs.length === nonAisJobs.length && selectedJobs.every(j => nonAisJobs.includes(j))
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||
}`}
|
||||
>
|
||||
AIS 제외
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedJobs([...aisJobs])}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedJobs.length > 0 && selectedJobs.length === aisJobs.length && selectedJobs.every(j => aisJobs.includes(j))
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||
}`}
|
||||
>
|
||||
AIS만
|
||||
</button>
|
||||
</div>
|
||||
{selectedJobs.length > 0 && (
|
||||
<button
|
||||
onClick={clearSelectedJobs}
|
||||
|
||||
@ -53,8 +53,8 @@ export default function Jobs() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>('ALL');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('card');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('recent');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||
|
||||
// Execute modal (individual card)
|
||||
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||
@ -243,10 +243,23 @@ export default function Jobs() {
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
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
|
||||
onClick={() => setViewMode('card')}
|
||||
title="카드 보기"
|
||||
className={`px-3 py-2 transition-colors ${
|
||||
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'
|
||||
@ -261,19 +274,6 @@ export default function Jobs() {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
title="테이블 보기"
|
||||
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -316,9 +316,6 @@ export default function Jobs() {
|
||||
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
||||
{getJobLabel(job)}
|
||||
</h3>
|
||||
{job.displayName && (
|
||||
<p className="text-xs text-wing-muted mt-0.5">{job.jobName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||
{isRunning && (
|
||||
@ -436,12 +433,7 @@ export default function Jobs() {
|
||||
{isRunning && (
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<span>{getJobLabel(job)}</span>
|
||||
{job.displayName && (
|
||||
<p className="text-xs text-wing-muted font-normal">{job.jobName}</p>
|
||||
)}
|
||||
</div>
|
||||
<span>{getJobLabel(job)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@ -477,7 +477,7 @@ export default function Recollects() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-wing-border/50">
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">API명</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">배치 작업명</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">마지막 수집 완료일시</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">경과시간</th>
|
||||
<th className="text-center py-2 px-3 text-xs font-medium text-wing-muted">상태</th>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { batchApi, type ScheduleResponse, type JobDisplayName } from '../api/batchApi';
|
||||
import { formatDateTime } from '../utils/formatters';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
@ -8,6 +8,7 @@ import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { getNextExecutions } from '../utils/cronPreview';
|
||||
|
||||
type ScheduleMode = 'new' | 'existing';
|
||||
type ScheduleViewMode = 'card' | 'table';
|
||||
|
||||
interface ConfirmAction {
|
||||
type: 'toggle' | 'delete';
|
||||
@ -95,11 +96,14 @@ export default function Schedules() {
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
|
||||
|
||||
// View mode state
|
||||
const [viewMode, setViewMode] = useState<ScheduleViewMode>('table');
|
||||
|
||||
// Confirm modal state
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
|
||||
// 폼 영역 ref (편집 버튼 클릭 시 스크롤)
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
// Form modal state
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
|
||||
const loadSchedules = useCallback(async () => {
|
||||
try {
|
||||
@ -186,8 +190,8 @@ export default function Schedules() {
|
||||
showToast('스케줄이 등록되었습니다', 'success');
|
||||
}
|
||||
await loadSchedules();
|
||||
// Reload schedule info for current job
|
||||
await handleJobSelect(selectedJob);
|
||||
setFormOpen(false);
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '저장 실패';
|
||||
showToast(message, 'error');
|
||||
@ -216,12 +220,10 @@ export default function Schedules() {
|
||||
await batchApi.deleteSchedule(schedule.jobName);
|
||||
showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success');
|
||||
await loadSchedules();
|
||||
// Clear form if deleted schedule was selected
|
||||
// Close form if deleted schedule was being edited
|
||||
if (selectedJob === schedule.jobName) {
|
||||
setSelectedJob('');
|
||||
setCronExpression('');
|
||||
setDescription('');
|
||||
setScheduleMode('new');
|
||||
resetForm();
|
||||
setFormOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '삭제 실패';
|
||||
@ -230,124 +232,159 @@ export default function Schedules() {
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedJob('');
|
||||
setCronExpression('');
|
||||
setDescription('');
|
||||
setScheduleMode('new');
|
||||
};
|
||||
|
||||
const handleEditFromCard = (schedule: ScheduleResponse) => {
|
||||
setSelectedJob(schedule.jobName);
|
||||
setCronExpression(schedule.cronExpression);
|
||||
setDescription(schedule.description ?? '');
|
||||
setScheduleMode('existing');
|
||||
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleNewSchedule = () => {
|
||||
resetForm();
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Form Section */}
|
||||
<div ref={formRef} className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-bold text-wing-text mb-4">스케줄 등록 / 수정</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Job Select */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
작업 선택
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedJob}
|
||||
onChange={(e) => handleJobSelect(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={formLoading}
|
||||
{/* Form Modal */}
|
||||
{formOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setFormOpen(false)} />
|
||||
<div className="relative bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-wing-text">
|
||||
{scheduleMode === 'existing' ? '스케줄 수정' : '스케줄 등록'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setFormOpen(false)}
|
||||
className="p-1 text-wing-muted hover:text-wing-text transition-colors"
|
||||
>
|
||||
<option value="">-- 작업을 선택하세요 --</option>
|
||||
{jobs.map((job) => (
|
||||
<option key={job} value={job}>
|
||||
{displayNameMap[job] || job}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedJob && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
|
||||
scheduleMode === 'existing'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
|
||||
</span>
|
||||
)}
|
||||
{formLoading && (
|
||||
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||
)}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron Expression */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
Cron 표현식
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="0 0/15 * * * ?"
|
||||
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={!selectedJob || formLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Job Select */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
작업 선택
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedJob}
|
||||
onChange={(e) => handleJobSelect(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={formLoading || scheduleMode === 'existing'}
|
||||
>
|
||||
<option value="">-- 작업을 선택하세요 --</option>
|
||||
{jobs.map((job) => (
|
||||
<option key={job} value={job}>
|
||||
{displayNameMap[job] || job}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedJob && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
|
||||
scheduleMode === 'existing'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
|
||||
</span>
|
||||
)}
|
||||
{formLoading && (
|
||||
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron Presets */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
프리셋
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CRON_PRESETS.map(({ label, cron }) => (
|
||||
<button
|
||||
key={cron}
|
||||
type="button"
|
||||
onClick={() => setCronExpression(cron)}
|
||||
{/* Cron Expression */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
Cron 표현식
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="0 0/15 * * * ?"
|
||||
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={!selectedJob || formLoading}
|
||||
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cron Presets */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
프리셋
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CRON_PRESETS.map(({ label, cron }) => (
|
||||
<button
|
||||
key={cron}
|
||||
type="button"
|
||||
onClick={() => setCronExpression(cron)}
|
||||
disabled={!selectedJob || formLoading}
|
||||
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron Preview */}
|
||||
{cronExpression.trim() && (
|
||||
<CronPreview cron={cronExpression.trim()} />
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
설명
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="스케줄 설명 (선택)"
|
||||
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={!selectedJob || formLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setFormOpen(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cron Preview */}
|
||||
{cronExpression.trim() && (
|
||||
<CronPreview cron={cronExpression.trim()} />
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
설명
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="스케줄 설명 (선택)"
|
||||
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
|
||||
disabled={!selectedJob || formLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule List */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
||||
@ -360,19 +397,47 @@ export default function Schedules() {
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleNewSchedule}
|
||||
className="px-3 py-1.5 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors"
|
||||
>
|
||||
+ 새 스케줄
|
||||
</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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyState message="등록된 스케줄이 없습니다" sub="위 폼에서 새 스케줄을 등록하세요" />
|
||||
) : (
|
||||
<EmptyState message="등록된 스케줄이 없습니다" sub="'+ 새 스케줄' 버튼을 클릭하여 등록하세요" />
|
||||
) : viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{schedules.map((schedule) => (
|
||||
<div
|
||||
@ -468,6 +533,73 @@ export default function Schedules() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-wing-border text-left text-wing-muted">
|
||||
<th className="px-4 py-3 font-medium">작업명</th>
|
||||
<th className="px-4 py-3 font-medium">Cron 표현식</th>
|
||||
<th className="px-4 py-3 font-medium">상태</th>
|
||||
<th className="px-4 py-3 font-medium">다음 실행</th>
|
||||
<th className="px-4 py-3 font-medium">이전 실행</th>
|
||||
<th className="px-4 py-3 font-medium text-right">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-wing-border/50">
|
||||
{schedules.map((schedule) => (
|
||||
<tr key={schedule.id} className="hover:bg-wing-hover transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-wing-text" title={schedule.jobName}>
|
||||
{displayNameMap[schedule.jobName] || schedule.jobName}
|
||||
</div>
|
||||
{schedule.description && (
|
||||
<div className="text-xs text-wing-muted mt-0.5">{schedule.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">{schedule.cronExpression}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs 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-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
|
||||
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
schedule.active
|
||||
? 'text-amber-600 bg-amber-50 hover:bg-amber-100'
|
||||
: 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
{schedule.active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user