534 lines
19 KiB
TypeScript
534 lines
19 KiB
TypeScript
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
DragOverlay,
|
|
type DragStartEvent,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core';
|
|
import { arrayMove } from '@dnd-kit/sortable';
|
|
import { apiClient } from '../lib/apiClient';
|
|
import { useTasks } from '../hooks/useTasks';
|
|
import { useBoardReferenceDate } from '../hooks/useBoardReferenceDate';
|
|
import { useTeamMembers } from '../hooks/useTeamMembers';
|
|
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
|
|
import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel';
|
|
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
|
|
import { HubColumn } from '../components/dashboard/HubColumn';
|
|
import { BoardConnectors } from '../components/dashboard/BoardConnectors';
|
|
import '../styles/dummy-board.css';
|
|
import { DonutGauge } from '../components/dashboard/DonutGauge';
|
|
import { TaskManager } from '../components/dashboard/TaskManager';
|
|
import { useSocket } from '../contexts/SocketContext';
|
|
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen, getWindowPlacementHint } from '../lib/dualMonitor';
|
|
import { TaskDetailShell } from '../pages/DetailPage';
|
|
import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType';
|
|
import { hasVisibleIssue } from '../lib/taskIssues';
|
|
import {
|
|
DEFAULT_STATUS_FILTERS,
|
|
taskMatchesStatusFilters,
|
|
toggleAllFilter,
|
|
toggleCoreFilter,
|
|
type CoreStatusFilter,
|
|
} from '../lib/statusFilters';
|
|
import {
|
|
SECTIONS,
|
|
LEGACY_COLUMN_KEYS,
|
|
taskBelongsToSection,
|
|
type SectionKey,
|
|
} from '../lib/sections';
|
|
import {
|
|
BOARD_SLOTS,
|
|
BOARD_SLOT_ORDER,
|
|
getBoardSlot,
|
|
slotSectionLabel,
|
|
columnDisplayTitle,
|
|
taskBelongsToBoardSlot,
|
|
} from '../lib/boardLayout';
|
|
import { invalidateTaskCaches } from '../lib/taskQueryCache';
|
|
|
|
function mergeCardOrders(primary: string | null | undefined, extras: (string | null | undefined)[]): string[] {
|
|
const merged: string[] = [];
|
|
const push = (raw: string | null | undefined) => {
|
|
if (!raw) return;
|
|
try {
|
|
const ids = JSON.parse(raw) as string[];
|
|
for (const id of ids) {
|
|
if (!merged.includes(id)) merged.push(id);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
push(primary);
|
|
extras.forEach(push);
|
|
return merged;
|
|
}
|
|
|
|
function sectionKeyForLabel(label: string): SectionKey | null {
|
|
for (const s of SECTIONS) {
|
|
if (label === s || taskBelongsToSection(label, s)) return s;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
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 [teamPanelOpen, setTeamPanelOpen] = useState(false);
|
|
const [showAllTeamTasks, setShowAllTeamTasks] = useState(false);
|
|
const [activeTeamProjectId, setActiveTeamProjectId] = useState<string | null>(null);
|
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
|
const [detailStageId, setDetailStageId] = useState<string | null>(null);
|
|
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
|
|
const [placementBanner, setPlacementBanner] = useState<string | null>(() => getWindowPlacementHint());
|
|
const [viewportWidth, setViewportWidth] = useState(() =>
|
|
typeof window !== 'undefined' ? window.innerWidth : 1920,
|
|
);
|
|
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
|
|
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
|
|
const queryClient = useQueryClient();
|
|
const socket = useSocket();
|
|
const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate();
|
|
|
|
const { data: tasks = [], isLoading } = useTasks({ quarter });
|
|
const { data: teamMembers = [] } = useTeamMembers();
|
|
|
|
const { data: colConfigs } = useQuery({
|
|
queryKey: ['columns', 'all', ...SECTIONS],
|
|
queryFn: async () => {
|
|
const results = await Promise.all(
|
|
SECTIONS.map(async (s) => {
|
|
const main = await apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => r.data);
|
|
const legacy = await Promise.all(
|
|
LEGACY_COLUMN_KEYS[s].map((key) =>
|
|
apiClient.get(`/columns/${encodeURIComponent(key)}`).then((r) => r.data).catch(() => null),
|
|
),
|
|
);
|
|
const cardOrder = JSON.stringify(
|
|
mergeCardOrders(main.cardOrder, legacy.map((l) => l?.cardOrder)),
|
|
);
|
|
return { key: s, ...main, cardOrder };
|
|
}),
|
|
);
|
|
return results;
|
|
},
|
|
staleTime: 0,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const onResize = () => setViewportWidth(window.innerWidth);
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, []);
|
|
|
|
useEffect(() => registerSyncProvider(() => selectedTaskId), [selectedTaskId]);
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
setDetailPopupOpen(isDetailWindowOpen());
|
|
}, 300);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!colConfigs) return;
|
|
setColumnOrders((prev) => {
|
|
const next = { ...prev };
|
|
BOARD_SLOTS.forEach((slot) => {
|
|
const label = slotSectionLabel(slot);
|
|
if (next[label]) return;
|
|
if (!slot.sectionKey) return;
|
|
const cfg = colConfigs.find((c) => c.key === slot.sectionKey);
|
|
if (cfg?.cardOrder) {
|
|
try {
|
|
next[label] = JSON.parse(cfg.cardOrder);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
});
|
|
return next;
|
|
});
|
|
}, [colConfigs]);
|
|
|
|
useEffect(() => {
|
|
BOARD_SLOTS.forEach((slot) => {
|
|
const label = slotSectionLabel(slot);
|
|
const ids = tasks.filter((t) => taskBelongsToBoardSlot(t, slot)).map((t) => t.id);
|
|
setColumnOrders((prev) => {
|
|
const existing = prev[label] ?? [];
|
|
const merged = [
|
|
...existing.filter((id) => ids.includes(id)),
|
|
...ids.filter((id) => !existing.includes(id)),
|
|
];
|
|
if (JSON.stringify(merged) === JSON.stringify(existing)) return prev;
|
|
return { ...prev, [label]: merged };
|
|
});
|
|
});
|
|
}, [tasks]);
|
|
|
|
const patchColumn = useMutation({
|
|
mutationFn: ({ section, data }: { section: string; data: Record<string, string> }) =>
|
|
apiClient.patch(`/columns/${encodeURIComponent(section)}`, data),
|
|
});
|
|
|
|
const patchTask = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
|
apiClient.patch(`/tasks/${id}`, data),
|
|
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!socket) return;
|
|
const refreshList = () => queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
const refreshTask = () => queryClient.invalidateQueries({ queryKey: ['task'] });
|
|
socket.on('tasks:refresh', refreshList);
|
|
socket.on('task:updated', refreshTask);
|
|
return () => {
|
|
socket.off('tasks:refresh', refreshList);
|
|
socket.off('task:updated', refreshTask);
|
|
};
|
|
}, [socket, queryClient]);
|
|
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
setActiveTaskId(String(event.active.id));
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
setActiveTaskId(null);
|
|
if (!over) return;
|
|
|
|
const activeId = String(active.id);
|
|
const overId = String(over.id);
|
|
|
|
const draggedTask = tasks.find((t) => t.id === activeId);
|
|
if (!draggedTask) return;
|
|
|
|
const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToBoardSlot(draggedTask, s));
|
|
const srcLabel = srcSlot ? slotSectionLabel(srcSlot) : (draggedTask.section ?? '인사관리');
|
|
|
|
if (overId.startsWith('drop::')) {
|
|
const parts = overId.split('::');
|
|
const areaType = parts[1];
|
|
const targetSection = parts[2];
|
|
|
|
const updateData: Record<string, unknown> = {};
|
|
|
|
if (areaType === 'routine' && targetSection === 'hub') {
|
|
if (!isRoutineTask(draggedTask.taskType)) {
|
|
updateData.taskType = '기반업무';
|
|
Object.assign(updateData, displayFlagsForTaskType('기반업무'));
|
|
}
|
|
if (Object.keys(updateData).length > 0) {
|
|
patchTask.mutate({ id: activeId, data: updateData });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (areaType === 'project') {
|
|
if (targetSection !== srcLabel) updateData.section = targetSection;
|
|
if (isRoutineTask(draggedTask.taskType)) {
|
|
updateData.taskType = '실행과제';
|
|
Object.assign(updateData, displayFlagsForTaskType('실행과제'));
|
|
}
|
|
if (Object.keys(updateData).length > 0) {
|
|
patchTask.mutate({ id: activeId, data: updateData });
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const overTask = tasks.find((t) => t.id === overId);
|
|
if (!overTask) return;
|
|
|
|
const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToBoardSlot(overTask, s));
|
|
const dstLabel = dstSlot ? slotSectionLabel(dstSlot) : (overTask.section ?? '인사관리');
|
|
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
|
|
|
|
if (dstLabel !== srcLabel || typeChanged) {
|
|
const updateData: Record<string, unknown> = {};
|
|
if (dstLabel !== srcLabel) updateData.section = dstLabel;
|
|
if (typeChanged) {
|
|
const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제';
|
|
updateData.taskType = newType;
|
|
Object.assign(updateData, displayFlagsForTaskType(newType));
|
|
}
|
|
patchTask.mutate({ id: activeId, data: updateData });
|
|
if (dstLabel !== srcLabel) return;
|
|
}
|
|
|
|
const current = columnOrders[srcLabel]
|
|
?? tasks.filter((t) => t.section === srcLabel).map((t) => t.id);
|
|
const oldIdx = current.indexOf(activeId);
|
|
const newIdx = current.indexOf(overId);
|
|
if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return;
|
|
|
|
const newOrder = arrayMove(current, oldIdx, newIdx);
|
|
setColumnOrders((prev) => ({ ...prev, [srcLabel]: newOrder }));
|
|
|
|
const apiKey = sectionKeyForLabel(srcLabel);
|
|
if (!apiKey) return;
|
|
|
|
if (saveTimers.current[srcLabel]) clearTimeout(saveTimers.current[srcLabel]);
|
|
saveTimers.current[srcLabel] = setTimeout(() => {
|
|
patchColumn.mutate({ section: apiKey, data: { cardOrder: JSON.stringify(newOrder) } });
|
|
}, 300);
|
|
};
|
|
|
|
const filtered = tasks.filter((t) => {
|
|
if (issueFilterActive) return hasVisibleIssue(t);
|
|
return taskMatchesStatusFilters(t, activeFilters);
|
|
});
|
|
|
|
/** 4분면 부서 카드에 표시되는 실행과제만 (상단 현황판과 건수 일치) */
|
|
const boardProjectTasks = useMemo(
|
|
() =>
|
|
filtered.filter(
|
|
(t) =>
|
|
isProjectTask(t.taskType) &&
|
|
BOARD_SLOTS.some((slot) => taskBelongsToBoardSlot(t, slot)),
|
|
),
|
|
[filtered],
|
|
);
|
|
|
|
const stats = useMemo(
|
|
() => ({
|
|
total: boardProjectTasks.length,
|
|
inProgress: boardProjectTasks.filter((t) => t.status === 'IN_PROGRESS').length,
|
|
review: boardProjectTasks.filter(
|
|
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
|
|
).length,
|
|
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
|
|
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t)).length,
|
|
}),
|
|
[boardProjectTasks],
|
|
);
|
|
|
|
const routineTasks = useMemo(
|
|
() => filtered.filter((t) => isRoutineTask(t.taskType)),
|
|
[filtered],
|
|
);
|
|
|
|
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 = BOARD_SLOTS.map((slot) => ({
|
|
value: slotSectionLabel(slot),
|
|
label: columnDisplayTitle(
|
|
slot,
|
|
colConfigs?.find((c) => c.key === slot.sectionKey),
|
|
),
|
|
}));
|
|
|
|
const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null;
|
|
|
|
/** 초광역(단일 화면)에서만 인라인 우측 패널 — 일반은 팝업(듀얼 모니터) 전용 */
|
|
const ultraWideLayout = viewportWidth >= 3800;
|
|
const showDockedDetail = ultraWideLayout && !!selectedTaskId && !detailPopupOpen;
|
|
const detailDocked = showDockedDetail;
|
|
|
|
const handleSelectTask = (taskId: string, stageId?: string | null) => {
|
|
setSelectedTaskId(taskId);
|
|
setDetailStageId(stageId ?? null);
|
|
if (teamPanelOpen) setActiveTeamProjectId(taskId);
|
|
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then((popupOpened) => {
|
|
if (popupOpened) {
|
|
setDetailPopupOpen(true);
|
|
} else if (!ultraWideLayout) {
|
|
window.alert(
|
|
'상세 창을 열 수 없습니다.\n\n' +
|
|
'• 팝업 허용 (주소창 아이콘)\n' +
|
|
'• 「모든 디스플레이에 대한 정보 보기」 권한 허용\n' +
|
|
' (IP 주소 접속 시 localhost와 별도로 한 번 더 필요)',
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleOpenDetailWindow = () => {
|
|
void openDetailWindow(() => setDetailPopupOpen(false)).then((win) => {
|
|
setDetailPopupOpen(!!win);
|
|
});
|
|
};
|
|
|
|
const renderDeptSlot = (slotId: typeof BOARD_SLOT_ORDER[number]) => {
|
|
const slot = getBoardSlot(slotId);
|
|
const label = slotSectionLabel(slot);
|
|
return (
|
|
<DepartmentColumn
|
|
key={slot.id}
|
|
slot={slot}
|
|
tasks={filtered.filter((t) => taskBelongsToBoardSlot(t, slot))}
|
|
orderedIds={columnOrders[label] ?? []}
|
|
quarter={quarter}
|
|
onSelectTask={(t) => handleSelectTask(t.id)}
|
|
sectionOptions={sectionOptions}
|
|
teamMembers={teamMembers}
|
|
/>
|
|
);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-screen items-center justify-center bg-white">
|
|
<div className="text-lg text-gray-400">데이터를 불러오는 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`app-shell dummy-board-page dummy-board-page--2slots ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
|
|
>
|
|
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden">
|
|
{placementBanner && (
|
|
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-950">
|
|
<p className="whitespace-pre-line leading-relaxed">{placementBanner}</p>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded-lg px-2 py-1 text-amber-800 hover:bg-amber-100"
|
|
onClick={() => setPlacementBanner(null)}
|
|
aria-label="닫기"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
<DashboardHeader
|
|
quarter={quarter}
|
|
referenceDate={referenceDate}
|
|
onReferenceDateChange={setReferenceDate}
|
|
stats={stats}
|
|
activeFilters={activeFilters}
|
|
issueFilterActive={issueFilterActive}
|
|
onToggleAll={handleToggleAll}
|
|
onToggleStatus={handleToggleStatus}
|
|
onToggleIssue={handleToggleIssue}
|
|
onOpenDetailWindow={handleOpenDetailWindow}
|
|
onOpenTaskManager={() => setShowTaskManager(true)}
|
|
teamPanelOpen={teamPanelOpen}
|
|
onToggleTeamPanel={() => {
|
|
setTeamPanelOpen((open) => {
|
|
if (open) {
|
|
setActiveTeamProjectId(null);
|
|
setShowAllTeamTasks(false);
|
|
} else if (selectedTaskId) {
|
|
setActiveTeamProjectId(selectedTaskId);
|
|
}
|
|
return !open;
|
|
});
|
|
}}
|
|
/>
|
|
|
|
{teamPanelOpen && (
|
|
<TeamStatusPanel
|
|
members={teamMembers}
|
|
tasks={tasks}
|
|
showAllTasks={showAllTeamTasks}
|
|
activeProjectId={activeTeamProjectId}
|
|
onToggleShowAll={() => setShowAllTeamTasks((v) => !v)}
|
|
onProjectClick={setActiveTeamProjectId}
|
|
onClose={() => {
|
|
setTeamPanelOpen(false);
|
|
setActiveTeamProjectId(null);
|
|
setShowAllTeamTasks(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<main className={`board-main dummy-board-main ${teamPanelOpen ? 'hidden' : ''}`}>
|
|
<div className="page-content">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="board-layout">
|
|
{renderDeptSlot('hrm')}
|
|
{renderDeptSlot('hrd')}
|
|
<HubColumn
|
|
routineTasks={routineTasks}
|
|
quarter={quarter}
|
|
referenceDate={referenceDate}
|
|
onSelectRoutine={(t) => handleSelectTask(t.id)}
|
|
onSelectRoutineMilestone={(taskId, milestoneId) => handleSelectTask(taskId, milestoneId)}
|
|
/>
|
|
{renderDeptSlot('ex')}
|
|
{renderDeptSlot('ga')}
|
|
<BoardConnectors enabled={!teamPanelOpen} style="reference" />
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
|
{activeTask && isProjectTask(activeTask.taskType) ? (
|
|
<article className="project-sub-card board-project-card--overlay">
|
|
<div className="project-sub-body">
|
|
<div className="project-fields">
|
|
<div className="project-sub-title">{activeTask.title}</div>
|
|
</div>
|
|
{activeTask.showProgress !== false && (
|
|
<div className="progress-col">
|
|
<DonutGauge task={activeTask} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
) : activeTask ? (
|
|
<div className="board-routine-item board-project-card--overlay">
|
|
<span className="board-project-title">{activeTask.title}</span>
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</div>
|
|
</main>
|
|
|
|
{showTaskManager && (
|
|
<TaskManager
|
|
tasks={tasks}
|
|
sectionOptions={sectionOptions}
|
|
quarter={quarter}
|
|
teamMembers={teamMembers}
|
|
onClose={() => setShowTaskManager(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{!detailPopupOpen && ultraWideLayout && (
|
|
<aside className={`app-right ${showDockedDetail ? 'detail-open' : ''}`}>
|
|
<TaskDetailShell taskId={selectedTaskId} initialStageId={detailStageId} />
|
|
</aside>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|