EENE Dashboard upload to Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,18 +13,21 @@ import {
|
||||
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 } from '../lib/dualMonitor';
|
||||
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,
|
||||
@@ -43,10 +46,10 @@ import {
|
||||
BOARD_SLOT_ORDER,
|
||||
getBoardSlot,
|
||||
slotSectionLabel,
|
||||
taskBelongsToSlot,
|
||||
columnDisplayTitle,
|
||||
taskBelongsToBoardSlot,
|
||||
} from '../lib/boardLayout';
|
||||
|
||||
const QUARTER = '2026-Q2';
|
||||
import { invalidateTaskCaches } from '../lib/taskQueryCache';
|
||||
|
||||
function mergeCardOrders(primary: string | null | undefined, extras: (string | null | undefined)[]): string[] {
|
||||
const merged: string[] = [];
|
||||
@@ -83,7 +86,9 @@ export default function DashboardPage() {
|
||||
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,
|
||||
);
|
||||
@@ -92,8 +97,9 @@ export default function DashboardPage() {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const socket = useSocket();
|
||||
const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate();
|
||||
|
||||
const { data: tasks = [], isLoading } = useTasks({ quarter: QUARTER });
|
||||
const { data: tasks = [], isLoading } = useTasks({ quarter });
|
||||
const { data: teamMembers = [] } = useTeamMembers();
|
||||
|
||||
const { data: colConfigs } = useQuery({
|
||||
@@ -157,7 +163,7 @@ export default function DashboardPage() {
|
||||
useEffect(() => {
|
||||
BOARD_SLOTS.forEach((slot) => {
|
||||
const label = slotSectionLabel(slot);
|
||||
const ids = tasks.filter((t) => taskBelongsToSlot(t.section, slot)).map((t) => t.id);
|
||||
const ids = tasks.filter((t) => taskBelongsToBoardSlot(t, slot)).map((t) => t.id);
|
||||
setColumnOrders((prev) => {
|
||||
const existing = prev[label] ?? [];
|
||||
const merged = [
|
||||
@@ -178,15 +184,19 @@ export default function DashboardPage() {
|
||||
const patchTask = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
apiClient.patch(`/tasks/${id}`, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
|
||||
});
|
||||
|
||||
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); };
|
||||
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 } }));
|
||||
@@ -206,7 +216,7 @@ export default function DashboardPage() {
|
||||
const draggedTask = tasks.find((t) => t.id === activeId);
|
||||
if (!draggedTask) return;
|
||||
|
||||
const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(draggedTask.section, s));
|
||||
const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToBoardSlot(draggedTask, s));
|
||||
const srcLabel = srcSlot ? slotSectionLabel(srcSlot) : (draggedTask.section ?? '인사관리');
|
||||
|
||||
if (overId.startsWith('drop::')) {
|
||||
@@ -243,7 +253,7 @@ export default function DashboardPage() {
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (!overTask) return;
|
||||
|
||||
const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(overTask.section, s));
|
||||
const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToBoardSlot(overTask, s));
|
||||
const dstLabel = dstSlot ? slotSectionLabel(dstSlot) : (overTask.section ?? '인사관리');
|
||||
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
|
||||
|
||||
@@ -277,19 +287,35 @@ export default function DashboardPage() {
|
||||
}, 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 (issueFilterActive) return !!t.issueNote;
|
||||
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],
|
||||
@@ -317,27 +343,40 @@ export default function DashboardPage() {
|
||||
|
||||
const sectionOptions = BOARD_SLOTS.map((slot) => ({
|
||||
value: slotSectionLabel(slot),
|
||||
label: colConfigs?.find((c) => c.key === slot.sectionKey)?.title
|
||||
?? slot.defaultTitle,
|
||||
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 = !!selectedTaskId && !detailPopupOpen;
|
||||
const detailDocked = showDockedDetail && ultraWideLayout;
|
||||
const showDockedDetail = ultraWideLayout && !!selectedTaskId && !detailPopupOpen;
|
||||
const detailDocked = showDockedDetail;
|
||||
|
||||
const handleSelectTask = (taskId: string) => {
|
||||
const handleSelectTask = (taskId: string, stageId?: string | null) => {
|
||||
setSelectedTaskId(taskId);
|
||||
setDetailStageId(stageId ?? null);
|
||||
if (teamPanelOpen) setActiveTeamProjectId(taskId);
|
||||
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then(() => {
|
||||
setDetailPopupOpen(isDetailWindowOpen());
|
||||
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(() => {
|
||||
setDetailPopupOpen(isDetailWindowOpen());
|
||||
void openDetailWindow(() => setDetailPopupOpen(false)).then((win) => {
|
||||
setDetailPopupOpen(!!win);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -348,9 +387,9 @@ export default function DashboardPage() {
|
||||
<DepartmentColumn
|
||||
key={slot.id}
|
||||
slot={slot}
|
||||
tasks={filtered.filter((t) => taskBelongsToSlot(t.section, slot))}
|
||||
tasks={filtered.filter((t) => taskBelongsToBoardSlot(t, slot))}
|
||||
orderedIds={columnOrders[label] ?? []}
|
||||
quarter={QUARTER}
|
||||
quarter={quarter}
|
||||
onSelectTask={(t) => handleSelectTask(t.id)}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
@@ -360,7 +399,7 @@ export default function DashboardPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-[#e9eef2]">
|
||||
<div className="flex h-screen items-center justify-center bg-white">
|
||||
<div className="text-lg text-gray-400">데이터를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
@@ -368,12 +407,27 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`app-shell ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
|
||||
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 bg-[#e9eef2]">
|
||||
<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}
|
||||
stats={stats}
|
||||
quarter={quarter}
|
||||
referenceDate={referenceDate}
|
||||
onReferenceDateChange={setReferenceDate}
|
||||
stats={stats}
|
||||
activeFilters={activeFilters}
|
||||
issueFilterActive={issueFilterActive}
|
||||
onToggleAll={handleToggleAll}
|
||||
@@ -411,7 +465,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
|
||||
<main className={`board-main dummy-board-main ${teamPanelOpen ? 'hidden' : ''}`}>
|
||||
<div className="page-content">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -424,12 +478,14 @@ export default function DashboardPage() {
|
||||
{renderDeptSlot('hrd')}
|
||||
<HubColumn
|
||||
routineTasks={routineTasks}
|
||||
quarter={QUARTER}
|
||||
quarter={quarter}
|
||||
referenceDate={referenceDate}
|
||||
onSelectRoutine={(t) => handleSelectTask(t.id)}
|
||||
onSelectRoutineMilestone={(taskId, milestoneId) => handleSelectTask(taskId, milestoneId)}
|
||||
/>
|
||||
{renderDeptSlot('ex')}
|
||||
{renderDeptSlot('ga')}
|
||||
<BoardConnectors enabled={!teamPanelOpen} />
|
||||
<BoardConnectors enabled={!teamPanelOpen} style="reference" />
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
||||
@@ -460,16 +516,16 @@ export default function DashboardPage() {
|
||||
<TaskManager
|
||||
tasks={tasks}
|
||||
sectionOptions={sectionOptions}
|
||||
quarter={QUARTER}
|
||||
quarter={quarter}
|
||||
teamMembers={teamMembers}
|
||||
onClose={() => setShowTaskManager(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!detailPopupOpen && (
|
||||
{!detailPopupOpen && ultraWideLayout && (
|
||||
<aside className={`app-right ${showDockedDetail ? 'detail-open' : ''}`}>
|
||||
<TaskDetailShell taskId={selectedTaskId} />
|
||||
<TaskDetailShell taskId={selectedTaskId} initialStageId={detailStageId} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user