diff --git a/frontend/src/components/dashboard/DashboardHeader.tsx b/frontend/src/components/dashboard/DashboardHeader.tsx index 3d5fa1b..5d1324a 100644 --- a/frontend/src/components/dashboard/DashboardHeader.tsx +++ b/frontend/src/components/dashboard/DashboardHeader.tsx @@ -1,142 +1,186 @@ -import { useState } from 'react'; -import { isDetailWindowOpen } from '../../lib/dualMonitor'; -import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons'; - -interface Stats { - total: number; - inProgress: number; - review: number; - done: number; - issues: number; -} - -interface DashboardHeaderProps { - quarter: string; - stats: Stats; - activeStatus: string; - onStatusChange: (status: string) => void; - onOpenDetailWindow: () => void | Promise; - onOpenTaskManager: () => void; -} - -const STAT_ACCENT = { - 전체: 'text-[#ffdb3a]', - IN_PROGRESS: 'text-[#10b981]', - REVIEW: 'text-[#ff9f0a]', - DONE: 'text-[#b0b0b0]', - ISSUES: 'text-[#ff5252]', -} as const; - -export function DashboardHeader({ - quarter, - stats, - activeStatus, - onStatusChange, - onOpenDetailWindow, - onOpenTaskManager, -}: DashboardHeaderProps) { - const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen); - const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무'); - - const handleOpenDetailWindow = () => { - void Promise.resolve(onOpenDetailWindow()).then(() => { - setDetailViewActive(isDetailWindowOpen()); - }); - }; - - const statItems = [ - { label: '전체', value: stats.total, statusKey: '전체' as const }, - { label: '진행', value: stats.inProgress, statusKey: 'IN_PROGRESS' as const }, - { label: '보류', value: stats.review, statusKey: 'REVIEW' as const }, - { label: '완료', value: stats.done, statusKey: 'DONE' as const }, - { label: '이슈', value: stats.issues, statusKey: 'ISSUES' as const }, - ]; - - return ( -
-
- - 총괄기획실 - | - People Growth Hub - - -
- -
-
- {quarterLabel} - · - {statItems.map((item, index) => ( - - {(index === 1 || index === 4) && } - - - ))} -
-
- -
- - -
-
- ); -} - -function StatDivider() { - return
; -} - -interface StatClickProps { - label: string; - value: number; - statusKey: keyof typeof STAT_ACCENT; - activeStatus: string; - accent: string; - onClick: (key: string) => void; -} - -function StatClick({ label, value, statusKey, activeStatus, accent, onClick }: StatClickProps) { - const isActive = activeStatus === statusKey; - - const handleActivate = () => onClick(isActive ? '전체' : statusKey); - - return ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleActivate(); - } - }} - className={`poly-click-stat header-stat-text ${isActive ? 'active' : ''}`} - style={{ cursor: 'pointer', padding: '2px 6px', borderRadius: '4px' }} - > - {label}{' '} - {value} - - - ); -} +import { useState } from 'react'; +import { isDetailWindowOpen } from '../../lib/dualMonitor'; +import { + FILTER_ALL, + isStatusChipActive, + type CoreStatusFilter, +} from '../../lib/statusFilters'; +import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons'; + +interface Stats { + total: number; + inProgress: number; + review: number; + done: number; + issues: number; +} + +interface DashboardHeaderProps { + quarter: string; + stats: Stats; + activeFilters: string[]; + issueFilterActive: boolean; + onToggleAll: () => void; + onToggleStatus: (key: CoreStatusFilter) => void; + onToggleIssue: () => void; + onOpenDetailWindow: () => void | Promise; + onOpenTaskManager: () => void; +} + +const STAT_ACCENT = { + 전체: 'text-[#ffdb3a]', + IN_PROGRESS: 'text-[#10b981]', + REVIEW: 'text-[#ff9f0a]', + DONE: 'text-[#b0b0b0]', + ISSUES: 'text-[#ff5252]', +} as const; + +type StatKey = keyof typeof STAT_ACCENT; + +export function DashboardHeader({ + quarter, + stats, + activeFilters, + issueFilterActive, + onToggleAll, + onToggleStatus, + onToggleIssue, + onOpenDetailWindow, + onOpenTaskManager, +}: DashboardHeaderProps) { + const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen); + const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무'); + + const handleOpenDetailWindow = () => { + void Promise.resolve(onOpenDetailWindow()).then(() => { + setDetailViewActive(isDetailWindowOpen()); + }); + }; + + const statItems: Array<{ + label: string; + value: number; + statusKey: StatKey; + onClick: () => void; + isActive: boolean; + }> = [ + { + label: '전체', + value: stats.total, + statusKey: '전체', + onClick: onToggleAll, + isActive: isStatusChipActive(FILTER_ALL, activeFilters, issueFilterActive), + }, + { + label: '진행', + value: stats.inProgress, + statusKey: 'IN_PROGRESS', + onClick: () => onToggleStatus('IN_PROGRESS'), + isActive: isStatusChipActive('IN_PROGRESS', activeFilters, issueFilterActive), + }, + { + label: '보류', + value: stats.review, + statusKey: 'REVIEW', + onClick: () => onToggleStatus('REVIEW'), + isActive: isStatusChipActive('REVIEW', activeFilters, issueFilterActive), + }, + { + label: '완료', + value: stats.done, + statusKey: 'DONE', + onClick: () => onToggleStatus('DONE'), + isActive: isStatusChipActive('DONE', activeFilters, issueFilterActive), + }, + { + label: '이슈', + value: stats.issues, + statusKey: 'ISSUES', + onClick: onToggleIssue, + isActive: issueFilterActive, + }, + ]; + + return ( +
+
+ + 총괄기획실 + | + People Growth Hub + + +
+ +
+
+ {quarterLabel} + · + {statItems.map((item, index) => ( + + {(index === 1 || index === 4) && } + + + ))} +
+
+ +
+ + +
+
+ ); +} + +function StatDivider() { + return
; +} + +interface StatClickProps { + label: string; + value: number; + accent: string; + isActive: boolean; + onClick: () => void; +} + +function StatClick({ label, value, accent, isActive, onClick }: StatClickProps) { + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className={`poly-click-stat header-stat-text ${isActive ? 'active' : ''}`} + style={{ cursor: 'pointer', padding: '2px 6px', borderRadius: '4px' }} + > + {label}{' '} + {value} + + + ); +} + \ No newline at end of file diff --git a/frontend/src/lib/statusFilters.ts b/frontend/src/lib/statusFilters.ts new file mode 100644 index 0000000..f41b412 --- /dev/null +++ b/frontend/src/lib/statusFilters.ts @@ -0,0 +1,68 @@ +import type { Task } from '../types'; + +export const FILTER_ALL = 'all' as const; +export const CORE_STATUS_FILTERS = ['IN_PROGRESS', 'REVIEW', 'DONE'] as const; + +export type CoreStatusFilter = (typeof CORE_STATUS_FILTERS)[number]; + +export const DEFAULT_STATUS_FILTERS: string[] = [ + FILTER_ALL, + ...CORE_STATUS_FILTERS, +]; + +export function isCoreStatusFilter(key: string): key is CoreStatusFilter { + return (CORE_STATUS_FILTERS as readonly string[]).includes(key); +} + +/** UI: 전체·진행·보류·완료 버튼 활성 표시 */ +export function isStatusChipActive( + key: string, + filters: string[], + issueMode: boolean, +): boolean { + if (issueMode) return false; + if (key === FILTER_ALL) return filters.includes(FILTER_ALL); + if (isCoreStatusFilter(key)) { + return filters.includes(FILTER_ALL) || filters.includes(key); + } + return false; +} + +export function toggleAllFilter(current: string[]): string[] { + if (current.includes(FILTER_ALL)) { + return ['IN_PROGRESS']; + } + return [...DEFAULT_STATUS_FILTERS]; +} + +export function toggleCoreFilter(current: string[], key: CoreStatusFilter): string[] { + if (current.includes(FILTER_ALL)) { + return CORE_STATUS_FILTERS.filter((k) => k !== key); + } + + const withoutAll = current.filter((f) => f !== FILTER_ALL); + + if (withoutAll.includes(key)) { + const next = withoutAll.filter((k) => k !== key); + return next.length === 0 ? [...DEFAULT_STATUS_FILTERS] : next; + } + + const next = [...withoutAll, key]; + if (CORE_STATUS_FILTERS.every((k) => next.includes(k))) { + return [FILTER_ALL, ...next]; + } + return next; +} + +export function taskMatchesStatusFilters(task: Task, filters: string[]): boolean { + if (filters.includes(FILTER_ALL)) return true; + + return filters.some((key) => { + if (key === 'IN_PROGRESS') return task.status === 'IN_PROGRESS'; + if (key === 'REVIEW') { + return task.status === 'REVIEW' || task.status === 'CANCELLED' || task.status === 'TODO'; + } + if (key === 'DONE') return task.status === 'DONE'; + return false; + }); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 05c4ede..ffb7ba8 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -19,6 +19,13 @@ import { TaskManager } from '../components/dashboard/TaskManager'; import { useSocket } from '../contexts/SocketContext'; import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor'; import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType'; +import { + DEFAULT_STATUS_FILTERS, + taskMatchesStatusFilters, + toggleAllFilter, + toggleCoreFilter, + type CoreStatusFilter, +} from '../lib/statusFilters'; const QUARTER = '2026-Q2'; const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const; @@ -31,7 +38,9 @@ const COLUMN_STYLES = [ ] as const; export default function DashboardPage() { - const [activeStatus, setActiveStatus] = useState('전체'); + const [activeFilters, setActiveFilters] = useState([...DEFAULT_STATUS_FILTERS]); + const [filtersBeforeIssue, setFiltersBeforeIssue] = useState([...DEFAULT_STATUS_FILTERS]); + const [issueFilterActive, setIssueFilterActive] = useState(false); const [showTaskManager, setShowTaskManager] = useState(false); const [activeTaskId, setActiveTaskId] = useState(null); const [columnOrders, setColumnOrders] = useState>({}); @@ -188,12 +197,30 @@ export default function DashboardPage() { }; const filtered = tasks.filter((t) => { - if (activeStatus === '전체') return true; - if (activeStatus === 'ISSUES') return !!t.issueNote; - if (activeStatus === 'REVIEW') return t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO'; - return t.status === activeStatus; + if (issueFilterActive) return !!t.issueNote; + return taskMatchesStatusFilters(t, activeFilters); }); + const handleToggleAll = () => { + setIssueFilterActive(false); + setActiveFilters((prev) => toggleAllFilter(prev)); + }; + + const handleToggleStatus = (key: CoreStatusFilter) => { + setIssueFilterActive(false); + setActiveFilters((prev) => toggleCoreFilter(prev, key)); + }; + + const handleToggleIssue = () => { + if (issueFilterActive) { + setIssueFilterActive(false); + setActiveFilters([...filtersBeforeIssue]); + return; + } + setFiltersBeforeIssue([...activeFilters]); + setIssueFilterActive(true); + }; + const sectionOptions = SECTIONS.map((s) => ({ value: s, label: colConfigs?.find((c) => c.key === s)?.title ?? s, @@ -214,8 +241,11 @@ export default function DashboardPage() { { openDetailWindow(); }} onOpenTaskManager={() => setShowTaskManager(true)} />