feat: quarter board theme, hub column, and team panel UX
Apply preview-style 4-dept layout with center hub, PM/assignee team status linking, task type label updates, and remove task keywords. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Task, TeamMember } from '../../types';
|
||||
import { normalizeTaskType, displayFlagsForTaskType } from '../../lib/taskType';
|
||||
import { normalizeTaskType, displayFlagsForTaskType, TASK_TYPE_OPTIONS } from '../../lib/taskType';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'TODO', label: '대기' },
|
||||
@@ -27,7 +27,6 @@ export interface TaskFormData {
|
||||
showStatus: boolean;
|
||||
showIssue: boolean;
|
||||
showProgress: boolean;
|
||||
keywords: string;
|
||||
pmMemberId: string;
|
||||
assigneeMemberIds: string[];
|
||||
}
|
||||
@@ -76,7 +75,6 @@ export function TaskModal({
|
||||
showStatus: task?.showStatus ?? true,
|
||||
showIssue: task?.showIssue ?? true,
|
||||
showProgress: task?.showProgress ?? true,
|
||||
keywords: task?.keywords ?? '',
|
||||
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
|
||||
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
|
||||
});
|
||||
@@ -168,8 +166,9 @@ export function TaskModal({
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
|
||||
>
|
||||
<option value="기반업무">기반업무</option>
|
||||
<option value="실행과제">실행과제</option>
|
||||
{TASK_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,17 +298,6 @@ export function TaskModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 키워드 */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">키워드 (콤마로 구분)</label>
|
||||
<input
|
||||
value={form.keywords}
|
||||
onChange={(e) => set('keywords', e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
||||
placeholder="예: 인사정보, ERP, 입퇴사 분석"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 기간 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
|
||||
11
frontend/src/components/dashboard/BoardConnectors.tsx
Normal file
11
frontend/src/components/dashboard/BoardConnectors.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useBoardConnectors } from '../../hooks/useBoardConnectors';
|
||||
|
||||
export function BoardConnectors({ enabled = true }: { enabled?: boolean }) {
|
||||
const { svgRef, lineGroupRef } = useBoardConnectors(enabled);
|
||||
|
||||
return (
|
||||
<svg ref={svgRef} className="connectors" aria-hidden="true">
|
||||
<g ref={lineGroupRef} id="connector-lines" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
||||
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
|
||||
import { COLUMN_META, type SectionKey } from '../../lib/sections';
|
||||
import { isProjectTask } from '../../lib/taskType';
|
||||
import { type BoardSlotConfig, slotSectionLabel } from '../../lib/boardLayout';
|
||||
import { SortableTaskCard } from './TaskCard';
|
||||
import { ContextMenu } from '../common/ContextMenu';
|
||||
import { TaskModal } from '../common/TaskModal';
|
||||
@@ -13,13 +13,29 @@ import type { TaskFormData } from '../common/TaskModal';
|
||||
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
||||
import type { Task, TeamMember } from '../../types';
|
||||
|
||||
const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1';
|
||||
|
||||
type DummyHeaders = Record<string, { title: string; titleEn: string; subtitle: string }>;
|
||||
|
||||
function loadDummyHeaders(): DummyHeaders {
|
||||
try {
|
||||
const raw = localStorage.getItem(DUMMY_HEADER_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveDummyHeader(slotId: string, data: { title: string; titleEn: string; subtitle: string }) {
|
||||
const all = loadDummyHeaders();
|
||||
all[slotId] = data;
|
||||
localStorage.setItem(DUMMY_HEADER_KEY, JSON.stringify(all));
|
||||
}
|
||||
|
||||
interface DepartmentColumnProps {
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
accent?: string;
|
||||
slot: BoardSlotConfig;
|
||||
tasks: Task[];
|
||||
orderedIds: string[];
|
||||
section: SectionKey;
|
||||
quarter: string;
|
||||
onSelectTask?: (task: Task) => void;
|
||||
sectionOptions?: { value: string; label: string }[];
|
||||
@@ -59,7 +75,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
||||
autoFocus
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -67,7 +83,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
||||
<input
|
||||
value={draftTitleEn}
|
||||
onChange={(e) => setDraftTitleEn(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-emerald-400"
|
||||
placeholder="HRM"
|
||||
/>
|
||||
</div>
|
||||
@@ -76,12 +92,12 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
||||
<input
|
||||
value={draftSubtitle}
|
||||
onChange={(e) => setDraftSubtitle(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50 transition">취소</button>
|
||||
<button type="submit" className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition">저장</button>
|
||||
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50">취소</button>
|
||||
<button type="submit" className="px-6 py-2.5 rounded-xl bg-emerald-700 text-white font-bold hover:bg-emerald-800">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -91,36 +107,44 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
||||
}
|
||||
|
||||
export function DepartmentColumn({
|
||||
title: initialTitle,
|
||||
titleEn,
|
||||
accent,
|
||||
slot,
|
||||
tasks,
|
||||
orderedIds,
|
||||
section,
|
||||
quarter,
|
||||
onSelectTask,
|
||||
sectionOptions: externalSectionOptions,
|
||||
teamMembers = [],
|
||||
}: DepartmentColumnProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const meta = COLUMN_META[section];
|
||||
const section = slotSectionLabel(slot);
|
||||
const isDummySlot = !slot.sectionKey;
|
||||
|
||||
const { data: colConfig } = useQuery({
|
||||
queryKey: ['columns', section],
|
||||
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(section)}`).then((r) => r.data),
|
||||
queryKey: ['columns', slot.sectionKey],
|
||||
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(slot.sectionKey!)}`).then((r) => r.data),
|
||||
enabled: !!slot.sectionKey,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [dummyHeader, setDummyHeader] = useState(() => loadDummyHeaders()[slot.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setDummyHeader(loadDummyHeaders()[slot.id]);
|
||||
}, [slot.id]);
|
||||
|
||||
const patchColumn = useMutation({
|
||||
mutationFn: (data: Record<string, string>) =>
|
||||
apiClient.patch(`/columns/${encodeURIComponent(section)}`, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }),
|
||||
apiClient.patch(`/columns/${encodeURIComponent(slot.sectionKey!)}`, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', slot.sectionKey] }),
|
||||
});
|
||||
|
||||
const title = colConfig?.title ?? initialTitle;
|
||||
const titleEnState = colConfig?.titleEn ?? (titleEn ?? meta.titleEn);
|
||||
const subtitle = colConfig?.subtitle ?? '';
|
||||
const accentColor = accent ?? meta.accent;
|
||||
const title = isDummySlot
|
||||
? dummyHeader?.title ?? slot.defaultTitle
|
||||
: colConfig?.title ?? slot.defaultTitle;
|
||||
const titleEnState = isDummySlot
|
||||
? dummyHeader?.titleEn ?? slot.defaultTitleEn
|
||||
: colConfig?.titleEn ?? slot.defaultTitleEn;
|
||||
const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? '';
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
|
||||
const [cardMenu, setCardMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
|
||||
@@ -129,8 +153,9 @@ export function DepartmentColumn({
|
||||
const [showHeaderModal, setShowHeaderModal] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
|
||||
const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` });
|
||||
const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({ id: `drop::routine::${section}` });
|
||||
const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({
|
||||
id: `drop::project::${section}`,
|
||||
});
|
||||
|
||||
const orderedTasks = [...tasks].sort((a, b) => {
|
||||
const ai = orderedIds.indexOf(a.id);
|
||||
@@ -142,9 +167,6 @@ export function DepartmentColumn({
|
||||
});
|
||||
|
||||
const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType));
|
||||
const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType));
|
||||
|
||||
const patchColumnField = (field: string, value: string) => patchColumn.mutate({ [field]: value });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
|
||||
@@ -211,33 +233,41 @@ export function DepartmentColumn({
|
||||
}
|
||||
};
|
||||
|
||||
const saveHeader = (t: string, te: string, s: string) => {
|
||||
if (isDummySlot) {
|
||||
saveDummyHeader(slot.id, { title: t, titleEn: te, subtitle: s });
|
||||
setDummyHeader({ title: t, titleEn: te, subtitle: s });
|
||||
} else {
|
||||
patchColumn.mutate({ title: t, titleEn: te, subtitle: s });
|
||||
}
|
||||
setShowHeaderModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="board-dept-column"
|
||||
<section
|
||||
className={`dept-card ${slot.cssClass}`}
|
||||
onContextMenuCapture={handleColumnContextMenuCapture}
|
||||
>
|
||||
<div
|
||||
className="board-dept-header"
|
||||
style={{ borderBottomColor: accentColor }}
|
||||
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }}
|
||||
className="dept-head"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' });
|
||||
}}
|
||||
>
|
||||
<div className="board-dept-header-main">
|
||||
<div className="board-dept-title-wrap">
|
||||
<h2 className="board-dept-title" style={{ color: accentColor }}>
|
||||
{title.replace(/\s*부문$/, '')}
|
||||
</h2>
|
||||
{titleEnState && (
|
||||
<span className="board-dept-title-en" style={{ color: accentColor }}>
|
||||
{titleEnState}
|
||||
</span>
|
||||
)}
|
||||
<div className="dept-head-main">
|
||||
<div className="board-dept-header-main">
|
||||
<div className="board-dept-title-wrap">
|
||||
<h2 className="board-dept-title">{title.replace(/\s*부문$/, '')}</h2>
|
||||
{titleEnState && <span className="board-dept-title-en">{titleEnState}</span>}
|
||||
</div>
|
||||
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="board-dept-count-badge" style={{ color: accentColor }}>
|
||||
<span className="board-dept-count-val">{tasks.length}</span>
|
||||
<span className="board-dept-count-unit">건</span>
|
||||
<div className="dept-head-count" aria-label={`${projectTasks.length}건`}>
|
||||
<span className="poly-stat-val">{projectTasks.length}</span>
|
||||
<span className="poly-stat-unit"> 건</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -262,35 +292,7 @@ export function DepartmentColumn({
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={setRoutineDropRef}
|
||||
className={`board-routine-section ${isRoutineOver ? 'is-over' : ''}`}
|
||||
style={{
|
||||
borderTopColor: `${accentColor}80`,
|
||||
background: COLUMN_META[section].routineBg,
|
||||
}}
|
||||
onContextMenu={handleListContextMenu}
|
||||
>
|
||||
<div className="board-routine-list">
|
||||
{routineTasks.length === 0 ? (
|
||||
<div className="board-routine-empty" aria-hidden />
|
||||
) : (
|
||||
<SortableContext items={routineTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
{routineTasks.map((task) => (
|
||||
<SortableTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
variant="routine"
|
||||
sectionOptions={sectionOptions}
|
||||
onSelect={onSelectTask}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{cardMenu && (
|
||||
<ContextMenu
|
||||
@@ -346,12 +348,7 @@ export function DepartmentColumn({
|
||||
title={title}
|
||||
titleEn={titleEnState}
|
||||
subtitle={subtitle}
|
||||
onSave={(t, te, s) => {
|
||||
patchColumnField('title', t);
|
||||
patchColumnField('titleEn', te);
|
||||
patchColumnField('subtitle', s);
|
||||
setShowHeaderModal(false);
|
||||
}}
|
||||
onSave={saveHeader}
|
||||
onClose={() => setShowHeaderModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
19
frontend/src/components/dashboard/DonutGauge.tsx
Normal file
19
frontend/src/components/dashboard/DonutGauge.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Task } from '../../types';
|
||||
import { getDonutDisplay } from '../../lib/taskStatusVisual';
|
||||
|
||||
export function DonutGauge({ task }: { task: Pick<Task, 'status' | 'progress'> }) {
|
||||
const display = getDonutDisplay(task);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="donut"
|
||||
style={{
|
||||
['--pct' as string]: display.pct,
|
||||
['--color' as string]: display.ringColor,
|
||||
}}
|
||||
aria-label={display.ariaLabel}
|
||||
>
|
||||
<span style={{ color: display.labelColor }}>{display.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
402
frontend/src/components/dashboard/HubColumn.tsx
Normal file
402
frontend/src/components/dashboard/HubColumn.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { Task } from '../../types';
|
||||
import { useHubConfig, type HubConfig, type HubScheduleItem } from '../../lib/hubConfig';
|
||||
import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSchedule';
|
||||
import { HubScheduleCarousel } from './HubScheduleCarousel';
|
||||
import { ContextMenu } from '../common/ContextMenu';
|
||||
|
||||
interface HubColumnProps {
|
||||
routineTasks: Task[];
|
||||
quarter?: string;
|
||||
onSelectRoutine?: (task: Task) => void;
|
||||
}
|
||||
|
||||
type HubEditSection = 'slogan' | 'routine' | 'schedule';
|
||||
|
||||
function ModalShell({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
onSubmit,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
}) {
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl w-[min(480px,92vw)] max-h-[90vh] overflow-y-auto p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xl font-black text-gray-800">{title}</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">✕</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{children}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" className="px-6 py-2.5 rounded-xl bg-emerald-700 text-white font-bold hover:bg-emerald-800">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function SloganEditModal({
|
||||
config,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
config: HubConfig;
|
||||
onSave: (patch: Pick<HubConfig, 'sloganTitle' | 'sloganLines'>) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(config.sloganTitle);
|
||||
const [lines, setLines] = useState(config.sloganLines.join('\n'));
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="분기 슬로건 수정"
|
||||
onClose={onClose}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSave({ sloganTitle: title.trim() || '분기 슬로건', sloganLines: lines.split('\n') });
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">제목</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">내용 (줄마다 Enter)</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={lines}
|
||||
onChange={(e) => setLines(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 resize-y"
|
||||
/>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutineEditModal({
|
||||
config,
|
||||
hasRoutineTasks,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
config: HubConfig;
|
||||
hasRoutineTasks: boolean;
|
||||
onSave: (patch: Pick<HubConfig, 'routineLabels'>) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [labels, setLabels] = useState(config.routineLabels.join(', '));
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="상시업무 수정"
|
||||
onClose={onClose}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
routineLabels: labels
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 6),
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{hasRoutineTasks && (
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
등록된 기반업무가 있으면 다이아몬드에 업무 제목이 우선 표시됩니다. 아래 라벨은 빈 칸·업무 없을 때 사용됩니다.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">표시 라벨 (쉼표 구분, 최대 6개)</label>
|
||||
<input
|
||||
value={labels}
|
||||
onChange={(e) => setLabels(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
|
||||
placeholder="채용, 교육, 소통, 시설, 자산, 행정"
|
||||
/>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleEditModal({
|
||||
config,
|
||||
quarter,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
config: HubConfig;
|
||||
quarter: string;
|
||||
onSave: (patch: Pick<HubConfig, 'scheduleTitle' | 'scheduleItems'>) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { min, max } = quarterDateBounds(quarter);
|
||||
const [title, setTitle] = useState(config.scheduleTitle);
|
||||
const [items, setItems] = useState<HubScheduleItem[]>(() => structuredClone(config.scheduleItems));
|
||||
|
||||
const updateItem = (id: string, patch: Partial<HubScheduleItem>) => {
|
||||
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setItems((prev) => [...prev, { id: String(Date.now()), date: todayIso(), text: '' }]);
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
setItems((prev) => (prev.length <= 1 ? prev : prev.filter((item) => item.id !== id)));
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="분기 주요 일정 수정"
|
||||
onClose={onClose}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const valid = items.filter((item) => item.date && item.text.trim());
|
||||
onSave({
|
||||
scheduleTitle: title.trim() || '분기 주요 일정',
|
||||
scheduleItems: sortScheduleItems(valid),
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">제목</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-gray-500">일정 항목 (날짜 · 내용)</label>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-2 items-center">
|
||||
<input
|
||||
type="date"
|
||||
value={item.date}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(e) => updateItem(item.id, { date: e.target.value })}
|
||||
className="shrink-0 w-[140px] border border-gray-200 rounded-lg px-2 py-2 text-sm"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
value={item.text}
|
||||
onChange={(e) => updateItem(item.id, { text: e.target.value })}
|
||||
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="일정 내용"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="shrink-0 px-2 text-gray-400 hover:text-red-500"
|
||||
title="항목 삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="text-sm font-semibold text-emerald-700 hover:text-emerald-800"
|
||||
>
|
||||
+ 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
function openSectionContextMenu(
|
||||
e: React.MouseEvent,
|
||||
section: HubEditSection,
|
||||
setCtxMenu: (menu: { x: number; y: number; section: HubEditSection } | null) => void,
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, section });
|
||||
}
|
||||
|
||||
export function HubColumn({ routineTasks, quarter = '2026-Q2', onSelectRoutine }: HubColumnProps) {
|
||||
const { config, setConfig } = useHubConfig();
|
||||
const [editSection, setEditSection] = useState<HubEditSection | null>(null);
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; section: HubEditSection } | null>(null);
|
||||
const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({
|
||||
id: 'drop::routine::hub',
|
||||
});
|
||||
|
||||
const routineLabels =
|
||||
routineTasks.length > 0
|
||||
? routineTasks.slice(0, 6).map((t) => ({ label: t.title, task: t }))
|
||||
: config.routineLabels.map((label) => ({ label, task: null as Task | null }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hub-column" id="hub-column">
|
||||
<div
|
||||
className="hub-box hub-box--message"
|
||||
onContextMenu={(e) => openSectionContextMenu(e, 'slogan', setCtxMenu)}
|
||||
>
|
||||
<div className="hub-postit-stack">
|
||||
<div className="hub-postit-sheet hub-postit-sheet--back" aria-hidden="true" />
|
||||
<div className="hub-postit-sheet hub-postit-sheet--front">
|
||||
<span className="hub-postit-pin" aria-hidden="true" />
|
||||
<span className="hub-postit-fold" aria-hidden="true" />
|
||||
<div className="hub-postit-content">
|
||||
<div className="hub-postit-header">
|
||||
<div className="hub-box-title-row">
|
||||
<span className="hub-message-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4v5" />
|
||||
<path d="M12 15v5" />
|
||||
<path d="M4 12h5" />
|
||||
<path d="M15 12h5" />
|
||||
<path d="M18 5v2" />
|
||||
<path d="M17 6h2" />
|
||||
<path d="M5 17v2" />
|
||||
<path d="M4 18h2" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="board-project-title">{config.sloganTitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hub-postit-message">
|
||||
{config.sloganLines.filter(Boolean).map((line, i) => (
|
||||
<p key={i} className="board-project-desc">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hub-diamond-wrap"
|
||||
id="hub-diamond-wrap"
|
||||
onContextMenu={(e) => openSectionContextMenu(e, 'routine', setCtxMenu)}
|
||||
>
|
||||
<div
|
||||
ref={setRoutineDropRef}
|
||||
className={`hub-diamond ${isRoutineOver ? 'is-over' : ''}`}
|
||||
id="hub-diamond"
|
||||
>
|
||||
<div className="hub-diamond-inner">
|
||||
<div className="hub-diamond-head">
|
||||
<span className="hub-diamond-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="m17 2 4 4-4 4" />
|
||||
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
|
||||
<path d="m7 22-4-4 4-4" />
|
||||
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="board-project-title">상시업무</div>
|
||||
</div>
|
||||
<div className="hub-diamond-divider" aria-hidden="true" />
|
||||
<div className="hub-routine-grid">
|
||||
{routineLabels.map(({ label, task }, i) => (
|
||||
<button
|
||||
key={task?.id ?? `label-${i}`}
|
||||
type="button"
|
||||
className="hub-routine-item"
|
||||
onClick={() => task && onSelectRoutine?.(task)}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hub-box hub-box--focus"
|
||||
onContextMenu={(e) => openSectionContextMenu(e, 'schedule', setCtxMenu)}
|
||||
>
|
||||
<div className="hub-schedule-planner">
|
||||
<div className="hub-schedule-head">
|
||||
<span className="hub-schedule-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4" />
|
||||
<path d="M8 2v4" />
|
||||
<path d="M3 10h18" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="board-project-title">{config.scheduleTitle}</div>
|
||||
</div>
|
||||
<HubScheduleCarousel items={config.scheduleItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
items={[
|
||||
{
|
||||
icon: '✏',
|
||||
label: '수정',
|
||||
onClick: () => setEditSection(ctxMenu.section),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editSection === 'slogan' && (
|
||||
<SloganEditModal
|
||||
config={config}
|
||||
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
|
||||
onClose={() => setEditSection(null)}
|
||||
/>
|
||||
)}
|
||||
{editSection === 'routine' && (
|
||||
<RoutineEditModal
|
||||
config={config}
|
||||
hasRoutineTasks={routineTasks.length > 0}
|
||||
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
|
||||
onClose={() => setEditSection(null)}
|
||||
/>
|
||||
)}
|
||||
{editSection === 'schedule' && (
|
||||
<ScheduleEditModal
|
||||
config={config}
|
||||
quarter={quarter}
|
||||
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
|
||||
onClose={() => setEditSection(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/dashboard/HubScheduleCarousel.tsx
Normal file
95
frontend/src/components/dashboard/HubScheduleCarousel.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { HubScheduleItem } from '../../lib/hubConfig';
|
||||
import {
|
||||
findScheduleIndexForToday,
|
||||
formatScheduleDateLabel,
|
||||
isSchedulePast,
|
||||
isScheduleToday,
|
||||
scheduleWindowStart,
|
||||
sortScheduleItems,
|
||||
} from '../../lib/hubSchedule';
|
||||
|
||||
const VISIBLE_COUNT = 3;
|
||||
|
||||
interface HubScheduleCarouselProps {
|
||||
items: HubScheduleItem[];
|
||||
}
|
||||
|
||||
export function HubScheduleCarousel({ items }: HubScheduleCarouselProps) {
|
||||
const sorted = useMemo(
|
||||
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
|
||||
[items],
|
||||
);
|
||||
|
||||
const todayIndex = useMemo(() => findScheduleIndexForToday(sorted), [sorted]);
|
||||
const initialStart = useMemo(
|
||||
() => scheduleWindowStart(sorted.length, todayIndex, VISIBLE_COUNT),
|
||||
[sorted.length, todayIndex],
|
||||
);
|
||||
const [startIndex, setStartIndex] = useState(initialStart);
|
||||
|
||||
useEffect(() => {
|
||||
setStartIndex(initialStart);
|
||||
}, [initialStart, sorted.length]);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="hub-schedule-viewport hub-schedule-viewport--empty">
|
||||
<p className="board-project-desc hub-schedule-empty">등록된 일정 없음</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxStart = Math.max(0, sorted.length - VISIBLE_COUNT);
|
||||
const canPrev = startIndex > 0;
|
||||
const canNext = startIndex < maxStart;
|
||||
|
||||
const stepPrev = () => setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
|
||||
const stepNext = () => setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
|
||||
|
||||
const visible = sorted.slice(startIndex, startIndex + VISIBLE_COUNT);
|
||||
|
||||
return (
|
||||
<div className="hub-schedule-viewport">
|
||||
<button
|
||||
type="button"
|
||||
className="hub-schedule-nav hub-schedule-nav--prev"
|
||||
disabled={!canPrev}
|
||||
onClick={stepPrev}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
aria-label="이전 일정"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<ul className="hub-schedule-list hub-list">
|
||||
{visible.map((item) => {
|
||||
const past = isSchedulePast(item.date);
|
||||
const today = isScheduleToday(item.date);
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
|
||||
>
|
||||
<span className="hub-schedule-date board-project-desc">
|
||||
{formatScheduleDateLabel(item.date)}
|
||||
</span>
|
||||
<span className="board-project-desc">{item.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="hub-schedule-nav hub-schedule-nav--next"
|
||||
disabled={!canNext}
|
||||
onClick={stepNext}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
aria-label="다음 일정"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export function MemberTaskTooltip({
|
||||
const badge = taskStatusBadge(task);
|
||||
const isActive = activeProjectId === task.id;
|
||||
const subtitle = taskSubtitle(task);
|
||||
const pmName = task.pmMember?.name ?? '미정';
|
||||
const assigneeNames = task.assigneeMembers?.length
|
||||
? task.assigneeMembers.map((m) => m.name).join(', ')
|
||||
: '미정';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,15 +62,11 @@ export function MemberTaskTooltip({
|
||||
{isActive && (
|
||||
<div className="tooltip-project-detail">
|
||||
<span>
|
||||
PM: <strong>{task.pmMember?.name ?? '미정'}</strong>
|
||||
PM: <strong>{pmName}</strong>
|
||||
</span>
|
||||
<span className="tooltip-detail-sep" aria-hidden>·</span>
|
||||
<span>
|
||||
담당자:{' '}
|
||||
<strong>
|
||||
{task.assigneeMembers?.length
|
||||
? task.assigneeMembers.map((m) => m.name).join(', ')
|
||||
: '미정'}
|
||||
</strong>
|
||||
담당자: <strong>{assigneeNames}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,14 +4,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import type { Task } from '../../types';
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
IN_PROGRESS: 'ongoing',
|
||||
REVIEW: 'hold',
|
||||
TODO: 'hold',
|
||||
CANCELLED: 'hold',
|
||||
DONE: 'done',
|
||||
};
|
||||
import { DonutGauge } from './DonutGauge';
|
||||
|
||||
function fmtDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
@@ -32,51 +25,6 @@ function firstDescriptionLine(text: string | null | undefined): string {
|
||||
return line ?? '';
|
||||
}
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
return STATUS_DOT[status] ?? 'hold';
|
||||
}
|
||||
|
||||
function SemiCircleGauge({ value }: { value: number }) {
|
||||
const p = Math.min(100, Math.max(0, value));
|
||||
const stroke = 6.75;
|
||||
const w = 88;
|
||||
const h = 56;
|
||||
const cx = 44;
|
||||
const r = 32;
|
||||
/** arc 좌·우 끝 = 숫자 세로 중앙 (100%도 여유 있게) */
|
||||
const cy = 46;
|
||||
const arcLen = Math.PI * r;
|
||||
const dash = (p / 100) * arcLen;
|
||||
const path = `M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="board-gauge"
|
||||
style={{ ['--gauge-cy' as string]: `${(cy / h) * 100}%` }}
|
||||
aria-label={`진행률 ${p}%`}
|
||||
>
|
||||
<svg className="board-gauge-svg" viewBox={`0 0 ${w} ${h}`} aria-hidden>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#d4e8de"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#29724f"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="butt"
|
||||
strokeDasharray={`${dash} ${arcLen}`}
|
||||
/>
|
||||
</svg>
|
||||
<span className="board-gauge-value">{p}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionOption = { value: string; label: string };
|
||||
|
||||
export function SortableTaskCard({
|
||||
@@ -87,6 +35,7 @@ export function SortableTaskCard({
|
||||
task: Task;
|
||||
variant?: 'project' | 'routine';
|
||||
sectionOptions?: SectionOption[];
|
||||
accent?: string;
|
||||
onSelect?: (task: Task) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
||||
@@ -155,9 +104,8 @@ export function TaskCard({
|
||||
onKeyDown: dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined,
|
||||
};
|
||||
|
||||
const dotClass = statusDotClass(task.status);
|
||||
|
||||
if (variant === 'routine') {
|
||||
const descLine = firstDescriptionLine(task.description);
|
||||
return (
|
||||
<div
|
||||
ref={dragRef}
|
||||
@@ -168,8 +116,8 @@ export function TaskCard({
|
||||
className="board-routine-item"
|
||||
{...dragHandlers}
|
||||
>
|
||||
<span className={`board-status-dot board-status-dot--${dotClass}`} aria-hidden />
|
||||
<span className="board-routine-name">{task.title}</span>
|
||||
<span className="board-project-title">{task.title}</span>
|
||||
{descLine && <p className="board-project-desc">• {descLine}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -179,33 +127,45 @@ export function TaskCard({
|
||||
const showProgress = task.showProgress !== false;
|
||||
|
||||
return (
|
||||
<div
|
||||
<article
|
||||
ref={dragRef}
|
||||
style={dragStyle}
|
||||
{...dragAttributes}
|
||||
data-task-card="true"
|
||||
data-task-id={task.id}
|
||||
className="board-project-card"
|
||||
className="project-sub-card"
|
||||
{...dragHandlers}
|
||||
>
|
||||
<div className="board-project-top">
|
||||
<div className="board-project-main">
|
||||
<div className="board-project-title-row">
|
||||
<span className={`board-status-dot board-status-dot--${dotClass}`} aria-hidden />
|
||||
<span className="board-project-title">{task.title}</span>
|
||||
</div>
|
||||
{dateRange && <p className="board-project-date">{dateRange}</p>}
|
||||
<div className="project-sub-body">
|
||||
<div className="project-fields">
|
||||
<div className="project-sub-title">{task.title}</div>
|
||||
{dateRange && (
|
||||
<div className="project-field">
|
||||
<span className="project-field-label">작업 기간</span>
|
||||
<span className="project-field-value">{dateRange}</span>
|
||||
</div>
|
||||
)}
|
||||
{descLine && (
|
||||
<div className="project-field">
|
||||
<span className="project-field-label">주요 내용</span>
|
||||
<span className="project-field-value">{descLine}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.showIssue && task.issueNote && (
|
||||
<div className="project-field">
|
||||
<span className="project-field-label">이슈</span>
|
||||
<span className="project-field-value" style={{ color: '#c0392b' }}>
|
||||
{task.issueNote}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showProgress && <SemiCircleGauge value={task.progress} />}
|
||||
{showProgress && (
|
||||
<div className="progress-col">
|
||||
<DonutGauge task={task} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{descLine && (
|
||||
<p className="board-project-desc">• {descLine}</p>
|
||||
)}
|
||||
|
||||
{task.showIssue && task.issueNote && (
|
||||
<p className="board-project-issue">▶ {task.issueNote}</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
234
frontend/src/hooks/useBoardConnectors.ts
Normal file
234
frontend/src/hooks/useBoardConnectors.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function relBox(el: Element, parent: Element): Box {
|
||||
const r = el.getBoundingClientRect();
|
||||
const p = parent.getBoundingClientRect();
|
||||
return {
|
||||
left: r.left - p.left,
|
||||
top: r.top - p.top,
|
||||
right: r.right - p.left,
|
||||
bottom: r.bottom - p.top,
|
||||
cx: r.left - p.left + r.width / 2,
|
||||
cy: r.top - p.top + r.height / 2,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
};
|
||||
}
|
||||
|
||||
function snap(n: number) {
|
||||
return Math.round(n * 2) / 2;
|
||||
}
|
||||
|
||||
function pointsToPath(points: Point[]) {
|
||||
return points
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${snap(p.x).toFixed(1)} ${snap(p.y).toFixed(1)}`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function diamondVertex(cx: number, cy: number, size: number, vertex: 'top' | 'right' | 'bottom' | 'left'): Point {
|
||||
const r = size / Math.SQRT2;
|
||||
if (vertex === 'top') return { x: cx, y: cy - r };
|
||||
if (vertex === 'right') return { x: cx + r, y: cy };
|
||||
if (vertex === 'bottom') return { x: cx, y: cy + r };
|
||||
return { x: cx - r, y: cy };
|
||||
}
|
||||
|
||||
function diamondEdgeMidpoint(cx: number, cy: number, size: number, edge: string): Point {
|
||||
const d = size / (2 * Math.SQRT2);
|
||||
if (edge === 'top-left') return { x: cx - d, y: cy - d };
|
||||
if (edge === 'top-right') return { x: cx + d, y: cy - d };
|
||||
if (edge === 'bottom-left') return { x: cx - d, y: cy + d };
|
||||
return { x: cx + d, y: cy + d };
|
||||
}
|
||||
|
||||
function cardEdgeAnchor(cardEl: Element, layout: Element, side: 'left' | 'right', y: number): Point {
|
||||
const cardBox = relBox(cardEl, layout);
|
||||
const clampedY = Math.max(cardBox.top + 10, Math.min(cardBox.bottom - 10, y));
|
||||
return { x: side === 'right' ? cardBox.right : cardBox.left, y: clampedY };
|
||||
}
|
||||
|
||||
const FACE_LINKS = [
|
||||
{ edge: 'top-left', card: '.dept-card--hrm', side: 'right' as const, vert: 'top' as const, knee: 'left' as const },
|
||||
{ edge: 'top-right', card: '.dept-card--hrd', side: 'left' as const, vert: 'top' as const, knee: 'right' as const },
|
||||
{ edge: 'bottom-left', card: '.dept-card--ex', side: 'right' as const, vert: 'bottom' as const, knee: 'left' as const },
|
||||
{ edge: 'bottom-right', card: '.dept-card--ga', side: 'left' as const, vert: 'bottom' as const, knee: 'right' as const },
|
||||
];
|
||||
|
||||
function buildBentPath(
|
||||
cardAnchor: Point,
|
||||
edgeMid: Point,
|
||||
side: 'left' | 'right',
|
||||
kneeX: number,
|
||||
approachX: number,
|
||||
): Point[] {
|
||||
const minLeg = 18;
|
||||
let x1 = kneeX;
|
||||
if (side === 'right') {
|
||||
if (x1 < cardAnchor.x + minLeg) x1 = cardAnchor.x + minLeg;
|
||||
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
|
||||
}
|
||||
if (x1 > cardAnchor.x - minLeg) x1 = cardAnchor.x - minLeg;
|
||||
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
|
||||
}
|
||||
|
||||
function anchorYForSymmetricFace(faceY: number, runX: number, vert: 'top' | 'bottom') {
|
||||
return vert === 'top' ? faceY + runX : faceY - runX;
|
||||
}
|
||||
|
||||
function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null) {
|
||||
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
|
||||
const wrap = diamond.parentElement;
|
||||
if (!wrap) return;
|
||||
const rowW = wrap.clientWidth;
|
||||
const rowH = wrap.clientHeight;
|
||||
const fitInRow = 1.04 / Math.SQRT2;
|
||||
let size = Math.min(rowW * fitInRow, rowH * fitInRow);
|
||||
let scale = 0.9;
|
||||
const v = getComputedStyle(hubColumn).getPropertyValue('--hub-diamond-scale').trim();
|
||||
if (v) scale = parseFloat(v) || scale;
|
||||
size = Math.max(Math.floor(size * scale), Math.floor(150 * scale));
|
||||
diamond.style.width = `${size}px`;
|
||||
diamond.style.height = `${size}px`;
|
||||
}
|
||||
|
||||
export function useBoardConnectors(enabled = true) {
|
||||
const lineGroupRef = useRef<SVGGElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const drawConnectors = () => {
|
||||
const layout = document.querySelector('.board-layout');
|
||||
const diamond = document.getElementById('hub-diamond');
|
||||
const hubColumn = document.getElementById('hub-column');
|
||||
const lineGroup = lineGroupRef.current;
|
||||
const svg = svgRef.current;
|
||||
if (!layout || !diamond || !lineGroup || !svg) return;
|
||||
|
||||
fitDiamond(hubColumn, diamond);
|
||||
|
||||
if (window.innerWidth <= 1200) {
|
||||
lineGroup.innerHTML = '';
|
||||
svg.removeAttribute('viewBox');
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutBox = layout.getBoundingClientRect();
|
||||
const diamondBox = relBox(diamond, layout);
|
||||
const diamondSize = diamond.offsetWidth;
|
||||
const { cx, cy } = diamondBox;
|
||||
|
||||
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
|
||||
lineGroup.innerHTML = '';
|
||||
|
||||
const topV = diamondVertex(cx, cy, diamondSize, 'top');
|
||||
const bottomV = diamondVertex(cx, cy, diamondSize, 'bottom');
|
||||
|
||||
const msgBox = document.querySelector('.hub-postit-sheet--front');
|
||||
const focusBox = document.querySelector('.hub-schedule-planner');
|
||||
|
||||
const hubBox = hubColumn ? relBox(hubColumn, layout) : null;
|
||||
const hrmBox = document.querySelector('.dept-card--hrm');
|
||||
const exBox = document.querySelector('.dept-card--ex');
|
||||
const hrdBox = document.querySelector('.dept-card--hrd');
|
||||
const gaBox = document.querySelector('.dept-card--ga');
|
||||
const kneeXs = { left: 0, right: 0 };
|
||||
|
||||
if (hubBox && hrmBox && exBox) {
|
||||
const innerLeft = Math.max(relBox(hrmBox, layout).right, relBox(exBox, layout).right);
|
||||
kneeXs.left = (innerLeft + hubBox.left) / 2;
|
||||
}
|
||||
if (hubBox && hrdBox && gaBox) {
|
||||
const innerRight = Math.min(relBox(hrdBox, layout).left, relBox(gaBox, layout).left);
|
||||
kneeXs.right = (innerRight + hubBox.right) / 2;
|
||||
}
|
||||
|
||||
const d = diamondSize / (2 * Math.SQRT2);
|
||||
const approachGap = 32;
|
||||
const leftApproachX = cx - d - approachGap;
|
||||
const rightApproachX = cx + d + approachGap;
|
||||
const leftRunX = Math.max(12, leftApproachX - kneeXs.left);
|
||||
const rightRunX = Math.max(12, kneeXs.right - rightApproachX);
|
||||
|
||||
const appendPath = (points: Point[]) => {
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('d', pointsToPath(points));
|
||||
path.setAttribute('stroke', '#b0bcc8');
|
||||
path.setAttribute('stroke-width', '2.5');
|
||||
path.setAttribute('opacity', '0.85');
|
||||
path.setAttribute('fill', 'none');
|
||||
lineGroup.appendChild(path);
|
||||
};
|
||||
|
||||
FACE_LINKS.forEach((link) => {
|
||||
const cardEl = document.querySelector(link.card);
|
||||
if (!cardEl) return;
|
||||
const edgeMid = diamondEdgeMidpoint(cx, cy, diamondSize, link.edge);
|
||||
const runX = link.knee === 'left' ? leftRunX : rightRunX;
|
||||
const approachX = link.knee === 'left' ? leftApproachX : rightApproachX;
|
||||
const anchorY = anchorYForSymmetricFace(edgeMid.y, runX, link.vert);
|
||||
const cardAnchor = cardEdgeAnchor(cardEl, layout, link.side, anchorY);
|
||||
appendPath(buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX));
|
||||
});
|
||||
|
||||
if (msgBox) {
|
||||
const msg = relBox(msgBox, layout);
|
||||
appendPath([topV, { x: cx, y: msg.bottom }]);
|
||||
}
|
||||
if (focusBox) {
|
||||
const focus = relBox(focusBox, layout);
|
||||
appendPath([bottomV, { x: cx, y: focus.top }]);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDraw = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(drawConnectors, 50);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', scheduleDraw);
|
||||
drawConnectors();
|
||||
|
||||
if (document.fonts?.ready) {
|
||||
document.fonts.ready.then(() => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(drawConnectors));
|
||||
});
|
||||
}
|
||||
|
||||
const layoutEl = document.querySelector('.board-layout');
|
||||
let ro: ResizeObserver | undefined;
|
||||
if (layoutEl && typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(scheduleDraw);
|
||||
ro.observe(layoutEl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', scheduleDraw);
|
||||
clearTimeout(resizeTimer);
|
||||
ro?.disconnect();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { svgRef, lineGroupRef };
|
||||
}
|
||||
@@ -661,7 +661,7 @@ body,
|
||||
left: auto;
|
||||
grid-area: tasks;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-top: 2px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
@@ -672,6 +672,14 @@ body,
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.team-tree-scroll.show-all-tooltips .member-tooltip.is-static {
|
||||
grid-area: tooltip;
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
margin-top: 0;
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
|
||||
.tooltip-header {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
@@ -757,18 +765,33 @@ body,
|
||||
|
||||
.tooltip-project-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 6px;
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.member-tooltip.is-static .tooltip-project-detail {
|
||||
color: #5a7a6a;
|
||||
}
|
||||
|
||||
.member-tooltip:not(.is-static) .tooltip-project-detail {
|
||||
margin-top: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: #ffffff18;
|
||||
font-size: 12px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.member-tooltip.is-static .tooltip-project-detail {
|
||||
background: #d4ede2;
|
||||
color: #064b36;
|
||||
.tooltip-detail-sep {
|
||||
opacity: 0.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tooltip-project-detail strong {
|
||||
@@ -814,16 +837,16 @@ body,
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 업무 전체보기 모드 */
|
||||
/* 업무 전체보기 모드 — 좌: 프로필, 우: 참여 업무 */
|
||||
.team-tree-scroll.show-all-tooltips .tree-member-card,
|
||||
.team-tree-scroll.show-all-tooltips .tree-leader-card {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"avatar tooltip"
|
||||
"info tooltip";
|
||||
grid-template-columns: 96px 1fr;
|
||||
align-items: flex-start;
|
||||
gap: 12px 18px;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
gap: 10px 16px;
|
||||
height: auto;
|
||||
min-height: unset;
|
||||
padding: 16px 20px;
|
||||
@@ -1606,7 +1629,7 @@ body,
|
||||
|
||||
.board-project-list {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
@@ -1776,12 +1799,31 @@ body,
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 기반업무 — 3부문 동일: 약 4줄 높이 고정, 초과 시 스크롤 */
|
||||
.board-routine-section {
|
||||
flex-shrink: 0;
|
||||
--routine-row-h: 40px;
|
||||
--routine-visible-rows: 4;
|
||||
--routine-row-gap: 6px;
|
||||
--routine-pad-y: 28px;
|
||||
flex: 0 0 calc(
|
||||
var(--routine-row-h) * var(--routine-visible-rows) +
|
||||
var(--routine-row-gap) * (var(--routine-visible-rows) - 1) +
|
||||
var(--routine-pad-y) + 2px
|
||||
);
|
||||
height: calc(
|
||||
var(--routine-row-h) * var(--routine-visible-rows) +
|
||||
var(--routine-row-gap) * (var(--routine-visible-rows) - 1) +
|
||||
var(--routine-pad-y) + 2px
|
||||
);
|
||||
max-height: calc(
|
||||
var(--routine-row-h) * var(--routine-visible-rows) +
|
||||
var(--routine-row-gap) * (var(--routine-visible-rows) - 1) +
|
||||
var(--routine-pad-y) + 2px
|
||||
);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
max-height: 42%;
|
||||
overflow: hidden;
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 2px solid #c8dfd5;
|
||||
transition: background 0.2s, filter 0.2s;
|
||||
@@ -1794,11 +1836,12 @@ body,
|
||||
|
||||
.board-routine-list {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: var(--routine-row-gap, 6px);
|
||||
}
|
||||
|
||||
.board-routine-empty {
|
||||
@@ -1808,16 +1851,27 @@ body,
|
||||
.board-routine-item {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #c8dfd5;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
transition: background 0.2s, transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.board-routine-item .board-project-title-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.board-routine-item .board-status-dot {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.board-routine-item .board-project-desc {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.board-routine-item:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: #a8cfc0;
|
||||
@@ -1828,21 +1882,6 @@ body,
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.board-routine-item .board-status-dot {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.board-routine-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #0a2e24;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.board-project-list::-webkit-scrollbar,
|
||||
.board-routine-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
74
frontend/src/lib/boardLayout.ts
Normal file
74
frontend/src/lib/boardLayout.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { SectionKey } from './sections';
|
||||
import { taskBelongsToSection } from './sections';
|
||||
|
||||
/** 4분면 보드 슬롯 (preview 레이아웃) */
|
||||
export type BoardSlotId = 'hrm' | 'hrd' | 'ex' | 'ga';
|
||||
|
||||
export interface BoardSlotConfig {
|
||||
id: BoardSlotId;
|
||||
cssClass: `dept-card--${BoardSlotId}`;
|
||||
/** API 컬럼 키 — null이면 더미 슬롯(로컬 헤더·section 문자열) */
|
||||
sectionKey: SectionKey | null;
|
||||
/** 더미 슬롯·API 미연동 시 task.section 값 */
|
||||
dummySection?: string;
|
||||
defaultTitle: string;
|
||||
defaultTitleEn: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export const BOARD_SLOTS: BoardSlotConfig[] = [
|
||||
{
|
||||
id: 'hrm',
|
||||
cssClass: 'dept-card--hrm',
|
||||
sectionKey: '인사관리',
|
||||
defaultTitle: '인사관리',
|
||||
defaultTitleEn: 'HRM',
|
||||
accent: '#29724f',
|
||||
},
|
||||
{
|
||||
id: 'hrd',
|
||||
cssClass: 'dept-card--hrd',
|
||||
sectionKey: '학습성장',
|
||||
defaultTitle: '인재육성',
|
||||
defaultTitleEn: 'HRD',
|
||||
accent: '#37a184',
|
||||
},
|
||||
{
|
||||
id: 'ex',
|
||||
cssClass: 'dept-card--ex',
|
||||
sectionKey: null,
|
||||
dummySection: '조직문화',
|
||||
defaultTitle: '조직문화',
|
||||
defaultTitleEn: 'EX',
|
||||
accent: '#4a9480',
|
||||
},
|
||||
{
|
||||
id: 'ga',
|
||||
cssClass: 'dept-card--ga',
|
||||
sectionKey: '운영관리',
|
||||
defaultTitle: '총무관리',
|
||||
defaultTitleEn: 'GA',
|
||||
accent: '#0d4a38',
|
||||
},
|
||||
];
|
||||
|
||||
export function slotSectionLabel(slot: BoardSlotConfig): string {
|
||||
return slot.sectionKey ?? slot.dummySection ?? slot.defaultTitle;
|
||||
}
|
||||
|
||||
export function taskBelongsToSlot(
|
||||
taskSection: string | null | undefined,
|
||||
slot: BoardSlotConfig,
|
||||
): boolean {
|
||||
if (!taskSection) return false;
|
||||
const label = slotSectionLabel(slot);
|
||||
if (taskSection.trim() === label) return true;
|
||||
if (slot.sectionKey && taskBelongsToSection(taskSection, slot.sectionKey)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const BOARD_SLOT_ORDER: BoardSlotId[] = ['hrm', 'hrd', 'ex', 'ga'];
|
||||
|
||||
export function getBoardSlot(id: BoardSlotId): BoardSlotConfig {
|
||||
return BOARD_SLOTS.find((s) => s.id === id)!;
|
||||
}
|
||||
90
frontend/src/lib/hubConfig.ts
Normal file
90
frontend/src/lib/hubConfig.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { migrateScheduleItem, quarterDateBounds } from './hubSchedule';
|
||||
|
||||
const STORAGE_KEY = 'eene-quarter-hub-config-v1';
|
||||
|
||||
export interface HubScheduleItem {
|
||||
id: string;
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface HubConfig {
|
||||
sloganTitle: string;
|
||||
sloganLines: string[];
|
||||
scheduleTitle: string;
|
||||
scheduleItems: HubScheduleItem[];
|
||||
routineLabels: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_HUB_CONFIG: HubConfig = {
|
||||
sloganTitle: '분기 슬로건',
|
||||
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
|
||||
scheduleTitle: '분기 주요 일정',
|
||||
scheduleItems: [
|
||||
{ id: '1', date: '2026-04-01', text: '상반기 채용·온보딩' },
|
||||
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
|
||||
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
|
||||
],
|
||||
routineLabels: ['채용', '교육', '소통', '시설', '자산', '행정'],
|
||||
};
|
||||
|
||||
function migrateConfig(raw: Record<string, unknown>): HubConfig {
|
||||
const { year } = quarterDateBounds('2026-Q2');
|
||||
const scheduleItems = Array.isArray(raw.scheduleItems)
|
||||
? (raw.scheduleItems as (HubScheduleItem & { month?: string })[]).map((item) =>
|
||||
migrateScheduleItem(item, year),
|
||||
)
|
||||
: DEFAULT_HUB_CONFIG.scheduleItems;
|
||||
|
||||
return {
|
||||
sloganTitle: (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle,
|
||||
sloganLines: (raw.sloganLines as string[]) ?? DEFAULT_HUB_CONFIG.sloganLines,
|
||||
scheduleTitle: (raw.scheduleTitle as string) ?? DEFAULT_HUB_CONFIG.scheduleTitle,
|
||||
scheduleItems,
|
||||
routineLabels: (raw.routineLabels as string[]) ?? DEFAULT_HUB_CONFIG.routineLabels,
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfig(): HubConfig {
|
||||
if (typeof window === 'undefined') return DEFAULT_HUB_CONFIG;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_HUB_CONFIG;
|
||||
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...JSON.parse(raw) });
|
||||
} catch {
|
||||
return DEFAULT_HUB_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig(config: HubConfig) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
}
|
||||
|
||||
export function useHubConfig() {
|
||||
const [config, setConfigState] = useState<HubConfig>(loadConfig);
|
||||
|
||||
const setConfig = useCallback((patch: Partial<HubConfig> | ((prev: HubConfig) => HubConfig)) => {
|
||||
setConfigState((prev) => {
|
||||
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch };
|
||||
saveConfig(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetConfig = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setConfigState(DEFAULT_HUB_CONFIG);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY) setConfigState(loadConfig());
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
return { config, setConfig, resetConfig };
|
||||
}
|
||||
130
frontend/src/lib/hubSchedule.ts
Normal file
130
frontend/src/lib/hubSchedule.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { HubScheduleItem } from './hubConfig';
|
||||
|
||||
const KOR_MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
|
||||
export function parseScheduleDate(dateStr: string): Date | null {
|
||||
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return null;
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const dt = new Date(y, m - 1, d);
|
||||
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
|
||||
return dt;
|
||||
}
|
||||
|
||||
export function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
/** "6월 8일" */
|
||||
export function formatScheduleDateLabel(dateStr: string): string {
|
||||
const dt = parseScheduleDate(dateStr);
|
||||
if (!dt) return dateStr;
|
||||
return `${dt.getMonth() + 1}월 ${dt.getDate()}일`;
|
||||
}
|
||||
|
||||
export function sortScheduleItems(items: HubScheduleItem[]): HubScheduleItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const da = parseScheduleDate(a.date)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||
const db = parseScheduleDate(b.date)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||
return da - db;
|
||||
});
|
||||
}
|
||||
|
||||
/** 3건 창에서 오늘(포커스) 일정이 가운데 오도록 시작 인덱스 */
|
||||
export function scheduleWindowStart(total: number, focusIndex: number, visible = 3): number {
|
||||
if (total <= visible) return 0;
|
||||
const middle = Math.floor(visible / 2);
|
||||
let start = focusIndex - middle;
|
||||
start = Math.max(0, start);
|
||||
start = Math.min(start, total - visible);
|
||||
return start;
|
||||
}
|
||||
|
||||
/** 오늘(또는 가장 가까운 일정) 기준 시작 인덱스 — 과거는 왼쪽, 미래는 오른쪽 */
|
||||
export function findScheduleIndexForToday(items: HubScheduleItem[], today = new Date()): number {
|
||||
const sorted = sortScheduleItems(items.filter((i) => i.date && i.text.trim()));
|
||||
if (sorted.length === 0) return 0;
|
||||
|
||||
const todayMs = startOfDay(today).getTime();
|
||||
|
||||
const exact = sorted.findIndex((item) => {
|
||||
const d = parseScheduleDate(item.date);
|
||||
return d && startOfDay(d).getTime() === todayMs;
|
||||
});
|
||||
if (exact >= 0) return exact;
|
||||
|
||||
const next = sorted.findIndex((item) => {
|
||||
const d = parseScheduleDate(item.date);
|
||||
return d && startOfDay(d).getTime() > todayMs;
|
||||
});
|
||||
if (next >= 0) return next;
|
||||
|
||||
return sorted.length - 1;
|
||||
}
|
||||
|
||||
export function isSchedulePast(dateStr: string, today = new Date()): boolean {
|
||||
const d = parseScheduleDate(dateStr);
|
||||
if (!d) return false;
|
||||
return startOfDay(d).getTime() < startOfDay(today).getTime();
|
||||
}
|
||||
|
||||
export function isScheduleToday(dateStr: string, today = new Date()): boolean {
|
||||
const d = parseScheduleDate(dateStr);
|
||||
if (!d) return false;
|
||||
return startOfDay(d).getTime() === startOfDay(today).getTime();
|
||||
}
|
||||
|
||||
/** 레거시 month 필드 → date (2026-Q2 가정) */
|
||||
export function migrateScheduleItem(
|
||||
item: HubScheduleItem & { month?: string },
|
||||
defaultYear = 2026,
|
||||
): HubScheduleItem {
|
||||
if (item.date && parseScheduleDate(item.date)) {
|
||||
return { id: item.id, date: item.date, text: item.text ?? '' };
|
||||
}
|
||||
const legacy = item.month?.trim() ?? '';
|
||||
const monthMatch = legacy.match(/(\d{1,2})\s*월/);
|
||||
if (monthMatch) {
|
||||
const m = Number(monthMatch[1]);
|
||||
if (m >= 1 && m <= 12) {
|
||||
return {
|
||||
id: item.id,
|
||||
date: `${defaultYear}-${String(m).padStart(2, '0')}-01`,
|
||||
text: item.text ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < KOR_MONTHS.length; i++) {
|
||||
if (legacy.includes(KOR_MONTHS[i])) {
|
||||
return {
|
||||
id: item.id,
|
||||
date: `${defaultYear}-${String(i + 1).padStart(2, '0')}-01`,
|
||||
text: item.text ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return { id: item.id, date: item.date ?? '', text: item.text ?? '' };
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
const t = new Date();
|
||||
return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 분기 문자열(2026-Q2) → date input min/max */
|
||||
export function quarterDateBounds(quarter = '2026-Q2'): { min: string; max: string; year: number } {
|
||||
const m = quarter.match(/^(\d{4})-Q([1-4])$/);
|
||||
if (!m) {
|
||||
const y = new Date().getFullYear();
|
||||
return { min: `${y}-01-01`, max: `${y}-12-31`, year: y };
|
||||
}
|
||||
const year = Number(m[1]);
|
||||
const q = Number(m[2]);
|
||||
const startMonth = (q - 1) * 3 + 1;
|
||||
const endMonth = startMonth + 2;
|
||||
const lastDay = new Date(year, endMonth, 0).getDate();
|
||||
return {
|
||||
min: `${year}-${String(startMonth).padStart(2, '0')}-01`,
|
||||
max: `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`,
|
||||
year,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export function taskFormToApiPayload(data: TaskFormData): Record<string, unknown
|
||||
showStatus: data.showStatus,
|
||||
showIssue: data.showIssue,
|
||||
showProgress: data.showProgress,
|
||||
keywords: data.keywords || null,
|
||||
quarter: data.quarter,
|
||||
pmMemberId: data.pmMemberId || null,
|
||||
assigneeMemberIds: data.assigneeMemberIds,
|
||||
|
||||
66
frontend/src/lib/taskStatusVisual.ts
Normal file
66
frontend/src/lib/taskStatusVisual.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Task, TaskStatus } from '../types';
|
||||
|
||||
/** DashboardHeader STAT_ACCENT 와 동일 */
|
||||
export const TASK_STAT_COLORS = {
|
||||
IN_PROGRESS: '#10b981',
|
||||
REVIEW: '#ff9f0a',
|
||||
TODO: '#ff9f0a',
|
||||
CANCELLED: '#ff9f0a',
|
||||
DONE: '#b0b0b0',
|
||||
} as const satisfies Record<TaskStatus, string>;
|
||||
|
||||
export interface DonutDisplay {
|
||||
pct: number;
|
||||
ringColor: string;
|
||||
label: string;
|
||||
labelColor: string;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function getDonutDisplay(task: Pick<Task, 'status' | 'progress'>): DonutDisplay {
|
||||
const p = Math.min(100, Math.max(0, task.progress));
|
||||
|
||||
switch (task.status) {
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
pct: p,
|
||||
ringColor: TASK_STAT_COLORS.IN_PROGRESS,
|
||||
label: `${p}%`,
|
||||
labelColor: TASK_STAT_COLORS.IN_PROGRESS,
|
||||
ariaLabel: `진행 ${p}%`,
|
||||
};
|
||||
case 'TODO':
|
||||
return {
|
||||
pct: 0,
|
||||
ringColor: TASK_STAT_COLORS.TODO,
|
||||
label: '대기',
|
||||
labelColor: TASK_STAT_COLORS.TODO,
|
||||
ariaLabel: '대기',
|
||||
};
|
||||
case 'REVIEW':
|
||||
case 'CANCELLED':
|
||||
return {
|
||||
pct: p,
|
||||
ringColor: TASK_STAT_COLORS.REVIEW,
|
||||
label: '보류',
|
||||
labelColor: TASK_STAT_COLORS.REVIEW,
|
||||
ariaLabel: p > 0 ? `보류 ${p}%` : '보류',
|
||||
};
|
||||
case 'DONE':
|
||||
return {
|
||||
pct: 100,
|
||||
ringColor: TASK_STAT_COLORS.DONE,
|
||||
label: '완료',
|
||||
labelColor: TASK_STAT_COLORS.DONE,
|
||||
ariaLabel: '완료',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
pct: p,
|
||||
ringColor: TASK_STAT_COLORS.IN_PROGRESS,
|
||||
label: `${p}%`,
|
||||
labelColor: TASK_STAT_COLORS.IN_PROGRESS,
|
||||
ariaLabel: `진행률 ${p}%`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,17 @@ export function normalizeTaskType(taskType: string | null | undefined): string {
|
||||
return taskType;
|
||||
}
|
||||
|
||||
/** 업무 수정·추가 폼 — 표시 순서·라벨 */
|
||||
export const TASK_TYPE_OPTIONS = [
|
||||
{ value: '실행과제', label: '프로젝트' },
|
||||
{ value: '기반업무', label: '상시업무' },
|
||||
] as const;
|
||||
|
||||
export function taskTypeDisplayLabel(taskType: string | null | undefined): string {
|
||||
if (isRoutineTask(taskType)) return '상시업무';
|
||||
return '프로젝트';
|
||||
}
|
||||
|
||||
/** 실행과제 카드에 표시할 필드 플래그 (기반업무는 모두 숨김) */
|
||||
export function displayFlagsForTaskType(taskType: string | null | undefined) {
|
||||
const visible = isProjectTask(taskType);
|
||||
|
||||
@@ -87,18 +87,46 @@ export function groupTeamMembers(members: TeamMember[]) {
|
||||
return { groups, cellKeys };
|
||||
}
|
||||
|
||||
export function isTaskPm(memberId: string, task: Task): boolean {
|
||||
return task.pmMember?.id === memberId || task.pmMemberId === memberId;
|
||||
}
|
||||
|
||||
export function isTaskAssignee(memberId: string, task: Task): boolean {
|
||||
return task.assigneeMembers?.some((a) => a.id === memberId) ?? false;
|
||||
}
|
||||
|
||||
/** 해당 팀원이 이 업무에서 맡은 역할 */
|
||||
export function getMemberTaskRole(
|
||||
memberId: string,
|
||||
task: Task,
|
||||
): 'pm' | 'assignee' | 'both' | null {
|
||||
const pm = isTaskPm(memberId, task);
|
||||
const assignee = isTaskAssignee(memberId, task);
|
||||
if (pm && assignee) return 'both';
|
||||
if (pm) return 'pm';
|
||||
if (assignee) return 'assignee';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMemberTaskRoleLabel(role: ReturnType<typeof getMemberTaskRole>): string | null {
|
||||
if (role === 'both') return 'PM · 담당';
|
||||
if (role === 'pm') return 'PM';
|
||||
if (role === 'assignee') return '담당';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMemberTasks(memberId: string, tasks: Task[]): Task[] {
|
||||
return tasks.filter((t) => {
|
||||
if (t.status === 'DONE' || t.status === 'CANCELLED') return false;
|
||||
if (t.pmMember?.id === memberId) return true;
|
||||
return t.assigneeMembers?.some((a) => a.id === memberId) ?? false;
|
||||
return isTaskPm(memberId, t) || isTaskAssignee(memberId, t);
|
||||
});
|
||||
}
|
||||
|
||||
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
|
||||
if (!task) return [];
|
||||
const ids: string[] = [];
|
||||
if (task.pmMember?.id) ids.push(task.pmMember.id);
|
||||
const pmId = task.pmMember?.id ?? task.pmMemberId;
|
||||
if (pmId) ids.push(pmId);
|
||||
task.assigneeMembers?.forEach((m) => {
|
||||
if (m.id && !ids.includes(m.id)) ids.push(m.id);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import './styles/quarter-board.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -17,11 +17,14 @@ 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 { 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 { TaskDetailShell } from '../pages/DetailPage';
|
||||
import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType';
|
||||
import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType';
|
||||
import {
|
||||
DEFAULT_STATUS_FILTERS,
|
||||
taskMatchesStatusFilters,
|
||||
@@ -31,12 +34,17 @@ import {
|
||||
} from '../lib/statusFilters';
|
||||
import {
|
||||
SECTIONS,
|
||||
COLUMN_META,
|
||||
LEGACY_COLUMN_KEYS,
|
||||
taskBelongsToSection,
|
||||
normalizeSection,
|
||||
type SectionKey,
|
||||
} from '../lib/sections';
|
||||
import {
|
||||
BOARD_SLOTS,
|
||||
BOARD_SLOT_ORDER,
|
||||
getBoardSlot,
|
||||
slotSectionLabel,
|
||||
taskBelongsToSlot,
|
||||
} from '../lib/boardLayout';
|
||||
|
||||
const QUARTER = '2026-Q2';
|
||||
|
||||
@@ -58,6 +66,13 @@ function mergeCardOrders(primary: string | null | undefined, extras: (string | n
|
||||
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]);
|
||||
@@ -122,9 +137,17 @@ export default function DashboardPage() {
|
||||
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 */ }
|
||||
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;
|
||||
@@ -132,16 +155,17 @@ export default function DashboardPage() {
|
||||
}, [colConfigs]);
|
||||
|
||||
useEffect(() => {
|
||||
SECTIONS.forEach((section) => {
|
||||
const ids = tasks.filter((t) => taskBelongsToSection(t.section, section)).map((t) => t.id);
|
||||
BOARD_SLOTS.forEach((slot) => {
|
||||
const label = slotSectionLabel(slot);
|
||||
const ids = tasks.filter((t) => taskBelongsToSlot(t.section, slot)).map((t) => t.id);
|
||||
setColumnOrders((prev) => {
|
||||
const existing = prev[section] ?? [];
|
||||
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, [section]: merged };
|
||||
return { ...prev, [label]: merged };
|
||||
});
|
||||
});
|
||||
}, [tasks]);
|
||||
@@ -182,58 +206,74 @@ export default function DashboardPage() {
|
||||
const draggedTask = tasks.find((t) => t.id === activeId);
|
||||
if (!draggedTask) return;
|
||||
|
||||
const srcSection = normalizeSection(draggedTask.section) ?? '인사관리';
|
||||
const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(draggedTask.section, s));
|
||||
const srcLabel = srcSlot ? slotSectionLabel(srcSlot) : (draggedTask.section ?? '인사관리');
|
||||
|
||||
if (overId.startsWith('drop::')) {
|
||||
const parts = overId.split('::');
|
||||
const areaType = parts[1];
|
||||
const targetSection = parts[2] as SectionKey;
|
||||
const targetSection = parts[2];
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
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 (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 (Object.keys(updateData).length > 0) {
|
||||
patchTask.mutate({ id: activeId, data: updateData });
|
||||
|
||||
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;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (!overTask) return;
|
||||
|
||||
const dstSection = normalizeSection(overTask.section) ?? '인사관리';
|
||||
const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(overTask.section, s));
|
||||
const dstLabel = dstSlot ? slotSectionLabel(dstSlot) : (overTask.section ?? '인사관리');
|
||||
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
|
||||
|
||||
if (dstSection !== srcSection || typeChanged) {
|
||||
if (dstLabel !== srcLabel || typeChanged) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (dstSection !== srcSection) updateData.section = dstSection;
|
||||
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 (dstSection !== srcSection) return;
|
||||
if (dstLabel !== srcLabel) return;
|
||||
}
|
||||
|
||||
const current = columnOrders[srcSection]
|
||||
?? tasks.filter((t) => taskBelongsToSection(t.section, srcSection)).map((t) => t.id);
|
||||
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, [srcSection]: newOrder }));
|
||||
setColumnOrders((prev) => ({ ...prev, [srcLabel]: newOrder }));
|
||||
|
||||
if (saveTimers.current[srcSection]) clearTimeout(saveTimers.current[srcSection]);
|
||||
saveTimers.current[srcSection] = setTimeout(() => {
|
||||
patchColumn.mutate({ section: srcSection, data: { cardOrder: JSON.stringify(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);
|
||||
};
|
||||
|
||||
@@ -250,6 +290,11 @@ export default function DashboardPage() {
|
||||
return taskMatchesStatusFilters(t, activeFilters);
|
||||
});
|
||||
|
||||
const routineTasks = useMemo(
|
||||
() => filtered.filter((t) => isRoutineTask(t.taskType)),
|
||||
[filtered],
|
||||
);
|
||||
|
||||
const handleToggleAll = () => {
|
||||
setIssueFilterActive(false);
|
||||
setActiveFilters((prev) => toggleAllFilter(prev));
|
||||
@@ -270,20 +315,21 @@ export default function DashboardPage() {
|
||||
setIssueFilterActive(true);
|
||||
};
|
||||
|
||||
const sectionOptions = SECTIONS.map((s) => ({
|
||||
value: s,
|
||||
label: colConfigs?.find((c) => c.key === s)?.title ?? COLUMN_META[s].displayTitle,
|
||||
const sectionOptions = BOARD_SLOTS.map((slot) => ({
|
||||
value: slotSectionLabel(slot),
|
||||
label: colConfigs?.find((c) => c.key === slot.sectionKey)?.title
|
||||
?? slot.defaultTitle,
|
||||
}));
|
||||
|
||||
const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null;
|
||||
|
||||
const ultraWideLayout = viewportWidth >= 3800;
|
||||
/** 팝업이 열려 있으면 우측 도킹 숨김 — 팝업 차단 시 같은 페이지 도킹 fallback */
|
||||
const showDockedDetail = !!selectedTaskId && !detailPopupOpen;
|
||||
const detailDocked = showDockedDetail && ultraWideLayout;
|
||||
|
||||
const handleSelectTask = (taskId: string) => {
|
||||
setSelectedTaskId(taskId);
|
||||
if (teamPanelOpen) setActiveTeamProjectId(taskId);
|
||||
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then(() => {
|
||||
setDetailPopupOpen(isDetailWindowOpen());
|
||||
});
|
||||
@@ -295,9 +341,26 @@ export default function DashboardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
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) => taskBelongsToSlot(t.section, 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-[#eef4f1]">
|
||||
<div className="flex h-screen items-center justify-center bg-[#e9eef2]">
|
||||
<div className="text-lg text-gray-400">데이터를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
@@ -307,7 +370,7 @@ export default function DashboardPage() {
|
||||
<div
|
||||
className={`app-shell ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
|
||||
>
|
||||
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#eef4f1]">
|
||||
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#e9eef2]">
|
||||
<DashboardHeader
|
||||
quarter={QUARTER}
|
||||
stats={stats}
|
||||
@@ -324,6 +387,8 @@ export default function DashboardPage() {
|
||||
if (open) {
|
||||
setActiveTeamProjectId(null);
|
||||
setShowAllTeamTasks(false);
|
||||
} else if (selectedTaskId) {
|
||||
setActiveTeamProjectId(selectedTaskId);
|
||||
}
|
||||
return !open;
|
||||
});
|
||||
@@ -347,51 +412,48 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
<main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="board-grid">
|
||||
{SECTIONS.map((section) => {
|
||||
const meta = COLUMN_META[section];
|
||||
return (
|
||||
<DepartmentColumn
|
||||
key={section}
|
||||
title={meta.displayTitle}
|
||||
titleEn={meta.titleEn}
|
||||
accent={meta.accent}
|
||||
tasks={filtered.filter((t) => taskBelongsToSection(t.section, section))}
|
||||
orderedIds={columnOrders[section] ?? []}
|
||||
section={section}
|
||||
quarter={QUARTER}
|
||||
onSelectTask={(t) => handleSelectTask(t.id)}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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}
|
||||
onSelectRoutine={(t) => handleSelectTask(t.id)}
|
||||
/>
|
||||
{renderDeptSlot('ex')}
|
||||
{renderDeptSlot('ga')}
|
||||
<BoardConnectors enabled={!teamPanelOpen} />
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
||||
{activeTask ? (
|
||||
<div className="board-project-card board-project-card--overlay">
|
||||
<div className="board-project-top">
|
||||
<div className="board-project-main">
|
||||
<div className="board-project-title-row">
|
||||
<span className="board-status-dot board-status-dot--ongoing" aria-hidden />
|
||||
<span className="board-project-title">{activeTask.title}</span>
|
||||
<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>
|
||||
{activeTask.showProgress !== false && (
|
||||
<span className="board-gauge-value">{activeTask.progress}%</span>
|
||||
)}
|
||||
</article>
|
||||
) : activeTask ? (
|
||||
<div className="board-routine-item board-project-card--overlay">
|
||||
<span className="board-project-title">{activeTask.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showTaskManager && (
|
||||
|
||||
1278
frontend/src/styles/quarter-board.css
Normal file
1278
frontend/src/styles/quarter-board.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,6 @@ export interface Task {
|
||||
showStatus: boolean;
|
||||
showIssue: boolean;
|
||||
showProgress: boolean;
|
||||
keywords: string | null;
|
||||
creatorId: string;
|
||||
assigneeId: string | null;
|
||||
pmMemberId?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user