286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
import { useState, useEffect, useRef } 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 { DashboardHeader } from '../components/dashboard/DashboardHeader';
|
|
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
|
|
import { TaskManager } from '../components/dashboard/TaskManager';
|
|
import { useSocket } from '../contexts/SocketContext';
|
|
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
|
|
import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType';
|
|
|
|
const QUARTER = '2026-Q2';
|
|
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
|
|
|
|
const COLUMN_STYLES = [
|
|
{ section: '인사관리', titleEn: 'HR Management', headerStyle: { background: 'linear-gradient(120deg,#2a4a8a 0%,#3461b8 50%,#3d72d0 100%)' } },
|
|
{ section: '학습성장', titleEn: 'Learning & Growth', headerStyle: { background: 'linear-gradient(120deg,#5b2d8a 0%,#7340b8 50%,#8a52d0 100%)' } },
|
|
{ section: '운영지원', titleEn: 'Operations', headerStyle: { background: 'linear-gradient(120deg,#0d6080 0%,#0d7a9a 50%,#0e92b8 100%)' } },
|
|
{ section: '전산관리', titleEn: 'IT Management', headerStyle: { background: 'linear-gradient(120deg,#0a6040 0%,#0d8050 50%,#10a060 100%)' } },
|
|
] as const;
|
|
|
|
export default function DashboardPage() {
|
|
const [activeStatus, setActiveStatus] = useState('전체');
|
|
const [showTaskManager, setShowTaskManager] = useState(false);
|
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
|
|
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
|
|
const queryClient = useQueryClient();
|
|
const socket = useSocket();
|
|
|
|
const { data: tasks = [], isLoading } = useTasks({ quarter: QUARTER });
|
|
|
|
const { data: colConfigs } = useQuery({
|
|
queryKey: ['columns', 'all'],
|
|
queryFn: async () => {
|
|
const results = await Promise.all(
|
|
SECTIONS.map((s) =>
|
|
apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => ({ key: s, ...r.data })),
|
|
),
|
|
);
|
|
return results;
|
|
},
|
|
staleTime: 0,
|
|
});
|
|
|
|
// colConfig의 cardOrder로 초기 순서 설정
|
|
useEffect(() => {
|
|
if (!colConfigs) return;
|
|
setColumnOrders((prev) => {
|
|
const next = { ...prev };
|
|
colConfigs.forEach((c) => {
|
|
if (c.cardOrder && !next[c.key]) {
|
|
try { next[c.key] = JSON.parse(c.cardOrder); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
return next;
|
|
});
|
|
}, [colConfigs]);
|
|
|
|
// 새 태스크 추가 시 순서 목록에 병합
|
|
useEffect(() => {
|
|
SECTIONS.forEach((section) => {
|
|
const ids = tasks.filter((t) => t.section === section).map((t) => t.id);
|
|
setColumnOrders((prev) => {
|
|
const existing = prev[section] ?? [];
|
|
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, [section]: 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, any> }) =>
|
|
apiClient.patch(`/tasks/${id}`, data),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!socket) return;
|
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
socket.on('tasks:refresh', refresh);
|
|
socket.on('task:updated', refresh);
|
|
return () => { socket.off('tasks:refresh', refresh); socket.off('task:updated', refresh); };
|
|
}, [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 srcSection = draggedTask.section ?? '';
|
|
|
|
// ── 드롭 대상이 컬럼 드롭존인 경우 ──────────────────────────
|
|
if (overId.startsWith('drop::')) {
|
|
// 형식: "drop::project::섹션명" 또는 "drop::routine::섹션명"
|
|
const parts = overId.split('::');
|
|
const areaType = parts[1]; // 'project' | 'routine'
|
|
const targetSection = parts[2];
|
|
|
|
const updateData: Record<string, any> = {};
|
|
if (targetSection !== srcSection) updateData.section = targetSection;
|
|
if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) {
|
|
updateData.taskType = '기반업무';
|
|
Object.assign(updateData, displayFlagsForTaskType('기반업무'));
|
|
} else if (areaType === 'project' && 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 dstSection = overTask.section ?? '';
|
|
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
|
|
|
|
if (dstSection !== srcSection || typeChanged) {
|
|
const updateData: Record<string, any> = {};
|
|
if (dstSection !== srcSection) updateData.section = dstSection;
|
|
if (typeChanged) {
|
|
const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제';
|
|
updateData.taskType = newType;
|
|
Object.assign(updateData, displayFlagsForTaskType(newType));
|
|
}
|
|
patchTask.mutate({ id: activeId, data: updateData });
|
|
if (dstSection !== srcSection) return;
|
|
}
|
|
|
|
// ── 같은 컬럼 내 순서 변경 ───────────────────────────────────
|
|
const current = columnOrders[srcSection] ?? tasks.filter((t) => t.section === srcSection).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, [srcSection]: newOrder }));
|
|
|
|
if (saveTimers.current[srcSection]) clearTimeout(saveTimers.current[srcSection]);
|
|
saveTimers.current[srcSection] = setTimeout(() => {
|
|
patchColumn.mutate({ section: srcSection, data: { cardOrder: JSON.stringify(newOrder) } });
|
|
}, 300);
|
|
};
|
|
|
|
const stats = {
|
|
total: tasks.length,
|
|
inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length,
|
|
review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO').length,
|
|
done: tasks.filter((t) => t.status === 'DONE').length,
|
|
issues: tasks.filter((t) => !!t.issueNote).length,
|
|
};
|
|
|
|
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;
|
|
});
|
|
|
|
const sectionOptions = SECTIONS.map((s) => ({
|
|
value: s,
|
|
label: colConfigs?.find((c) => c.key === s)?.title ?? s,
|
|
}));
|
|
|
|
const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-screen items-center justify-center bg-slate-100">
|
|
<div className="text-3xl text-gray-400">데이터를 불러오는 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative flex h-screen flex-col overflow-hidden bg-[#eef2f5]" style={{ fontSize: '18px' }}>
|
|
<DashboardHeader
|
|
quarter={QUARTER}
|
|
stats={stats}
|
|
activeStatus={activeStatus}
|
|
onStatusChange={setActiveStatus}
|
|
onOpenDetailWindow={openDetailWindow}
|
|
onOpenTaskManager={() => setShowTaskManager(true)}
|
|
/>
|
|
|
|
<main className="relative flex min-h-0 flex-1 overflow-hidden px-5 py-5">
|
|
{/* ── 좌측 라벨 컬럼 ── */}
|
|
<div className="mr-4 flex w-16 shrink-0 flex-col overflow-hidden rounded-[2rem] bg-white shadow-[0_16px_40px_rgba(15,23,42,0.12)] ring-1 ring-slate-200/70">
|
|
<div className="h-10 shrink-0 border-b border-slate-100 bg-slate-50" />
|
|
<div className="flex flex-1 items-center justify-center border-b border-slate-100 bg-gradient-to-b from-slate-50 to-white">
|
|
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}>실행과제</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center justify-center bg-gradient-to-b from-white to-slate-50" style={{ height: 300 }}>
|
|
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}>기반업무</span>
|
|
</div>
|
|
</div>
|
|
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="grid h-full min-h-0 flex-1 grid-cols-4 gap-4 overflow-hidden">
|
|
{COLUMN_STYLES.map(({ section, titleEn, headerStyle }) => (
|
|
<DepartmentColumn
|
|
key={section}
|
|
title={section}
|
|
titleEn={titleEn}
|
|
tasks={filtered.filter((t) => t.section === section)}
|
|
orderedIds={columnOrders[section] ?? []}
|
|
headerBg=""
|
|
headerStyle={headerStyle}
|
|
storageKey={`col_${section}`}
|
|
section={section}
|
|
quarter={QUARTER}
|
|
onSelectTask={(t) => sendTaskSelected(t.id)}
|
|
sectionOptions={sectionOptions}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
|
{activeTask ? (
|
|
<div className="rotate-1 rounded-[1.35rem] border border-white/80 bg-white px-5 py-4 opacity-90 shadow-[0_28px_56px_rgba(15,23,42,0.22)] ring-1 ring-slate-200/60">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<span className="min-w-0 truncate text-2xl font-black leading-snug text-slate-900">{activeTask.title}</span>
|
|
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${
|
|
activeTask.progress >= 70 ? 'text-emerald-500' : activeTask.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
|
|
}`}>{activeTask.progress}%</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</main>
|
|
|
|
{showTaskManager && (
|
|
<TaskManager
|
|
tasks={tasks}
|
|
sectionOptions={sectionOptions}
|
|
quarter={QUARTER}
|
|
onClose={() => setShowTaskManager(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|