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:
EENE Dashboard
2026-06-08 22:09:46 +09:00
parent 525a4fc1f2
commit cf72281c6d
28 changed files with 4743 additions and 314 deletions

View File

@@ -41,7 +41,6 @@ export interface MappedTask {
issueNote: string | null;
startDate: Date | null;
dueDate: Date | null;
keywords: string | null;
showDate: boolean;
showDescription: boolean;
showStatus: boolean;
@@ -194,7 +193,6 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
issueNote: pickIssueNote(p),
startDate: parseDate(p.startDate),
dueDate: parseDate(p.endDate),
keywords: p.keywords?.length ? p.keywords.join(', ') : null,
showDate: visible,
showDescription: visible,
showStatus: visible,

View File

@@ -0,0 +1,3 @@
-- 키워드 필드 제거 (데이터 삭제 후 컬럼 drop)
UPDATE "tasks" SET "keywords" = NULL WHERE "keywords" IS NOT NULL;
ALTER TABLE "tasks" DROP COLUMN IF EXISTS "keywords";

View File

@@ -80,7 +80,6 @@ model Task {
showStatus Boolean @default(true)
showIssue Boolean @default(true)
showProgress Boolean @default(true)
keywords String?
creatorId String
assigneeId String?
pmMemberId String?

View File

@@ -39,7 +39,6 @@ type RemoteTask = {
showStatus: boolean;
showIssue: boolean;
showProgress: boolean;
keywords?: string | null;
creatorId: string;
assigneeId?: string | null;
pmMemberId?: string | null;
@@ -314,7 +313,6 @@ async function main() {
showStatus: remote.showStatus,
showIssue: remote.showIssue,
showProgress: remote.showProgress,
keywords: remote.keywords ?? null,
creatorId,
assigneeId,
pmMemberId,

View File

@@ -189,7 +189,6 @@ async function syncTasks(memberIdMap: Map<string, string>) {
showStatus: task.showStatus,
showIssue: task.showIssue,
showProgress: task.showProgress,
keywords: task.keywords,
pmMemberId,
assigneeMemberIds,
});

View File

@@ -56,7 +56,7 @@ router.post('/', async (req, res, next) => {
const body = req.body as Record<string, any>;
const { title, description, status, priority, quarter, category,
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body;
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
if (!title || !quarter) {
throw new AppError(400, '제목과 분기는 필수입니다.');
@@ -85,7 +85,6 @@ router.post('/', async (req, res, next) => {
showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === true : true,
showIssue: showIssue !== undefined ? showIssue === 'true' || showIssue === true : true,
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
keywords: keywords || null,
assigneeId: assigneeId || null,
pmMemberId: pmMemberId || null,
creatorId,
@@ -118,7 +117,7 @@ router.patch('/:id', async (req, res, next) => {
const body = req.body as Record<string, any>;
const { title, description, status, priority, quarter, category,
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body;
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
const assigneeMemberIds = parseMemberIds(body);
@@ -145,7 +144,6 @@ router.patch('/:id', async (req, res, next) => {
...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }),
...(showIssue !== undefined && { showIssue: showIssue === true || showIssue === 'true' }),
...(showProgress !== undefined && { showProgress: showProgress === true || showProgress === 'true' }),
...(keywords !== undefined && { keywords: keywords || null }),
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -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">

View 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>
);
}

View File

@@ -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)}
/>
)}

View 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>
);
}

View 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)}
/>
)}
</>
);
}

View 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>
);
}

View File

@@ -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>
)}

View File

@@ -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>
);
}

View 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 };
}

View File

@@ -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;

View 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)!;
}

View 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 };
}

View 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,
};
}

View File

@@ -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,

View 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}%`,
};
}
}

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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: {

View File

@@ -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 && (

File diff suppressed because it is too large Load Diff

View File

@@ -49,7 +49,6 @@ export interface Task {
showStatus: boolean;
showIssue: boolean;
showProgress: boolean;
keywords: string | null;
creatorId: string;
assigneeId: string | null;
pmMemberId?: string | null;