feat: implement multi-select status filter logic from reference hub
Support combined active chips for all/progress/hold/done and restore prior filters when toggling issue view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,142 +1,186 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { isDetailWindowOpen } from '../../lib/dualMonitor';
|
|
||||||
import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
|
import { isDetailWindowOpen } from '../../lib/dualMonitor';
|
||||||
|
|
||||||
interface Stats {
|
import {
|
||||||
total: number;
|
|
||||||
inProgress: number;
|
FILTER_ALL,
|
||||||
review: number;
|
|
||||||
done: number;
|
isStatusChipActive,
|
||||||
issues: number;
|
|
||||||
}
|
type CoreStatusFilter,
|
||||||
|
|
||||||
interface DashboardHeaderProps {
|
} from '../../lib/statusFilters';
|
||||||
quarter: string;
|
|
||||||
stats: Stats;
|
import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
|
||||||
activeStatus: string;
|
|
||||||
onStatusChange: (status: string) => void;
|
|
||||||
onOpenDetailWindow: () => void | Promise<void>;
|
|
||||||
onOpenTaskManager: () => void;
|
interface Stats {
|
||||||
}
|
|
||||||
|
total: number;
|
||||||
const STAT_ACCENT = {
|
|
||||||
전체: 'text-[#ffdb3a]',
|
inProgress: number;
|
||||||
IN_PROGRESS: 'text-[#10b981]',
|
|
||||||
REVIEW: 'text-[#ff9f0a]',
|
review: number;
|
||||||
DONE: 'text-[#b0b0b0]',
|
|
||||||
ISSUES: 'text-[#ff5252]',
|
done: number;
|
||||||
} as const;
|
|
||||||
|
issues: number;
|
||||||
export function DashboardHeader({
|
|
||||||
quarter,
|
}
|
||||||
stats,
|
|
||||||
activeStatus,
|
|
||||||
onStatusChange,
|
|
||||||
onOpenDetailWindow,
|
interface DashboardHeaderProps {
|
||||||
onOpenTaskManager,
|
|
||||||
}: DashboardHeaderProps) {
|
quarter: string;
|
||||||
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
|
|
||||||
const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
|
stats: Stats;
|
||||||
|
|
||||||
const handleOpenDetailWindow = () => {
|
activeFilters: string[];
|
||||||
void Promise.resolve(onOpenDetailWindow()).then(() => {
|
|
||||||
setDetailViewActive(isDetailWindowOpen());
|
issueFilterActive: boolean;
|
||||||
});
|
|
||||||
};
|
onToggleAll: () => void;
|
||||||
|
|
||||||
const statItems = [
|
onToggleStatus: (key: CoreStatusFilter) => void;
|
||||||
{ label: '전체', value: stats.total, statusKey: '전체' as const },
|
|
||||||
{ label: '진행', value: stats.inProgress, statusKey: 'IN_PROGRESS' as const },
|
onToggleIssue: () => void;
|
||||||
{ label: '보류', value: stats.review, statusKey: 'REVIEW' as const },
|
|
||||||
{ label: '완료', value: stats.done, statusKey: 'DONE' as const },
|
onOpenDetailWindow: () => void | Promise<void>;
|
||||||
{ label: '이슈', value: stats.issues, statusKey: 'ISSUES' as const },
|
|
||||||
];
|
onOpenTaskManager: () => void;
|
||||||
|
|
||||||
return (
|
}
|
||||||
<header className="dashboard-header-bar shrink-0">
|
|
||||||
<div className="side-left-group min-w-0 shrink-0">
|
|
||||||
<span className="side-title-main main_tit flex shrink-0 items-center gap-[10px] text-[20px] font-bold tracking-[-0.5px] text-[#bad8ca]">
|
|
||||||
<span>총괄기획실</span>
|
const STAT_ACCENT = {
|
||||||
<span>|</span>
|
|
||||||
<span>People Growth Hub</span>
|
전체: 'text-[#ffdb3a]',
|
||||||
</span>
|
|
||||||
<button type="button" title="팀 현황" className="team-status-btn-new">
|
IN_PROGRESS: 'text-[#10b981]',
|
||||||
<UsersIcon size={16} />
|
|
||||||
</button>
|
REVIEW: 'text-[#ff9f0a]',
|
||||||
</div>
|
|
||||||
|
DONE: 'text-[#b0b0b0]',
|
||||||
<div className="header-stats-bar side-polygon-stats">
|
|
||||||
<div className="poly-stat-item" style={{ fontSize: '18px', gap: '8px' }}>
|
ISSUES: 'text-[#ff5252]',
|
||||||
<span className="poly-stat-quarter header-stat-text">{quarterLabel}</span>
|
|
||||||
<span className="poly-stat-bullet header-stat-text">·</span>
|
} as const;
|
||||||
{statItems.map((item, index) => (
|
|
||||||
<span key={item.statusKey} className="contents">
|
|
||||||
{(index === 1 || index === 4) && <StatDivider />}
|
|
||||||
<StatClick
|
type StatKey = keyof typeof STAT_ACCENT;
|
||||||
label={item.label}
|
|
||||||
value={item.value}
|
|
||||||
statusKey={item.statusKey}
|
|
||||||
activeStatus={activeStatus}
|
export function DashboardHeader({
|
||||||
accent={STAT_ACCENT[item.statusKey]}
|
|
||||||
onClick={onStatusChange}
|
quarter,
|
||||||
/>
|
|
||||||
</span>
|
stats,
|
||||||
))}
|
|
||||||
</div>
|
activeFilters,
|
||||||
</div>
|
|
||||||
|
issueFilterActive,
|
||||||
<div className="side-right-actions shrink-0">
|
|
||||||
<button type="button" onClick={onOpenTaskManager} title="신규 프로젝트 추가" className="header-action-btn-new">
|
onToggleAll,
|
||||||
<PlusIcon size={16} />
|
|
||||||
</button>
|
onToggleStatus,
|
||||||
<button
|
|
||||||
type="button"
|
onToggleIssue,
|
||||||
onClick={handleOpenDetailWindow}
|
|
||||||
title="듀얼뷰"
|
onOpenDetailWindow,
|
||||||
className={`header-view-btn-new ${detailViewActive ? 'active' : ''}`}
|
|
||||||
>
|
onOpenTaskManager,
|
||||||
<DualMonitorIcon size={16} />
|
|
||||||
</button>
|
}: DashboardHeaderProps) {
|
||||||
</div>
|
|
||||||
</header>
|
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
|
||||||
);
|
|
||||||
}
|
const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
|
||||||
|
|
||||||
function StatDivider() {
|
|
||||||
return <div className="poly-stat-divider" aria-hidden />;
|
|
||||||
}
|
const handleOpenDetailWindow = () => {
|
||||||
|
|
||||||
interface StatClickProps {
|
void Promise.resolve(onOpenDetailWindow()).then(() => {
|
||||||
label: string;
|
|
||||||
value: number;
|
setDetailViewActive(isDetailWindowOpen());
|
||||||
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 statItems: Array<{
|
||||||
|
|
||||||
const handleActivate = () => onClick(isActive ? '전체' : statusKey);
|
label: string;
|
||||||
|
|
||||||
return (
|
value: number;
|
||||||
<span
|
|
||||||
role="button"
|
statusKey: StatKey;
|
||||||
tabIndex={0}
|
|
||||||
onClick={handleActivate}
|
onClick: () => void;
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
isActive: boolean;
|
||||||
e.preventDefault();
|
|
||||||
handleActivate();
|
}> = [
|
||||||
}
|
|
||||||
}}
|
{
|
||||||
className={`poly-click-stat header-stat-text ${isActive ? 'active' : ''}`}
|
|
||||||
style={{ cursor: 'pointer', padding: '2px 6px', borderRadius: '4px' }}
|
label: '전체',
|
||||||
>
|
|
||||||
{label}{' '}
|
value: stats.total,
|
||||||
<span className={`poly-stat-val ${accent}`}>{value}</span>
|
|
||||||
<span className="poly-stat-unit"> 건</span>
|
statusKey: '전체',
|
||||||
</span>
|
|
||||||
);
|
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),
|
||||||
|
|
||||||
68
frontend/src/lib/statusFilters.ts
Normal file
68
frontend/src/lib/statusFilters.ts
Normal file
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,13 @@ import { TaskManager } from '../components/dashboard/TaskManager';
|
|||||||
import { useSocket } from '../contexts/SocketContext';
|
import { useSocket } from '../contexts/SocketContext';
|
||||||
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
|
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
|
||||||
import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType';
|
import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType';
|
||||||
|
import {
|
||||||
|
DEFAULT_STATUS_FILTERS,
|
||||||
|
taskMatchesStatusFilters,
|
||||||
|
toggleAllFilter,
|
||||||
|
toggleCoreFilter,
|
||||||
|
type CoreStatusFilter,
|
||||||
|
} from '../lib/statusFilters';
|
||||||
|
|
||||||
const QUARTER = '2026-Q2';
|
const QUARTER = '2026-Q2';
|
||||||
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
|
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
|
||||||
@@ -31,7 +38,9 @@ const COLUMN_STYLES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [activeStatus, setActiveStatus] = useState('전체');
|
const [activeFilters, setActiveFilters] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
|
||||||
|
const [filtersBeforeIssue, setFiltersBeforeIssue] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
|
||||||
|
const [issueFilterActive, setIssueFilterActive] = useState(false);
|
||||||
const [showTaskManager, setShowTaskManager] = useState(false);
|
const [showTaskManager, setShowTaskManager] = useState(false);
|
||||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||||
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
|
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
|
||||||
@@ -188,12 +197,30 @@ export default function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filtered = tasks.filter((t) => {
|
const filtered = tasks.filter((t) => {
|
||||||
if (activeStatus === '전체') return true;
|
if (issueFilterActive) return !!t.issueNote;
|
||||||
if (activeStatus === 'ISSUES') return !!t.issueNote;
|
return taskMatchesStatusFilters(t, activeFilters);
|
||||||
if (activeStatus === 'REVIEW') return t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO';
|
|
||||||
return t.status === activeStatus;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) => ({
|
const sectionOptions = SECTIONS.map((s) => ({
|
||||||
value: s,
|
value: s,
|
||||||
label: colConfigs?.find((c) => c.key === s)?.title ?? s,
|
label: colConfigs?.find((c) => c.key === s)?.title ?? s,
|
||||||
@@ -214,8 +241,11 @@ export default function DashboardPage() {
|
|||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
quarter={QUARTER}
|
quarter={QUARTER}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
activeStatus={activeStatus}
|
activeFilters={activeFilters}
|
||||||
onStatusChange={setActiveStatus}
|
issueFilterActive={issueFilterActive}
|
||||||
|
onToggleAll={handleToggleAll}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
onToggleIssue={handleToggleIssue}
|
||||||
onOpenDetailWindow={() => { openDetailWindow(); }}
|
onOpenDetailWindow={() => { openDetailWindow(); }}
|
||||||
onOpenTaskManager={() => setShowTaskManager(true)}
|
onOpenTaskManager={() => setShowTaskManager(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user