feat: 3-section dashboard, reference dual-monitor layout, and detail dock

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-08 11:13:40 +09:00
parent 5f16515dab
commit 525a4fc1f2
13 changed files with 1205 additions and 386 deletions

View File

@@ -15,7 +15,8 @@
"db:import-hr": "tsx scripts/import-hr-data.ts",
"db:sync-remote": "tsx scripts/sync-from-remote.ts",
"db:push-remote": "tsx scripts/sync-to-remote.ts",
"db:push-photos": "tsx scripts/sync-to-remote.ts --photos-only"
"db:push-photos": "tsx scripts/sync-to-remote.ts --photos-only",
"db:migrate-sections": "tsx scripts/migrate-sections.ts"
},
"dependencies": {
"@prisma/client": "^6.0.0",

View File

@@ -59,8 +59,7 @@ export interface MappedTask {
const SECTION_MAP: Record<string, string> = {
: '인사관리',
: '학습성장',
: '운영지원',
: '전산관리',
: '운영관리',
};
const STATUS_MAP: Record<string, TaskStatus> = {

View File

@@ -0,0 +1,66 @@
/**
* 전산관리·운영지원 → 운영관리 부문 통합 (1회 실행)
* 사용: npm run db:migrate-sections
*/
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const MERGE_INTO = '운영관리';
const FROM = ['전산관리', '운영지원'] as const;
async function mergeCardOrder(intoKey: string, fromKeys: readonly string[]) {
const target = await prisma.columnConfig.findUnique({ where: { key: intoKey } });
const orders: string[] = [];
if (target?.cardOrder) {
try {
orders.push(...JSON.parse(target.cardOrder));
} catch {
/* ignore */
}
}
for (const from of fromKeys) {
const src = await prisma.columnConfig.findUnique({ where: { key: from } });
if (!src?.cardOrder) continue;
try {
const ids = JSON.parse(src.cardOrder) as string[];
for (const id of ids) {
if (!orders.includes(id)) orders.push(id);
}
} catch {
/* ignore */
}
}
if (orders.length === 0 && !target) return;
await prisma.columnConfig.upsert({
where: { key: intoKey },
update: { cardOrder: JSON.stringify(orders) },
create: {
key: intoKey,
title: '운영관리',
titleEn: 'GA',
subtitle: '',
cardOrder: JSON.stringify(orders),
},
});
}
async function main() {
for (const from of FROM) {
const { count } = await prisma.task.updateMany({
where: { section: from },
data: { section: MERGE_INTO },
});
if (count > 0) console.log(`${from}${MERGE_INTO}: ${count} tasks`);
}
await mergeCardOrder(MERGE_INTO, FROM);
console.log('✅ Section migration done.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -9,7 +9,14 @@ import { PrismaClient, type Priority, type TaskStatus } from '@prisma/client';
const prisma = new PrismaClient();
const SOURCE = (process.env.SOURCE_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'];
const SECTIONS = ['인사관리', '학습성장', '운영관리'];
function normalizeSection(section: string | null | undefined): string | null {
if (!section) return null;
if (section === '전산관리' || section === '운영지원') return '운영관리';
if (section === '성장지원') return '학습성장';
return section;
}
type RemoteUser = { id: string; name: string; department?: string | null };
type RemoteTask = {
@@ -295,7 +302,7 @@ async function main() {
priority: remote.priority,
quarter: remote.quarter,
category: remote.category ?? null,
section: remote.section ?? null,
section: normalizeSection(remote.section),
tag: remote.tag ?? null,
taskType: remote.taskType ?? null,
progress: remote.progress ?? 0,

View File

@@ -10,7 +10,7 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'];
const SECTIONS = ['인사관리', '학습성장', '운영관리'];
const PHOTOS_ONLY = process.argv.includes('--photos-only');
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads'));
const TEAM_DIR = path.join(UPLOAD_DIR, 'team');

View File

@@ -5,6 +5,7 @@ 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 { SortableTaskCard } from './TaskCard';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
@@ -15,22 +16,16 @@ import type { Task, TeamMember } from '../../types';
interface DepartmentColumnProps {
title: string;
titleEn?: string;
subtitle?: string;
accent?: string;
tasks: Task[];
orderedIds: string[]; // DashboardPage에서 관리
headerBg: string;
headerStyle?: React.CSSProperties;
storageKey: string;
section: string;
orderedIds: string[];
section: SectionKey;
quarter: string;
noHeader?: boolean;
headerAlign?: 'left' | 'right';
onSelectTask?: (task: Task) => void;
sectionOptions?: { value: string; label: string }[];
teamMembers?: TeamMember[];
}
// ── 헤더 편집 팝업 ──────────────────────────────────────────
interface HeaderModalProps {
title: string;
titleEn: string;
@@ -73,7 +68,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
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"
placeholder="Human Resources"
placeholder="HRM"
/>
</div>
<div>
@@ -82,7 +77,6 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
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"
placeholder="부제목 입력"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
@@ -92,14 +86,25 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
</form>
</div>
</div>,
document.body
document.body,
);
}
export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, orderedIds, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions, teamMembers = [] }: DepartmentColumnProps) {
export function DepartmentColumn({
title: initialTitle,
titleEn,
accent,
tasks,
orderedIds,
section,
quarter,
onSelectTask,
sectionOptions: externalSectionOptions,
teamMembers = [],
}: DepartmentColumnProps) {
const queryClient = useQueryClient();
const meta = COLUMN_META[section];
// ── 컬럼 설정 API ─────────────────────────────────────────
const { data: colConfig } = useQuery({
queryKey: ['columns', section],
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(section)}`).then((r) => r.data),
@@ -113,8 +118,9 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
});
const title = colConfig?.title ?? initialTitle;
const titleEnState = colConfig?.titleEn ?? (titleEn ?? '');
const subtitle = colConfig?.subtitle ?? initialSubtitle;
const titleEnState = colConfig?.titleEn ?? (titleEn ?? meta.titleEn);
const subtitle = colConfig?.subtitle ?? '';
const accentColor = accent ?? meta.accent;
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);
@@ -123,11 +129,9 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const [showHeaderModal, setShowHeaderModal] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
// ── useDroppable: 컬럼 드롭존 등록 ──────────────────────
const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` });
const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({ id: `drop::routine::${section}` });
// ── 순서 적용 ─────────────────────────────────────────────
const orderedTasks = [...tasks].sort((a, b) => {
const ai = orderedIds.indexOf(a.id);
const bi = orderedIds.indexOf(b.id);
@@ -137,9 +141,10 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
return ai - bi;
});
const saveTitle = (v: string) => patchColumn.mutate({ title: v });
const saveTitleEn = (v: string) => patchColumn.mutate({ titleEn: v });
const saveSubtitle = (v: string) => patchColumn.mutate({ subtitle: v });
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),
@@ -157,7 +162,6 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
// capture 단계에서 카드 우클릭 처리 — dnd-kit보다 먼저 실행됨
const handleColumnContextMenuCapture = (e: React.MouseEvent) => {
const card = (e.target as HTMLElement).closest('[data-task-id]');
if (!card) return;
@@ -181,12 +185,11 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const sectionOptions = externalSectionOptions ?? [{ value: section, label: title }];
const displayTitle = title.replace(/\s*부문$/, '');
const handleAdd = async (data: TaskFormData) => {
try {
await create.mutateAsync({
...taskFormToApiPayload(data),
section,
priority: 'MEDIUM',
} as Partial<Task>);
setShowAddModal(false);
@@ -197,10 +200,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const handleEdit = (data: TaskFormData) => {
if (!editingTask) return;
patch.mutate({
id: editingTask.id,
data: taskFormToApiPayload(data),
});
patch.mutate({ id: editingTask.id, data: taskFormToApiPayload(data) });
setShowEditModal(false);
setEditingTask(null);
};
@@ -214,79 +214,84 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
return (
<>
<div
className="flex min-h-0 flex-col overflow-hidden rounded-[1.6rem] border border-white/80 bg-white/70 shadow-[0_18px_45px_rgba(15,23,42,0.10)] ring-1 ring-slate-200/60 backdrop-blur"
className="board-dept-column"
onContextMenuCapture={handleColumnContextMenuCapture}
>
{/* 컬럼 헤더 (noHeader 시 숨김) */}
{!noHeader && (
<div
className="relative flex h-10 shrink-0 select-none items-center justify-center gap-2 px-4 shadow-sm"
style={headerStyle}
className="board-dept-header"
style={{ borderBottomColor: accentColor }}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }}
>
<span className="truncate text-base font-black tracking-tight text-white drop-shadow-sm">{displayTitle}</span>
<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="text-white/60 text-xs font-medium truncate hidden xl:block">{titleEnState}</span>
)}
<span className="absolute right-3 shrink-0 rounded-full bg-white/25 px-2 py-0.5 text-xs font-black text-white ring-1 ring-white/20">
{tasks.length}
<span className="board-dept-title-en" style={{ color: accentColor }}>
{titleEnState}
</span>
</div>
)}
</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>
</div>
{/* 실행과제 카드 목록 (스크롤 영역) */}
{(() => {
const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType));
return (
<div
ref={setProjectDropRef}
className={`min-h-0 flex-1 overflow-y-auto bg-gradient-to-b from-slate-50/80 to-white/70 p-4 transition-colors ${isProjectOver ? 'bg-blue-50/60' : ''}`}
className={`board-project-list ${isProjectOver ? 'is-over' : ''}`}
onContextMenu={handleListContextMenu}
>
{projectTasks.length === 0 ? (
<div className="flex h-40 items-center justify-center text-2xl text-slate-300">
</div>
<div className="board-empty"> </div>
) : (
<SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{projectTasks.map((task) => (
<SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />
<SortableTaskCard
key={task.id}
task={task}
variant="project"
sectionOptions={sectionOptions}
onSelect={onSelectTask}
/>
))}
</SortableContext>
)}
</div>
);
})()}
{/* 기반업무 고정 영역 */}
{(() => {
const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType));
return (
<div
ref={setRoutineDropRef}
className={`shrink-0 border-t border-slate-200/80 bg-white/75 transition-colors ${isRoutineOver ? 'bg-amber-50/60' : ''}`}
style={{ height: 300 }}
className={`board-routine-section ${isRoutineOver ? 'is-over' : ''}`}
style={{
borderTopColor: `${accentColor}80`,
background: COLUMN_META[section].routineBg,
}}
onContextMenu={handleListContextMenu}
>
<div className="h-full overflow-y-auto p-4">
<div className="board-routine-list">
{routineTasks.length === 0 ? (
<div className="flex h-full items-center justify-center text-base text-slate-300">
</div>
<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} sectionOptions={sectionOptions} onSelect={onSelectTask} />
<SortableTaskCard
key={task.id}
task={task}
variant="routine"
sectionOptions={sectionOptions}
onSelect={onSelectTask}
/>
))}
</SortableContext>
)}
</div>
</div>
);
})()}
</div>
{/* 카드 우클릭 메뉴 (추가/수정/삭제) */}
{cardMenu && (
<ContextMenu
x={cardMenu.x}
@@ -300,7 +305,6 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
/>
)}
{/* 빈 영역/헤더 우클릭 메뉴 */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
@@ -314,7 +318,6 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
/>
)}
{/* 추가 모달 */}
{showAddModal && (
<TaskModal
mode="add"
@@ -338,13 +341,17 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
/>
)}
{/* 헤더 편집 모달 */}
{showHeaderModal && (
<HeaderModal
title={title}
titleEn={titleEnState}
subtitle={subtitle}
onSave={(t, te, s) => { saveTitle(t); saveTitleEn(te); saveSubtitle(s); setShowHeaderModal(false); }}
onSave={(t, te, s) => {
patchColumnField('title', t);
patchColumnField('titleEn', te);
patchColumnField('subtitle', s);
setShowHeaderModal(false);
}}
onClose={() => setShowHeaderModal(false)}
/>
)}

View File

@@ -1,23 +1,16 @@
import { useRef } from 'react';
import { useSortable } from '@dnd-kit/sortable';
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_STYLE: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500 text-white shadow-blue-500/20',
REVIEW: 'bg-amber-400 text-white shadow-amber-400/20',
TODO: 'bg-slate-200 text-slate-600 shadow-slate-300/20',
DONE: 'bg-emerald-500 text-white shadow-emerald-500/20',
CANCELLED: 'bg-slate-200 text-slate-400 shadow-slate-300/20',
};
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
const STATUS_DOT: Record<string, string> = {
IN_PROGRESS: 'ongoing',
REVIEW: 'hold',
TODO: 'hold',
CANCELLED: 'hold',
DONE: 'done',
};
function fmtDate(iso: string | null | undefined): string {
@@ -26,21 +19,99 @@ function fmtDate(iso: string | null | undefined): string {
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
}
function fmtDateRange(task: Task): string {
if (!task.showDate || (!task.startDate && !task.dueDate)) return '';
const start = task.startDate ? fmtDate(task.startDate) : '?';
const end = task.dueDate ? fmtDate(task.dueDate) : '?';
return `${start} ~ ${end}`;
}
function firstDescriptionLine(text: string | null | undefined): string {
if (!text) return '';
const line = text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).find(Boolean);
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({
task,
variant = 'project',
onSelect,
}: {
task: Task;
variant?: 'project' | 'routine';
sectionOptions?: SectionOption[];
onSelect?: (task: Task) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const pointerStart = useRef<{ x: number; y: number } | null>(null);
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
pointerStart.current = { x: e.clientX, y: e.clientY };
listeners?.onPointerDown?.(e);
};
const handlePointerUp = (e: React.PointerEvent) => {
if (e.button !== 0 || !pointerStart.current) return;
const dx = e.clientX - pointerStart.current.x;
const dy = e.clientY - pointerStart.current.y;
pointerStart.current = null;
if (!isDragging && Math.hypot(dx, dy) < 8) {
onSelect?.(task);
}
};
return (
<TaskCard
task={task}
variant={variant}
dragRef={setNodeRef}
dragStyle={{
transform: CSS.Transform.toString(transform),
@@ -49,26 +120,44 @@ export function SortableTaskCard({
}}
dragAttributes={attributes}
dragListeners={listeners}
onCardClick={() => { if (!isDragging) onSelect?.(task); }}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
/>
);
}
export function TaskCard({
task,
variant = 'project',
dragRef,
dragStyle,
dragAttributes,
dragListeners,
onCardClick,
onPointerDown,
onPointerUp,
}: {
task: Task;
variant?: 'project' | 'routine';
dragRef?: (node: HTMLElement | null) => void;
dragStyle?: React.CSSProperties;
dragAttributes?: DraggableAttributes;
dragListeners?: SyntheticListenerMap;
onCardClick?: () => void;
onPointerDown?: (e: React.PointerEvent) => void;
onPointerUp?: (e: React.PointerEvent) => void;
}) {
const dragHandlers = {
onPointerDown: (e: React.PointerEvent) => {
onPointerDown?.(e);
},
onPointerUp: (e: React.PointerEvent) => {
onPointerUp?.(e);
},
onKeyDown: dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined,
};
const dotClass = statusDotClass(task.status);
if (variant === 'routine') {
return (
<div
ref={dragRef}
@@ -76,60 +165,46 @@ export function TaskCard({
{...dragAttributes}
data-task-card="true"
data-task-id={task.id}
className="mb-3 cursor-grab select-none overflow-hidden rounded-[1.35rem] border border-white/80 bg-white px-5 py-4 shadow-[0_10px_28px_rgba(15,23,42,0.08)] ring-1 ring-slate-200/60 transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(15,23,42,0.14)] active:cursor-grabbing"
onPointerDown={(e) => {
if (e.button !== 0) return;
dragListeners?.onPointerDown?.(e);
}}
onKeyDown={dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined}
onClick={onCardClick}
className="board-routine-item"
{...dragHandlers}
>
<div className="flex items-start justify-between gap-3">
<span className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-900">
{task.title}
</span>
{task.showProgress !== false && (
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${
task.progress >= 70 ? 'text-emerald-500' :
task.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
}`}>
{task.progress}%
</span>
)}
<span className={`board-status-dot board-status-dot--${dotClass}`} aria-hidden />
<span className="board-routine-name">{task.title}</span>
</div>
);
}
const dateRange = fmtDateRange(task);
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
const showProgress = task.showProgress !== false;
return (
<div
ref={dragRef}
style={dragStyle}
{...dragAttributes}
data-task-card="true"
data-task-id={task.id}
className="board-project-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>
{showProgress && <SemiCircleGauge value={task.progress} />}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="flex-1 truncate text-sm font-semibold text-slate-400">
{task.showDate && (task.startDate || task.dueDate)
? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}`
: ''}
</span>
{task.showStatus && (
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-sm font-black shadow-sm ${STATUS_STYLE[task.status]}`}>
{STATUS_LABEL[task.status]}
</span>
)}
</div>
{task.keywords && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{task.keywords.split(',').map((kw, i) => (
<span key={i} className="rounded-md border border-slate-200/60 bg-slate-100 px-2 py-0.5 text-sm font-bold text-slate-600">
{kw.trim()}
</span>
))}
</div>
)}
{task.showDescription && task.description && (
<div className="mt-2 truncate text-2xl text-slate-700">{task.description}</div>
{descLine && (
<p className="board-project-desc"> {descLine}</p>
)}
{task.showIssue && task.issueNote && (
<div className="mt-1.5 flex min-w-0 gap-2 rounded-xl bg-red-50/80 px-2 py-1 text-2xl text-red-500">
<span className="shrink-0"></span>
<span className="truncate">{task.issueNote}</span>
</div>
<p className="board-project-issue"> {task.issueNote}</p>
)}
</div>
);

View File

@@ -19,7 +19,7 @@ const STATUS_STYLE: Record<string, string> = {
CANCELLED: 'bg-gray-100 text-gray-400',
};
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
import { SECTIONS, formatSectionDisplay } from '../../lib/sections';
interface TaskManagerProps {
tasks: Task[];
@@ -153,7 +153,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [],
<tr><td colSpan={9} className="text-center py-16 text-gray-300 text-lg"> </td></tr>
) : filtered.map((task) => (
<tr key={task.id} className="hover:bg-blue-50/40 transition-colors group">
<td className="px-4 py-3 text-gray-600 font-medium whitespace-nowrap">{task.section ?? '-'}</td>
<td className="px-4 py-3 text-gray-600 font-medium whitespace-nowrap">{formatSectionDisplay(task.section)}</td>
<td className="px-4 py-3">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
isRoutineTask(task.taskType) ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'

View File

@@ -1422,3 +1422,440 @@ body,
border: 0;
}
/* ─── 앱 셸: 보드 + 우측 상세 도킹 (dashboard-260504 참고) ─── */
.app-shell {
display: flex;
flex-direction: row;
align-items: stretch;
width: 100vw;
height: 100dvh;
overflow: hidden;
}
.app-main {
display: flex;
flex-direction: column;
flex: auto;
width: 100%;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
.app-shell:not(.detail-docked) .app-main {
flex: 0 0 100vw;
width: 100vw;
min-width: 100vw;
}
.app-shell.detail-docked .app-main {
flex: auto;
width: auto;
min-width: 0;
}
.app-shell.dual-mode-parent .app-right {
display: none !important;
}
.app-shell.dual-mode-parent .app-main {
flex: 0 0 100vw;
width: 100vw;
min-width: 100vw;
}
.app-right {
position: relative;
flex: auto;
min-width: 0;
height: 100dvh;
overflow: hidden;
background: linear-gradient(135deg, #fbfaf8, #eef5f1);
border-left: 1px solid #e5e5e5;
}
.app-right:not(.detail-open) {
display: none;
}
.app-right.detail-open {
z-index: 1000;
display: block;
flex: 0 0 clamp(400px, 35vw, 600px);
width: clamp(400px, 35vw, 600px);
box-shadow: -10px 0 30px #0000000d;
}
.detail-popup-view {
width: 100vw;
height: 100dvh;
overflow: hidden;
}
.detail-popup-view .app-right {
display: block;
flex: auto;
width: 100vw;
min-width: 100vw;
border-left: none;
}
/* ─── 대시보드 3부문 보드 — 상단 헤더 그린 톤 통일 ─── */
.board-main {
position: relative;
display: flex;
min-height: 0;
flex: 1;
overflow: hidden;
padding: 0;
}
.board-grid {
display: grid;
height: 100%;
min-height: 0;
flex: 1;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
overflow: hidden;
border-top: 1px solid #135643;
}
.board-dept-column {
display: flex;
min-height: 0;
flex-direction: column;
overflow: hidden;
border-radius: 0;
border: none;
border-right: 1px solid #c8dfd5;
background: #f8fcfa;
box-shadow: none;
}
.board-dept-column:last-child {
border-right: none;
}
.board-dept-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 14px 20px 10px;
border-bottom: 3px solid #29724f;
background: linear-gradient(180deg, #f8fcfa 0%, #f0f7f4 100%);
}
.board-dept-header-main {
min-width: 0;
flex: 1;
}
.board-dept-title-wrap {
display: flex;
align-items: baseline;
gap: 8px;
}
.board-dept-title {
margin: 0;
font-size: 34px;
font-weight: 800;
letter-spacing: -0.6px;
line-height: 1.1;
}
.board-dept-title-en {
font-size: 20px;
font-weight: 700;
opacity: 0.85;
}
.board-dept-subtitle {
margin: 4px 0 0;
font-size: 14px;
font-weight: 500;
color: #5a6b62;
}
/* 상단 현황판(poly-stat)처럼 — 숫자 크게, 건은 오른쪽 */
.board-dept-count-badge {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: flex-end;
flex-shrink: 0;
line-height: 1;
text-align: right;
}
.board-dept-count-val {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
}
.board-dept-count-unit {
margin-left: 2px;
font-size: 13px;
font-weight: 600;
opacity: 0.55;
}
.board-project-list {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 8px 16px;
display: flex;
flex-direction: column;
gap: 4px;
background: #f8fcfa;
transition: background 0.2s;
}
.board-project-list.is-over {
background: #eef6f2;
}
.board-empty {
display: flex;
height: 120px;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
color: #8a9a92;
}
/* 상태 점 — 헤더 통계 색과 동일 계열 */
.board-status-dot {
flex-shrink: 0;
width: 10px;
height: 10px;
margin-top: 9px;
border-radius: 50%;
}
.board-status-dot--ongoing {
background-color: #37a184;
}
.board-status-dot--hold {
background-color: #ff8b13;
}
.board-status-dot--done {
background-color: #73726f;
}
.board-project-card {
cursor: grab;
user-select: none;
position: relative;
padding: 10px 4px 12px;
border-radius: 0;
border: none;
border-bottom: 1px solid #dce8e3;
background: transparent;
transition: background 0.2s;
}
.board-project-card:last-child {
border-bottom: none;
}
.board-project-card:hover {
background: linear-gradient(180deg, #f2f9f6 0%, #eaf3ef 100%);
}
.board-project-card--overlay {
opacity: 0.92;
background: #f8fcfa;
border: 1px solid #a8cfc0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(7, 65, 46, 0.12);
padding: 12px 16px;
}
.board-project-card:active {
cursor: grabbing;
}
.board-project-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.board-project-main {
min-width: 0;
flex: 1;
}
.board-project-title-row {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 8px;
}
.board-project-title {
color: #0a2e24;
font-size: 24px;
font-weight: 600;
line-height: 1.35;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.board-project-date {
margin: 5px 0 0 18px;
color: #5a6b62;
font-size: 20px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.board-project-desc {
margin: 4px 0 0 18px;
color: #3d5248;
font-size: 20px;
font-weight: 500;
line-height: 1.45;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.board-project-issue {
margin: 4px 0 0 18px;
color: #c0392b;
font-size: 20px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 반원 진행률 */
.board-gauge {
position: relative;
flex-shrink: 0;
width: 88px;
height: 56px;
}
.board-gauge-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.board-gauge-value {
position: absolute;
left: 50%;
top: var(--gauge-cy, 82%);
transform: translate(-50%, -50%);
color: #29724f;
font-size: 18px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
pointer-events: none;
}
.board-routine-section {
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 200px;
max-height: 42%;
padding: 12px 16px 16px;
border-top: 2px solid #c8dfd5;
transition: background 0.2s, filter 0.2s;
}
.board-routine-section.is-over {
filter: brightness(0.98);
background-color: #e4f2ec !important;
}
.board-routine-list {
min-height: 0;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.board-routine-empty {
height: 24px;
}
.board-routine-item {
cursor: grab;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 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:hover {
background: rgba(255, 255, 255, 0.92);
border-color: #a8cfc0;
transform: translateX(2px);
}
.board-routine-item:active {
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;
}
.board-project-list::-webkit-scrollbar-thumb,
.board-routine-list::-webkit-scrollbar-thumb {
background: #c8dfd5;
border-radius: 3px;
}
.board-project-list::-webkit-scrollbar-thumb:hover,
.board-routine-list::-webkit-scrollbar-thumb:hover {
background: #a8cfc0;
}

View File

@@ -1,19 +1,23 @@
/**
* 듀얼 모니터 연동 유틸리티
* BroadcastChannel API를 사용해 두 브라우저 창 간 실시간 통신
* 듀얼 모니터 연동 — dashboard-260504.vercel.app 와 동일한 창 배치 로직
* @see https://dashboard-260504.vercel.app/
*/
const CHANNEL_NAME = 'eee_dashboard';
const DETAIL_WINDOW_NAME = 'eene_detail';
const SELECTED_TASK_KEY = 'eee_selected_task';
type DualMonitorEvent =
export type DualMonitorEvent =
| { type: 'TASK_SELECTED'; taskId: string }
| { type: 'TASK_DESELECTED' }
| { type: 'REQUEST_SYNC' }
| { type: 'REFRESH' };
let channel: BroadcastChannel | null = null;
let detailWindow: Window | null = null;
let dualModeActive = false;
let closePollTimer: ReturnType<typeof setInterval> | null = null;
let syncProvider: (() => string | null) | null = null;
interface ScreenDetailed {
left: number;
@@ -35,20 +39,22 @@ interface WindowWithScreenDetails extends Window {
getScreenDetails?: () => Promise<ScreenDetails>;
}
/** 클릭 직후 동기적으로 쓸 기본 창 위치 (await 없음) */
function buildSyncWindowFeatures(): string {
const left = window.screenX + window.outerWidth;
const top = window.screenY;
let width = window.screen.availWidth - left;
if (width < 800) {
width = 1280;
interface WindowPlacement {
left: number;
top: number;
width: number;
height: number;
}
const height = window.screen.availHeight;
function placementToFeatures({ left, top, width, height }: WindowPlacement): string {
return `left=${left},top=${top},width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`;
}
/** 우측 모니터 좌표·크기 계산 (열린 뒤 위치 조정용) */
export async function getRightMonitorWindowFeatures(): Promise<string> {
/**
* 참고 사이트(fr)와 동일한 좌표 계산
* — getScreenDetails await 후 window.open (권한 요청 + 우측 모니터 배치)
*/
async function resolveDetailWindowPlacement(): Promise<WindowPlacement> {
let left = window.screenX + window.outerWidth;
let top = window.screenY;
let width = window.screen.availWidth;
@@ -81,7 +87,12 @@ export async function getRightMonitorWindowFeatures(): Promise<string> {
console.warn('Window Management API failed or denied, using fallback', err);
}
return `left=${left},top=${top},width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`;
return { left, top, width, height };
}
/** 우측 모니터 좌표·크기 (열린 뒤 moveTo 보정용) */
export async function getRightMonitorWindowFeatures(): Promise<string> {
return placementToFeatures(await resolveDetailWindowPlacement());
}
function getChannel(): BroadcastChannel {
@@ -91,30 +102,50 @@ function getChannel(): BroadcastChannel {
return channel;
}
/** 상세 창이 열려 있는지 확인 */
function stopClosePoll() {
if (closePollTimer) {
clearInterval(closePollTimer);
closePollTimer = null;
}
}
function startClosePoll(onClose: () => void) {
stopClosePoll();
closePollTimer = setInterval(() => {
if (detailWindow && detailWindow.closed) {
detailWindow = null;
dualModeActive = false;
stopClosePoll();
onClose();
}
}, 500);
}
export function isDetailWindowOpen(): boolean {
return !!detailWindow && !detailWindow.closed;
}
export function isDualModeActive(): boolean {
return dualModeActive && isDetailWindowOpen();
}
function persistSelectedTask(taskId: string | null) {
if (taskId) sessionStorage.setItem(SELECTED_TASK_KEY, taskId);
else sessionStorage.removeItem(SELECTED_TASK_KEY);
}
/** 상세 페이지 초기 로드용 (BroadcastChannel 유실 대비) */
export function getPersistedTaskId(): string | null {
return sessionStorage.getItem(SELECTED_TASK_KEY);
}
function parseWindowFeatures(features: string) {
const out: Record<string, number> = {};
for (const part of features.split(',')) {
const [key, value] = part.split('=');
if (key && value != null) out[key.trim()] = Number(value);
}
return out;
export function registerSyncProvider(fn: () => string | null): () => void {
syncProvider = fn;
return () => {
if (syncProvider === fn) syncProvider = null;
};
}
function applyWindowPlacement(win: Window, left: number, top: number, width: number, height: number) {
try {
win.moveTo(left, top);
@@ -134,10 +165,9 @@ function scheduleTaskSelected(taskId: string) {
setTimeout(() => postTaskSelected(taskId), 1500);
}
/** 상세 창 열기 — 반드시 사용자 클릭 직후 동기 호출 */
function openDetailWindowSync(): Window | null {
const detailUrl = `${window.location.origin}/detail`;
const features = buildSyncWindowFeatures();
function openDetailWindowWithPlacement(placement: WindowPlacement): Window | null {
const detailUrl = `${window.location.origin}/detail?view=detail`;
const features = placementToFeatures(placement);
detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, features);
if (!detailWindow) {
@@ -151,78 +181,121 @@ function openDetailWindowSync(): Window | null {
// ignore
}
// 창을 연 뒤 비동기로 위치만 보정 (팝업 차단과 무관)
void getRightMonitorWindowFeatures().then((f) => {
const { left, top, width, height } = parseWindowFeatures(f);
if (detailWindow && !detailWindow.closed && left != null && top != null && width && height) {
applyWindowPlacement(detailWindow, left, top, width, height);
}
});
applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height);
return detailWindow;
}
/** 상세 창 토글 — 열려 있으면 닫고, 닫혀 있으면 오른쪽 모니터에 열기 */
export function openDetailWindow(): Window | null {
if (isDetailWindowOpen()) {
detailWindow!.close();
detailWindow = null;
return null;
}
/** 참고 사이트: getScreenDetails(await) → window.open */
async function openDetailWindowPlaced(onPopupClosed?: () => void): Promise<Window | null> {
const placement = await resolveDetailWindowPlacement();
const win = openDetailWindowWithPlacement(placement);
if (!win) return null;
const win = openDetailWindowSync();
const savedTaskId = getPersistedTaskId();
if (win && savedTaskId) {
scheduleTaskSelected(savedTaskId);
}
startClosePoll(() => onPopupClosed?.());
return win;
}
/** 좌측 → 우측: 업무 선택 이벤트 전송 (창이 닫혀 있으면 열고 전송) */
export function sendTaskSelected(taskId: string): void {
/** 듀얼뷰 토글 */
export async function openDetailWindow(onPopupClosed?: () => void): Promise<Window | null> {
if (isDetailWindowOpen()) {
detailWindow!.close();
detailWindow = null;
dualModeActive = false;
stopClosePoll();
onPopupClosed?.();
return null;
}
dualModeActive = true;
const win = await openDetailWindowPlaced(onPopupClosed);
if (!win) {
dualModeActive = false;
return null;
}
const savedTaskId = getPersistedTaskId();
if (savedTaskId) scheduleTaskSelected(savedTaskId);
return win;
}
/** 업무 선택 — 참고 사이트와 같이 배치 계산(await) 후 팝업 열기 */
export async function sendTaskSelected(taskId: string, onPopupClosed?: () => void): Promise<void> {
persistSelectedTask(taskId);
if (!isDetailWindowOpen()) {
const win = openDetailWindowSync();
if (!win) return;
dualModeActive = true;
const win = await openDetailWindowPlaced(onPopupClosed);
if (!win) {
dualModeActive = false;
return;
}
scheduleTaskSelected(taskId);
return;
}
scheduleTaskSelected(taskId);
try {
detailWindow!.focus();
} catch {
// ignore
}
scheduleTaskSelected(taskId);
void resolveDetailWindowPlacement().then((placement) => {
if (detailWindow && !detailWindow.closed) {
applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height);
}
});
}
/** 좌측 → 우측: 업무 선택 해제 */
export function sendTaskDeselected(): void {
persistSelectedTask(null);
getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent);
}
/** 이벤트 수신 리스너 등록 */
export function requestDetailSync(): void {
getChannel().postMessage({ type: 'REQUEST_SYNC' } satisfies DualMonitorEvent);
}
function respondToSyncRequest() {
const id = syncProvider?.() ?? getPersistedTaskId();
if (id) postTaskSelected(id);
else getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent);
}
export function onDualMonitorEvent(
handler: (event: DualMonitorEvent) => void,
options?: { isPopupView?: boolean },
): () => void {
const ch = getChannel();
const listener = (e: MessageEvent<DualMonitorEvent>) => handler(e.data);
const isPopupView = options?.isPopupView ?? false;
const listener = (e: MessageEvent<DualMonitorEvent>) => {
const evt = e.data;
if (evt.type === 'REQUEST_SYNC') {
if (!isPopupView) respondToSyncRequest();
return;
}
handler(evt);
};
ch.addEventListener('message', listener);
if (isPopupView) {
requestDetailSync();
}
return () => ch.removeEventListener('message', listener);
}
/** 채널 종료 */
export function closeChannel(): void {
stopClosePoll();
channel?.close();
channel = null;
}
/** 웹 링크를 우측 모니터 새 창에서 열기 */
export function openLinkOnRightMonitor(url: string, windowName: string): Window | null {
const features = buildSyncWindowFeatures();
const win = window.open(url, windowName, features);
const win = window.open(url, windowName, 'noopener,noreferrer,width=1280,height=900');
try {
win?.focus();
} catch {
@@ -230,10 +303,9 @@ export function openLinkOnRightMonitor(url: string, windowName: string): Window
}
if (win) {
void getRightMonitorWindowFeatures().then((f) => {
const { left, top, width, height } = parseWindowFeatures(f);
if (win && !win.closed && left != null && top != null && width && height) {
applyWindowPlacement(win, left, top, width, height);
void resolveDetailWindowPlacement().then((placement) => {
if (win && !win.closed) {
applyWindowPlacement(win, placement.left, placement.top, placement.width, placement.height);
}
});
}

View File

@@ -0,0 +1,68 @@
/** 대시보드 부문 (3열) */
export const SECTIONS = ['인사관리', '학습성장', '운영관리'] as const;
export type SectionKey = (typeof SECTIONS)[number];
/** DB·레거시 section 값 → 표준 부문 */
export const SECTION_ALIASES: Record<SectionKey, readonly string[]> = {
: ['인사관리'],
: ['학습성장', '성장지원'],
: ['운영관리', '운영지원', '전산관리'],
};
/** 컬럼 설정·cardOrder 조회 시 함께 병합할 레거시 키 */
export const LEGACY_COLUMN_KEYS: Record<SectionKey, readonly string[]> = {
: [],
: [],
: ['운영지원', '전산관리'],
};
export function normalizeSection(section: string | null | undefined): SectionKey | null {
if (!section) return null;
for (const key of SECTIONS) {
if (SECTION_ALIASES[key].includes(section)) return key;
}
return null;
}
export function taskBelongsToSection(
taskSection: string | null | undefined,
columnSection: SectionKey,
): boolean {
return normalizeSection(taskSection) === columnSection;
}
/** 화면·듀얼모니터 상세에 표시할 부문명 */
export function formatSectionDisplay(section: string | null | undefined): string {
const key = normalizeSection(section);
if (key) return key;
return section?.trim() || '—';
}
export function canonicalSection(section: string | null | undefined): SectionKey {
return normalizeSection(section) ?? '인사관리';
}
export const COLUMN_META: Record<
SectionKey,
{ titleEn: string; accent: string; displayTitle: string; routineBg: string }
> = {
: {
titleEn: 'HRM',
accent: '#07412e',
displayTitle: '인사관리',
routineBg: 'linear-gradient(180deg, #dce8e3 0%, #e8f0ec 100%)',
},
: {
titleEn: 'HRD',
accent: '#29724f',
displayTitle: '성장지원',
routineBg: 'linear-gradient(180deg, #d8ebe3 0%, #e6f2ec 100%)',
},
: {
titleEn: 'GA',
accent: '#36816d',
displayTitle: '운영관리',
routineBg: 'linear-gradient(180deg, #d4ece4 0%, #e4f3ed 100%)',
},
};

View File

@@ -19,7 +19,8 @@ import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel';
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
import { TaskManager } from '../components/dashboard/TaskManager';
import { useSocket } from '../contexts/SocketContext';
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen } from '../lib/dualMonitor';
import { TaskDetailShell } from '../pages/DetailPage';
import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType';
import {
DEFAULT_STATUS_FILTERS,
@@ -28,16 +29,34 @@ import {
toggleCoreFilter,
type CoreStatusFilter,
} from '../lib/statusFilters';
import {
SECTIONS,
COLUMN_META,
LEGACY_COLUMN_KEYS,
taskBelongsToSection,
normalizeSection,
type SectionKey,
} from '../lib/sections';
const QUARTER = '2026-Q2';
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
const COLUMN_STYLES = [
{ section: '인사관리', titleEn: 'HR Management', headerStyle: { background: 'linear-gradient(120deg,#2a4a8a 0%,#3461b8 50%,#3d72d0 100%)' } },
{ section: '학습성장', titleEn: 'Learning & Growth', headerStyle: { background: 'linear-gradient(120deg,#5b2d8a 0%,#7340b8 50%,#8a52d0 100%)' } },
{ section: '운영지원', titleEn: 'Operations', headerStyle: { background: 'linear-gradient(120deg,#0d6080 0%,#0d7a9a 50%,#0e92b8 100%)' } },
{ section: '전산관리', titleEn: 'IT Management', headerStyle: { background: 'linear-gradient(120deg,#0a6040 0%,#0d8050 50%,#10a060 100%)' } },
] as const;
function mergeCardOrders(primary: string | null | undefined, extras: (string | null | undefined)[]): string[] {
const merged: string[] = [];
const push = (raw: string | null | undefined) => {
if (!raw) return;
try {
const ids = JSON.parse(raw) as string[];
for (const id of ids) {
if (!merged.includes(id)) merged.push(id);
}
} catch {
/* ignore */
}
};
push(primary);
extras.forEach(push);
return merged;
}
export default function DashboardPage() {
const [activeFilters, setActiveFilters] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
@@ -48,6 +67,11 @@ export default function DashboardPage() {
const [showAllTeamTasks, setShowAllTeamTasks] = useState(false);
const [activeTeamProjectId, setActiveTeamProjectId] = useState<string | null>(null);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
const [viewportWidth, setViewportWidth] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth : 1920,
);
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
@@ -58,19 +82,42 @@ export default function DashboardPage() {
const { data: teamMembers = [] } = useTeamMembers();
const { data: colConfigs } = useQuery({
queryKey: ['columns', 'all'],
queryKey: ['columns', 'all', ...SECTIONS],
queryFn: async () => {
const results = await Promise.all(
SECTIONS.map((s) =>
apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => ({ key: s, ...r.data })),
SECTIONS.map(async (s) => {
const main = await apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => r.data);
const legacy = await Promise.all(
LEGACY_COLUMN_KEYS[s].map((key) =>
apiClient.get(`/columns/${encodeURIComponent(key)}`).then((r) => r.data).catch(() => null),
),
);
const cardOrder = JSON.stringify(
mergeCardOrders(main.cardOrder, legacy.map((l) => l?.cardOrder)),
);
return { key: s, ...main, cardOrder };
}),
);
return results;
},
staleTime: 0,
});
// colConfig의 cardOrder로 초기 순서 설정
useEffect(() => {
const onResize = () => setViewportWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => registerSyncProvider(() => selectedTaskId), [selectedTaskId]);
useEffect(() => {
const timer = setInterval(() => {
setDetailPopupOpen(isDetailWindowOpen());
}, 300);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (!colConfigs) return;
setColumnOrders((prev) => {
@@ -84,10 +131,9 @@ export default function DashboardPage() {
});
}, [colConfigs]);
// 새 태스크 추가 시 순서 목록에 병합
useEffect(() => {
SECTIONS.forEach((section) => {
const ids = tasks.filter((t) => t.section === section).map((t) => t.id);
const ids = tasks.filter((t) => taskBelongsToSection(t.section, section)).map((t) => t.id);
setColumnOrders((prev) => {
const existing = prev[section] ?? [];
const merged = [
@@ -106,7 +152,7 @@ export default function DashboardPage() {
});
const patchTask = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, any> }) =>
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
apiClient.patch(`/tasks/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
@@ -136,16 +182,14 @@ export default function DashboardPage() {
const draggedTask = tasks.find((t) => t.id === activeId);
if (!draggedTask) return;
const srcSection = draggedTask.section ?? '';
const srcSection = normalizeSection(draggedTask.section) ?? '인사관리';
// ── 드롭 대상이 컬럼 드롭존인 경우 ──────────────────────────
if (overId.startsWith('drop::')) {
// 형식: "drop::project::섹션명" 또는 "drop::routine::섹션명"
const parts = overId.split('::');
const areaType = parts[1]; // 'project' | 'routine'
const targetSection = parts[2];
const areaType = parts[1];
const targetSection = parts[2] as SectionKey;
const updateData: Record<string, any> = {};
const updateData: Record<string, unknown> = {};
if (targetSection !== srcSection) updateData.section = targetSection;
if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) {
updateData.taskType = '기반업무';
@@ -160,15 +204,14 @@ export default function DashboardPage() {
return;
}
// ── 드롭 대상이 다른 카드인 경우 ─────────────────────────────
const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return;
const dstSection = overTask.section ?? '';
const dstSection = normalizeSection(overTask.section) ?? '인사관리';
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
if (dstSection !== srcSection || typeChanged) {
const updateData: Record<string, any> = {};
const updateData: Record<string, unknown> = {};
if (dstSection !== srcSection) updateData.section = dstSection;
if (typeChanged) {
const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제';
@@ -179,8 +222,8 @@ export default function DashboardPage() {
if (dstSection !== srcSection) return;
}
// ── 같은 컬럼 내 순서 변경 ───────────────────────────────────
const current = columnOrders[srcSection] ?? tasks.filter((t) => t.section === srcSection).map((t) => t.id);
const current = columnOrders[srcSection]
?? tasks.filter((t) => taskBelongsToSection(t.section, srcSection)).map((t) => t.id);
const oldIdx = current.indexOf(activeId);
const newIdx = current.indexOf(overId);
if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return;
@@ -229,21 +272,42 @@ export default function DashboardPage() {
const sectionOptions = SECTIONS.map((s) => ({
value: s,
label: colConfigs?.find((c) => c.key === s)?.title ?? s,
label: colConfigs?.find((c) => c.key === s)?.title ?? COLUMN_META[s].displayTitle,
}));
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);
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then(() => {
setDetailPopupOpen(isDetailWindowOpen());
});
};
const handleOpenDetailWindow = () => {
void openDetailWindow(() => setDetailPopupOpen(false)).then(() => {
setDetailPopupOpen(isDetailWindowOpen());
});
};
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-slate-100">
<div className="text-3xl text-gray-400"> ...</div>
<div className="flex h-screen items-center justify-center bg-[#eef4f1]">
<div className="text-lg text-gray-400"> ...</div>
</div>
);
}
return (
<div className="relative flex h-screen flex-col overflow-hidden bg-[#eef2f5]" style={{ fontSize: '18px' }}>
<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]">
<DashboardHeader
quarter={QUARTER}
stats={stats}
@@ -252,7 +316,7 @@ export default function DashboardPage() {
onToggleAll={handleToggleAll}
onToggleStatus={handleToggleStatus}
onToggleIssue={handleToggleIssue}
onOpenDetailWindow={() => { openDetailWindow(); }}
onOpenDetailWindow={handleOpenDetailWindow}
onOpenTaskManager={() => setShowTaskManager(true)}
teamPanelOpen={teamPanelOpen}
onToggleTeamPanel={() => {
@@ -282,52 +346,47 @@ export default function DashboardPage() {
/>
)}
<main className={`relative flex min-h-0 flex-1 overflow-hidden px-5 py-5 ${teamPanelOpen ? 'hidden' : ''}`}>
{/* ── 좌측 라벨 컬럼 ── */}
<div className="mr-4 flex w-16 shrink-0 flex-col overflow-hidden rounded-[2rem] bg-white shadow-[0_16px_40px_rgba(15,23,42,0.12)] ring-1 ring-slate-200/70">
<div className="h-10 shrink-0 border-b border-slate-100 bg-slate-50" />
<div className="flex flex-1 items-center justify-center border-b border-slate-100 bg-gradient-to-b from-slate-50 to-white">
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}></span>
</div>
<div className="flex shrink-0 items-center justify-center bg-gradient-to-b from-white to-slate-50" style={{ height: 300 }}>
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}></span>
</div>
</div>
<main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid h-full min-h-0 flex-1 grid-cols-4 gap-4 overflow-hidden">
{COLUMN_STYLES.map(({ section, titleEn, headerStyle }) => (
<div className="board-grid">
{SECTIONS.map((section) => {
const meta = COLUMN_META[section];
return (
<DepartmentColumn
key={section}
title={section}
titleEn={titleEn}
tasks={filtered.filter((t) => t.section === section)}
title={meta.displayTitle}
titleEn={meta.titleEn}
accent={meta.accent}
tasks={filtered.filter((t) => taskBelongsToSection(t.section, section))}
orderedIds={columnOrders[section] ?? []}
headerBg=""
headerStyle={headerStyle}
storageKey={`col_${section}`}
section={section}
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
onSelectTask={(t) => handleSelectTask(t.id)}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
/>
))}
);
})}
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
{activeTask ? (
<div className="rotate-1 rounded-[1.35rem] border border-white/80 bg-white px-5 py-4 opacity-90 shadow-[0_28px_56px_rgba(15,23,42,0.22)] ring-1 ring-slate-200/60">
<div className="flex items-start justify-between gap-3">
<span className="min-w-0 truncate text-2xl font-black leading-snug text-slate-900">{activeTask.title}</span>
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${
activeTask.progress >= 70 ? 'text-emerald-500' : activeTask.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
}`}>{activeTask.progress}%</span>
<div 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>
</div>
</div>
{activeTask.showProgress !== false && (
<span className="board-gauge-value">{activeTask.progress}%</span>
)}
</div>
</div>
) : null}
@@ -345,5 +404,12 @@ export default function DashboardPage() {
/>
)}
</div>
{!detailPopupOpen && (
<aside className={`app-right ${showDockedDetail ? 'detail-open' : ''}`}>
<TaskDetailShell taskId={selectedTaskId} />
</aside>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
} from '../components/detail/StageModal';
import { sortFilesByOrder } from '../lib/fileDisplay';
import { useAuth } from '../contexts/AuthContext';
import { formatSectionDisplay } from '../lib/sections';
import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
const STATUS_CONFIG: Record<string, { label: string }> = {
@@ -204,7 +205,7 @@ function DetailHeader({ task }: { task: Task }) {
<span className="h-4 w-px bg-white/25" />
<span className="whitespace-nowrap">
<span className="font-semibold text-white/55"></span>{' '}
<span className="font-bold text-white/90">{task.section ?? '—'}</span>
<span className="font-bold text-white/90">{formatSectionDisplay(task.section)}</span>
</span>
<Badge className="border border-white/20 bg-white/10 text-white/90">{status.label}</Badge>
</div>
@@ -704,24 +705,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
);
}
export default function DetailPage() {
const { taskId: routeTaskId } = useParams<{ taskId?: string }>();
const [taskId, setTaskId] = useState<string | null>(
() => routeTaskId ?? getPersistedTaskId(),
);
useEffect(() => {
if (routeTaskId) setTaskId(routeTaskId);
}, [routeTaskId]);
useEffect(() => {
const unsub = onDualMonitorEvent((evt) => {
if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId);
if (evt.type === 'TASK_DESELECTED') setTaskId(null);
});
return unsub;
}, []);
export function TaskDetailShell({ taskId }: { taskId: string | null }) {
const { data: task, isLoading, isError, error } = useQuery({
queryKey: ['task', taskId],
queryFn: async () => {
@@ -734,7 +718,7 @@ export default function DetailPage() {
});
return (
<div className="flex h-screen flex-col overflow-hidden bg-[#eef2f5] text-slate-800" style={{ fontSize: '18px' }}>
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-[#eef2f5] text-slate-800" style={{ fontSize: '18px' }}>
{task && <DetailHeader task={task} />}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -756,3 +740,40 @@ export default function DetailPage() {
</div>
);
}
export default function DetailPage() {
const { taskId: routeTaskId } = useParams<{ taskId?: string }>();
const isPopupView =
typeof window !== 'undefined' &&
(window.location.search.includes('view=detail') || window.name === 'eene_detail');
const [taskId, setTaskId] = useState<string | null>(
() => routeTaskId ?? getPersistedTaskId(),
);
useEffect(() => {
if (routeTaskId) setTaskId(routeTaskId);
}, [routeTaskId]);
useEffect(() => {
const unsub = onDualMonitorEvent(
(evt) => {
if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId);
if (evt.type === 'TASK_DESELECTED') setTaskId(null);
},
{ isPopupView },
);
return unsub;
}, [isPopupView]);
if (isPopupView) {
return (
<div className="detail-popup-view flex h-screen w-screen overflow-hidden">
<aside className="app-right detail-open">
<TaskDetailShell taskId={taskId} />
</aside>
</div>
);
}
return <TaskDetailShell taskId={taskId} />;
}