From 8cd9218a6ec325c8f5cf54505e4981b98308af6d Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Tue, 17 Mar 2026 09:07:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EC=8A=A4=EC=BC=80=EC=A4=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B2=80=EC=83=89/=EC=A0=95=EB=A0=AC/?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20UI=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20(#54?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/Navbar.tsx | 2 +- frontend/src/pages/Schedules.tsx | 499 +++++++++++++++++++---------- 2 files changed, 339 insertions(+), 162 deletions(-) diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index ea28634..fa9be55 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -3,9 +3,9 @@ import { useThemeContext } from '../contexts/ThemeContext'; const navItems = [ { path: '/', label: 'λŒ€μ‹œλ³΄λ“œ', icon: 'πŸ“Š' }, - { path: '/jobs', label: 'μž‘μ—…', icon: 'βš™οΈ' }, { path: '/executions', label: 'μ‹€ν–‰ 이λ ₯', icon: 'πŸ“‹' }, { path: '/recollects', label: 'μž¬μˆ˜μ§‘ 이λ ₯', icon: 'πŸ”„' }, + { path: '/jobs', label: 'μž‘μ—…', icon: 'βš™οΈ' }, { path: '/schedules', label: 'μŠ€μΌ€μ€„', icon: 'πŸ•' }, { path: '/schedule-timeline', label: 'νƒ€μž„λΌμΈ', icon: 'πŸ“…' }, ]; diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx index 8366301..a248c3e 100644 --- a/frontend/src/pages/Schedules.tsx +++ b/frontend/src/pages/Schedules.tsx @@ -10,6 +10,19 @@ import GuideModal, { HelpButton } from '../components/GuideModal'; type ScheduleMode = 'new' | 'existing'; 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 { type: 'toggle' | 'delete'; @@ -122,6 +135,11 @@ export default function Schedules() { // View mode state const [viewMode, setViewMode] = useState('table'); + // Search / filter / sort state + const [searchTerm, setSearchTerm] = useState(''); + const [activeFilter, setActiveFilter] = useState('ALL'); + const [sortKey, setSortKey] = useState('name'); + // Confirm modal state const [confirmAction, setConfirmAction] = useState(null); @@ -164,6 +182,75 @@ export default function Schedules() { return map; }, [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>( + (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) => { setSelectedJob(jobName); setCronExpression(''); @@ -275,6 +362,11 @@ export default function Schedules() { setFormOpen(true); }; + const getScheduleLabel = (schedule: ScheduleResponse) => + displayNameMap[schedule.jobName] || schedule.jobName; + + if (listLoading) return ; + return (
{/* Form Modal */} @@ -409,179 +501,262 @@ export default function Schedules() {
)} - {/* Schedule List */} -
-
-
-

- λ“±λ‘λœ μŠ€μΌ€μ€„ - {schedules.length > 0 && ( - - ({schedules.length}개) - - )} -

- setGuideOpen(true)} /> + {/* Header */} +
+
+

μŠ€μΌ€μ€„ 관리

+ setGuideOpen(true)} /> +
+
+ + + + 총 {schedules.length}개 μŠ€μΌ€μ€„ + +
+
+ + {/* Active Filter Tabs */} +
+ {ACTIVE_TABS.map((tab) => ( + + ))} +
+ + {/* Search + Sort + View Toggle */} +
+
+ {/* Search */} +
+ + + + + + 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 && ( + + )}
-
+ + {/* Sort dropdown */} + + + {/* View mode toggle */} +
-
- - -
- {listLoading ? ( - - ) : schedules.length === 0 ? ( - - ) : viewMode === 'card' ? ( -
- {schedules.map((schedule) => ( -
- {/* Header */} -
-
-

- {displayNameMap[schedule.jobName] || schedule.jobName} -

- - {schedule.active ? 'ν™œμ„±' : 'λΉ„ν™œμ„±'} - - {schedule.triggerState && ( - - {schedule.triggerState} - - )} -
-
+ {searchTerm && ( +

+ {filteredSchedules.length}개 μŠ€μΌ€μ€„ 검색됨 +

+ )} +
- {/* Cron Expression */} -
- + {/* Schedule List */} + {filteredSchedules.length === 0 ? ( +
+ +
+ ) : viewMode === 'card' ? ( + /* Card View */ +
+ {filteredSchedules.map((schedule) => ( +
+
+
+

+ {getScheduleLabel(schedule)} +

+
+
+ + {schedule.active ? 'ν™œμ„±' : 'λΉ„ν™œμ„±'} + + {schedule.triggerState && ( + + {schedule.triggerState} + + )} +
+
+ + {/* Detail Info */} +
+
+ {schedule.cronExpression}
- - {/* Description */} - {schedule.description && ( -

{schedule.description}

+

+ λ‹€μŒ μ‹€ν–‰: {formatDateTime(schedule.nextFireTime)} +

+ {schedule.previousFireTime && ( +

+ 이전 μ‹€ν–‰: {formatDateTime(schedule.previousFireTime)} +

)} - - {/* Time Info */} -
-
- λ‹€μŒ μ‹€ν–‰:{' '} - {formatDateTime(schedule.nextFireTime)} -
-
- 이전 μ‹€ν–‰:{' '} - {formatDateTime(schedule.previousFireTime)} -
-
- 등둝일:{' '} - {formatDateTime(schedule.createdAt)} -
-
- μˆ˜μ •μΌ:{' '} - {formatDateTime(schedule.updatedAt)} -
-
- - {/* Action Buttons */} -
- - - -
- ))} -
- ) : ( + + {/* Action Buttons */} +
+ + + +
+
+ ))} +
+ ) : ( + /* Table View */ +
- - - - - - - + + + + + + + - - {schedules.map((schedule) => ( - - + {filteredSchedules.map((schedule) => ( + + - - - + +
μž‘μ—…λͺ…Cron ν‘œν˜„μ‹μƒνƒœλ‹€μŒ 싀행이전 μ‹€ν–‰μ•‘μ…˜
μž‘μ—…λͺ…Cron ν‘œν˜„μ‹μƒνƒœλ‹€μŒ 싀행이전 μ‹€ν–‰μ•‘μ…˜
-
- {displayNameMap[schedule.jobName] || schedule.jobName} -
- {schedule.description && ( -
{schedule.description}
- )} +
+ {getScheduleLabel(schedule)} {schedule.cronExpression} @@ -593,19 +768,20 @@ export default function Schedules() { {schedule.active ? 'ν™œμ„±' : 'λΉ„ν™œμ„±'} {formatDateTime(schedule.nextFireTime)}{schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'} -
+
{formatDateTime(schedule.nextFireTime)}{schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'} +
@@ -626,8 +803,8 @@ export default function Schedules() {
- )} -
+
+ )} {/* Confirm Modal */} {confirmAction?.type === 'toggle' && (