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:import-hr": "tsx scripts/import-hr-data.ts",
"db:sync-remote": "tsx scripts/sync-from-remote.ts", "db:sync-remote": "tsx scripts/sync-from-remote.ts",
"db:push-remote": "tsx scripts/sync-to-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": { "dependencies": {
"@prisma/client": "^6.0.0", "@prisma/client": "^6.0.0",

View File

@@ -59,8 +59,7 @@ export interface MappedTask {
const SECTION_MAP: Record<string, string> = { const SECTION_MAP: Record<string, string> = {
: '인사관리', : '인사관리',
: '학습성장', : '학습성장',
: '운영지원', : '운영관리',
: '전산관리',
}; };
const STATUS_MAP: Record<string, TaskStatus> = { 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 prisma = new PrismaClient();
const SOURCE = (process.env.SOURCE_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, ''); 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 RemoteUser = { id: string; name: string; department?: string | null };
type RemoteTask = { type RemoteTask = {
@@ -295,7 +302,7 @@ async function main() {
priority: remote.priority, priority: remote.priority,
quarter: remote.quarter, quarter: remote.quarter,
category: remote.category ?? null, category: remote.category ?? null,
section: remote.section ?? null, section: normalizeSection(remote.section),
tag: remote.tag ?? null, tag: remote.tag ?? null,
taskType: remote.taskType ?? null, taskType: remote.taskType ?? null,
progress: remote.progress ?? 0, progress: remote.progress ?? 0,

View File

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

View File

@@ -1,23 +1,16 @@
import { useRef } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import type { DraggableAttributes } from '@dnd-kit/core'; import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type { Task } from '../../types'; import type { Task } from '../../types';
const STATUS_STYLE: Record<string, string> = { const STATUS_DOT: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500 text-white shadow-blue-500/20', IN_PROGRESS: 'ongoing',
REVIEW: 'bg-amber-400 text-white shadow-amber-400/20', REVIEW: 'hold',
TODO: 'bg-slate-200 text-slate-600 shadow-slate-300/20', TODO: 'hold',
DONE: 'bg-emerald-500 text-white shadow-emerald-500/20', CANCELLED: 'hold',
CANCELLED: 'bg-slate-200 text-slate-400 shadow-slate-300/20', DONE: 'done',
};
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
}; };
function fmtDate(iso: string | null | undefined): string { 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')}`; 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 }; type SectionOption = { value: string; label: string };
export function SortableTaskCard({ export function SortableTaskCard({
task, task,
variant = 'project',
onSelect, onSelect,
}: { }: {
task: Task; task: Task;
variant?: 'project' | 'routine';
sectionOptions?: SectionOption[]; sectionOptions?: SectionOption[];
onSelect?: (task: Task) => void; onSelect?: (task: Task) => void;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }); 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 ( return (
<TaskCard <TaskCard
task={task} task={task}
variant={variant}
dragRef={setNodeRef} dragRef={setNodeRef}
dragStyle={{ dragStyle={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -49,26 +120,64 @@ export function SortableTaskCard({
}} }}
dragAttributes={attributes} dragAttributes={attributes}
dragListeners={listeners} dragListeners={listeners}
onCardClick={() => { if (!isDragging) onSelect?.(task); }} onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
/> />
); );
} }
export function TaskCard({ export function TaskCard({
task, task,
variant = 'project',
dragRef, dragRef,
dragStyle, dragStyle,
dragAttributes, dragAttributes,
dragListeners, dragListeners,
onCardClick, onPointerDown,
onPointerUp,
}: { }: {
task: Task; task: Task;
variant?: 'project' | 'routine';
dragRef?: (node: HTMLElement | null) => void; dragRef?: (node: HTMLElement | null) => void;
dragStyle?: React.CSSProperties; dragStyle?: React.CSSProperties;
dragAttributes?: DraggableAttributes; dragAttributes?: DraggableAttributes;
dragListeners?: SyntheticListenerMap; 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}
style={dragStyle}
{...dragAttributes}
data-task-card="true"
data-task-id={task.id}
className="board-routine-item"
{...dragHandlers}
>
<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 ( return (
<div <div
ref={dragRef} ref={dragRef}
@@ -76,60 +185,26 @@ export function TaskCard({
{...dragAttributes} {...dragAttributes}
data-task-card="true" data-task-card="true"
data-task-id={task.id} 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" className="board-project-card"
onPointerDown={(e) => { {...dragHandlers}
if (e.button !== 0) return;
dragListeners?.onPointerDown?.(e);
}}
onKeyDown={dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined}
onClick={onCardClick}
> >
<div className="flex items-start justify-between gap-3"> <div className="board-project-top">
<span className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-900"> <div className="board-project-main">
{task.title} <div className="board-project-title-row">
</span> <span className={`board-status-dot board-status-dot--${dotClass}`} aria-hidden />
{task.showProgress !== false && ( <span className="board-project-title">{task.title}</span>
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${ </div>
task.progress >= 70 ? 'text-emerald-500' : {dateRange && <p className="board-project-date">{dateRange}</p>}
task.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
}`}>
{task.progress}%
</span>
)}
</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> </div>
)} {showProgress && <SemiCircleGauge value={task.progress} />}
</div>
{task.showDescription && task.description && ( {descLine && (
<div className="mt-2 truncate text-2xl text-slate-700">{task.description}</div> <p className="board-project-desc"> {descLine}</p>
)} )}
{task.showIssue && task.issueNote && ( {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"> <p className="board-project-issue"> {task.issueNote}</p>
<span className="shrink-0"></span>
<span className="truncate">{task.issueNote}</span>
</div>
)} )}
</div> </div>
); );

View File

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

View File

@@ -1422,3 +1422,440 @@ body,
border: 0; 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 @@
/** /**
* 듀얼 모니터 연동 유틸리티 * 듀얼 모니터 연동 — dashboard-260504.vercel.app 와 동일한 창 배치 로직
* BroadcastChannel API를 사용해 두 브라우저 창 간 실시간 통신 * @see https://dashboard-260504.vercel.app/
*/ */
const CHANNEL_NAME = 'eee_dashboard'; const CHANNEL_NAME = 'eee_dashboard';
const DETAIL_WINDOW_NAME = 'eene_detail'; const DETAIL_WINDOW_NAME = 'eene_detail';
const SELECTED_TASK_KEY = 'eee_selected_task'; const SELECTED_TASK_KEY = 'eee_selected_task';
type DualMonitorEvent = export type DualMonitorEvent =
| { type: 'TASK_SELECTED'; taskId: string } | { type: 'TASK_SELECTED'; taskId: string }
| { type: 'TASK_DESELECTED' } | { type: 'TASK_DESELECTED' }
| { type: 'REQUEST_SYNC' }
| { type: 'REFRESH' }; | { type: 'REFRESH' };
let channel: BroadcastChannel | null = null; let channel: BroadcastChannel | null = null;
let detailWindow: Window | 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 { interface ScreenDetailed {
left: number; left: number;
@@ -35,20 +39,22 @@ interface WindowWithScreenDetails extends Window {
getScreenDetails?: () => Promise<ScreenDetails>; getScreenDetails?: () => Promise<ScreenDetails>;
} }
/** 클릭 직후 동기적으로 쓸 기본 창 위치 (await 없음) */ interface WindowPlacement {
function buildSyncWindowFeatures(): string { left: number;
const left = window.screenX + window.outerWidth; top: number;
const top = window.screenY; width: number;
let width = window.screen.availWidth - left; height: number;
if (width < 800) { }
width = 1280;
} function placementToFeatures({ left, top, width, height }: WindowPlacement): string {
const height = window.screen.availHeight;
return `left=${left},top=${top},width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`; 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 left = window.screenX + window.outerWidth;
let top = window.screenY; let top = window.screenY;
let width = window.screen.availWidth; 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); 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 { function getChannel(): BroadcastChannel {
@@ -91,30 +102,50 @@ function getChannel(): BroadcastChannel {
return channel; 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 { export function isDetailWindowOpen(): boolean {
return !!detailWindow && !detailWindow.closed; return !!detailWindow && !detailWindow.closed;
} }
export function isDualModeActive(): boolean {
return dualModeActive && isDetailWindowOpen();
}
function persistSelectedTask(taskId: string | null) { function persistSelectedTask(taskId: string | null) {
if (taskId) sessionStorage.setItem(SELECTED_TASK_KEY, taskId); if (taskId) sessionStorage.setItem(SELECTED_TASK_KEY, taskId);
else sessionStorage.removeItem(SELECTED_TASK_KEY); else sessionStorage.removeItem(SELECTED_TASK_KEY);
} }
/** 상세 페이지 초기 로드용 (BroadcastChannel 유실 대비) */
export function getPersistedTaskId(): string | null { export function getPersistedTaskId(): string | null {
return sessionStorage.getItem(SELECTED_TASK_KEY); return sessionStorage.getItem(SELECTED_TASK_KEY);
} }
function parseWindowFeatures(features: string) { export function registerSyncProvider(fn: () => string | null): () => void {
const out: Record<string, number> = {}; syncProvider = fn;
for (const part of features.split(',')) { return () => {
const [key, value] = part.split('='); if (syncProvider === fn) syncProvider = null;
if (key && value != null) out[key.trim()] = Number(value); };
}
return out;
} }
function applyWindowPlacement(win: Window, left: number, top: number, width: number, height: number) { function applyWindowPlacement(win: Window, left: number, top: number, width: number, height: number) {
try { try {
win.moveTo(left, top); win.moveTo(left, top);
@@ -134,10 +165,9 @@ function scheduleTaskSelected(taskId: string) {
setTimeout(() => postTaskSelected(taskId), 1500); setTimeout(() => postTaskSelected(taskId), 1500);
} }
/** 상세 창 열기 — 반드시 사용자 클릭 직후 동기 호출 */ function openDetailWindowWithPlacement(placement: WindowPlacement): Window | null {
function openDetailWindowSync(): Window | null { const detailUrl = `${window.location.origin}/detail?view=detail`;
const detailUrl = `${window.location.origin}/detail`; const features = placementToFeatures(placement);
const features = buildSyncWindowFeatures();
detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, features); detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, features);
if (!detailWindow) { if (!detailWindow) {
@@ -151,78 +181,121 @@ function openDetailWindowSync(): Window | null {
// ignore // ignore
} }
// 창을 연 뒤 비동기로 위치만 보정 (팝업 차단과 무관) applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height);
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);
}
});
return detailWindow; return detailWindow;
} }
/** 상세 창 토글 — 열려 있으면 닫고, 닫혀 있으면 오른쪽 모니터에 열기 */ /** 참고 사이트: getScreenDetails(await) → window.open */
export function openDetailWindow(): Window | null { async function openDetailWindowPlaced(onPopupClosed?: () => void): Promise<Window | null> {
if (isDetailWindowOpen()) { const placement = await resolveDetailWindowPlacement();
detailWindow!.close(); const win = openDetailWindowWithPlacement(placement);
detailWindow = null; if (!win) return null;
return null;
}
const win = openDetailWindowSync(); startClosePoll(() => onPopupClosed?.());
const savedTaskId = getPersistedTaskId();
if (win && savedTaskId) {
scheduleTaskSelected(savedTaskId);
}
return win; 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); persistSelectedTask(taskId);
if (!isDetailWindowOpen()) { if (!isDetailWindowOpen()) {
const win = openDetailWindowSync(); dualModeActive = true;
if (!win) return; const win = await openDetailWindowPlaced(onPopupClosed);
if (!win) {
dualModeActive = false;
return;
}
scheduleTaskSelected(taskId); scheduleTaskSelected(taskId);
return; return;
} }
scheduleTaskSelected(taskId);
try { try {
detailWindow!.focus(); detailWindow!.focus();
} catch { } catch {
// ignore // 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 { export function sendTaskDeselected(): void {
persistSelectedTask(null); persistSelectedTask(null);
getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent); 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( export function onDualMonitorEvent(
handler: (event: DualMonitorEvent) => void, handler: (event: DualMonitorEvent) => void,
options?: { isPopupView?: boolean },
): () => void { ): () => void {
const ch = getChannel(); 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); ch.addEventListener('message', listener);
if (isPopupView) {
requestDetailSync();
}
return () => ch.removeEventListener('message', listener); return () => ch.removeEventListener('message', listener);
} }
/** 채널 종료 */
export function closeChannel(): void { export function closeChannel(): void {
stopClosePoll();
channel?.close(); channel?.close();
channel = null; channel = null;
} }
/** 웹 링크를 우측 모니터 새 창에서 열기 */
export function openLinkOnRightMonitor(url: string, windowName: string): Window | null { export function openLinkOnRightMonitor(url: string, windowName: string): Window | null {
const features = buildSyncWindowFeatures(); const win = window.open(url, windowName, 'noopener,noreferrer,width=1280,height=900');
const win = window.open(url, windowName, features);
try { try {
win?.focus(); win?.focus();
} catch { } catch {
@@ -230,10 +303,9 @@ export function openLinkOnRightMonitor(url: string, windowName: string): Window
} }
if (win) { if (win) {
void getRightMonitorWindowFeatures().then((f) => { void resolveDetailWindowPlacement().then((placement) => {
const { left, top, width, height } = parseWindowFeatures(f); if (win && !win.closed) {
if (win && !win.closed && left != null && top != null && width && height) { applyWindowPlacement(win, placement.left, placement.top, placement.width, placement.height);
applyWindowPlacement(win, left, top, width, 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 { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
import { TaskManager } from '../components/dashboard/TaskManager'; import { TaskManager } from '../components/dashboard/TaskManager';
import { useSocket } from '../contexts/SocketContext'; 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 { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType';
import { import {
DEFAULT_STATUS_FILTERS, DEFAULT_STATUS_FILTERS,
@@ -28,16 +29,34 @@ import {
toggleCoreFilter, toggleCoreFilter,
type CoreStatusFilter, type CoreStatusFilter,
} from '../lib/statusFilters'; } from '../lib/statusFilters';
import {
SECTIONS,
COLUMN_META,
LEGACY_COLUMN_KEYS,
taskBelongsToSection,
normalizeSection,
type SectionKey,
} from '../lib/sections';
const QUARTER = '2026-Q2'; const QUARTER = '2026-Q2';
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
const COLUMN_STYLES = [ function mergeCardOrders(primary: string | null | undefined, extras: (string | null | undefined)[]): string[] {
{ section: '인사관리', titleEn: 'HR Management', headerStyle: { background: 'linear-gradient(120deg,#2a4a8a 0%,#3461b8 50%,#3d72d0 100%)' } }, const merged: string[] = [];
{ section: '학습성장', titleEn: 'Learning & Growth', headerStyle: { background: 'linear-gradient(120deg,#5b2d8a 0%,#7340b8 50%,#8a52d0 100%)' } }, const push = (raw: string | null | undefined) => {
{ section: '운영지원', titleEn: 'Operations', headerStyle: { background: 'linear-gradient(120deg,#0d6080 0%,#0d7a9a 50%,#0e92b8 100%)' } }, if (!raw) return;
{ section: '전산관리', titleEn: 'IT Management', headerStyle: { background: 'linear-gradient(120deg,#0a6040 0%,#0d8050 50%,#10a060 100%)' } }, try {
] as const; 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() { export default function DashboardPage() {
const [activeFilters, setActiveFilters] = useState<string[]>([...DEFAULT_STATUS_FILTERS]); const [activeFilters, setActiveFilters] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
@@ -48,6 +67,11 @@ export default function DashboardPage() {
const [showAllTeamTasks, setShowAllTeamTasks] = useState(false); const [showAllTeamTasks, setShowAllTeamTasks] = useState(false);
const [activeTeamProjectId, setActiveTeamProjectId] = useState<string | null>(null); const [activeTeamProjectId, setActiveTeamProjectId] = useState<string | null>(null);
const [activeTaskId, setActiveTaskId] = 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 [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({}); const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
@@ -58,19 +82,42 @@ export default function DashboardPage() {
const { data: teamMembers = [] } = useTeamMembers(); const { data: teamMembers = [] } = useTeamMembers();
const { data: colConfigs } = useQuery({ const { data: colConfigs } = useQuery({
queryKey: ['columns', 'all'], queryKey: ['columns', 'all', ...SECTIONS],
queryFn: async () => { queryFn: async () => {
const results = await Promise.all( const results = await Promise.all(
SECTIONS.map((s) => SECTIONS.map(async (s) => {
apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => ({ key: s, ...r.data })), 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; return results;
}, },
staleTime: 0, 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(() => { useEffect(() => {
if (!colConfigs) return; if (!colConfigs) return;
setColumnOrders((prev) => { setColumnOrders((prev) => {
@@ -84,10 +131,9 @@ export default function DashboardPage() {
}); });
}, [colConfigs]); }, [colConfigs]);
// 새 태스크 추가 시 순서 목록에 병합
useEffect(() => { useEffect(() => {
SECTIONS.forEach((section) => { 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) => { setColumnOrders((prev) => {
const existing = prev[section] ?? []; const existing = prev[section] ?? [];
const merged = [ const merged = [
@@ -106,7 +152,7 @@ export default function DashboardPage() {
}); });
const patchTask = useMutation({ 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), apiClient.patch(`/tasks/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
}); });
@@ -131,21 +177,19 @@ export default function DashboardPage() {
if (!over) return; if (!over) return;
const activeId = String(active.id); const activeId = String(active.id);
const overId = String(over.id); const overId = String(over.id);
const draggedTask = tasks.find((t) => t.id === activeId); const draggedTask = tasks.find((t) => t.id === activeId);
if (!draggedTask) return; if (!draggedTask) return;
const srcSection = draggedTask.section ?? ''; const srcSection = normalizeSection(draggedTask.section) ?? '인사관리';
// ── 드롭 대상이 컬럼 드롭존인 경우 ──────────────────────────
if (overId.startsWith('drop::')) { if (overId.startsWith('drop::')) {
// 형식: "drop::project::섹션명" 또는 "drop::routine::섹션명"
const parts = overId.split('::'); const parts = overId.split('::');
const areaType = parts[1]; // 'project' | 'routine' const areaType = parts[1];
const targetSection = parts[2]; const targetSection = parts[2] as SectionKey;
const updateData: Record<string, any> = {}; const updateData: Record<string, unknown> = {};
if (targetSection !== srcSection) updateData.section = targetSection; if (targetSection !== srcSection) updateData.section = targetSection;
if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) { if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) {
updateData.taskType = '기반업무'; updateData.taskType = '기반업무';
@@ -160,15 +204,14 @@ export default function DashboardPage() {
return; return;
} }
// ── 드롭 대상이 다른 카드인 경우 ─────────────────────────────
const overTask = tasks.find((t) => t.id === overId); const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return; if (!overTask) return;
const dstSection = overTask.section ?? ''; const dstSection = normalizeSection(overTask.section) ?? '인사관리';
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType); const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
if (dstSection !== srcSection || typeChanged) { if (dstSection !== srcSection || typeChanged) {
const updateData: Record<string, any> = {}; const updateData: Record<string, unknown> = {};
if (dstSection !== srcSection) updateData.section = dstSection; if (dstSection !== srcSection) updateData.section = dstSection;
if (typeChanged) { if (typeChanged) {
const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제'; const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제';
@@ -179,10 +222,10 @@ export default function DashboardPage() {
if (dstSection !== srcSection) return; if (dstSection !== srcSection) return;
} }
// ── 같은 컬럼 내 순서 변경 ─────────────────────────────────── const current = columnOrders[srcSection]
const current = columnOrders[srcSection] ?? tasks.filter((t) => t.section === srcSection).map((t) => t.id); ?? tasks.filter((t) => taskBelongsToSection(t.section, srcSection)).map((t) => t.id);
const oldIdx = current.indexOf(activeId); const oldIdx = current.indexOf(activeId);
const newIdx = current.indexOf(overId); const newIdx = current.indexOf(overId);
if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return; if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return;
const newOrder = arrayMove(current, oldIdx, newIdx); const newOrder = arrayMove(current, oldIdx, newIdx);
@@ -195,11 +238,11 @@ export default function DashboardPage() {
}; };
const stats = { const stats = {
total: tasks.length, total: tasks.length,
inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length, inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length,
review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO').length, review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO').length,
done: tasks.filter((t) => t.status === 'DONE').length, done: tasks.filter((t) => t.status === 'DONE').length,
issues: tasks.filter((t) => !!t.issueNote).length, issues: tasks.filter((t) => !!t.issueNote).length,
}; };
const filtered = tasks.filter((t) => { const filtered = tasks.filter((t) => {
@@ -229,120 +272,143 @@ export default function DashboardPage() {
const sectionOptions = SECTIONS.map((s) => ({ const sectionOptions = SECTIONS.map((s) => ({
value: 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 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) { if (isLoading) {
return ( return (
<div className="flex h-screen items-center justify-center bg-slate-100"> <div className="flex h-screen items-center justify-center bg-[#eef4f1]">
<div className="text-3xl text-gray-400"> ...</div> <div className="text-lg text-gray-400"> ...</div>
</div> </div>
); );
} }
return ( return (
<div className="relative flex h-screen flex-col overflow-hidden bg-[#eef2f5]" style={{ fontSize: '18px' }}> <div
<DashboardHeader className={`app-shell ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
quarter={QUARTER} >
stats={stats} <div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#eef4f1]">
activeFilters={activeFilters} <DashboardHeader
issueFilterActive={issueFilterActive} quarter={QUARTER}
onToggleAll={handleToggleAll} stats={stats}
onToggleStatus={handleToggleStatus} activeFilters={activeFilters}
onToggleIssue={handleToggleIssue} issueFilterActive={issueFilterActive}
onOpenDetailWindow={() => { openDetailWindow(); }} onToggleAll={handleToggleAll}
onOpenTaskManager={() => setShowTaskManager(true)} onToggleStatus={handleToggleStatus}
teamPanelOpen={teamPanelOpen} onToggleIssue={handleToggleIssue}
onToggleTeamPanel={() => { onOpenDetailWindow={handleOpenDetailWindow}
setTeamPanelOpen((open) => { onOpenTaskManager={() => setShowTaskManager(true)}
if (open) { teamPanelOpen={teamPanelOpen}
setActiveTeamProjectId(null); onToggleTeamPanel={() => {
setShowAllTeamTasks(false); setTeamPanelOpen((open) => {
} if (open) {
return !open; setActiveTeamProjectId(null);
}); setShowAllTeamTasks(false);
}} }
/> return !open;
});
{teamPanelOpen && (
<TeamStatusPanel
members={teamMembers}
tasks={tasks}
showAllTasks={showAllTeamTasks}
activeProjectId={activeTeamProjectId}
onToggleShowAll={() => setShowAllTeamTasks((v) => !v)}
onProjectClick={setActiveTeamProjectId}
onClose={() => {
setTeamPanelOpen(false);
setActiveTeamProjectId(null);
setShowAllTeamTasks(false);
}} }}
/> />
)}
<main className={`relative flex min-h-0 flex-1 overflow-hidden px-5 py-5 ${teamPanelOpen ? 'hidden' : ''}`}> {teamPanelOpen && (
{/* ── 좌측 라벨 컬럼 ── */} <TeamStatusPanel
<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"> members={teamMembers}
<div className="h-10 shrink-0 border-b border-slate-100 bg-slate-50" /> tasks={tasks}
<div className="flex flex-1 items-center justify-center border-b border-slate-100 bg-gradient-to-b from-slate-50 to-white"> showAllTasks={showAllTeamTasks}
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}></span> activeProjectId={activeTeamProjectId}
</div> onToggleShowAll={() => setShowAllTeamTasks((v) => !v)}
<div className="flex shrink-0 items-center justify-center bg-gradient-to-b from-white to-slate-50" style={{ height: 300 }}> onProjectClick={setActiveTeamProjectId}
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}></span> onClose={() => {
</div> setTeamPanelOpen(false);
</div> setActiveTeamProjectId(null);
setShowAllTeamTasks(false);
}}
/>
)}
<DndContext <main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
sensors={sensors} <DndContext
collisionDetection={closestCenter} sensors={sensors}
onDragStart={handleDragStart} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} 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">
<DepartmentColumn {SECTIONS.map((section) => {
key={section} const meta = COLUMN_META[section];
title={section} return (
titleEn={titleEn} <DepartmentColumn
tasks={filtered.filter((t) => t.section === section)} key={section}
orderedIds={columnOrders[section] ?? []} title={meta.displayTitle}
headerBg="" titleEn={meta.titleEn}
headerStyle={headerStyle} accent={meta.accent}
storageKey={`col_${section}`} tasks={filtered.filter((t) => taskBelongsToSection(t.section, section))}
section={section} orderedIds={columnOrders[section] ?? []}
quarter={QUARTER} section={section}
onSelectTask={(t) => sendTaskSelected(t.id)} quarter={QUARTER}
sectionOptions={sectionOptions} onSelectTask={(t) => handleSelectTask(t.id)}
teamMembers={teamMembers} sectionOptions={sectionOptions}
/> teamMembers={teamMembers}
))} />
</div> );
})}
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}> <DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
{activeTask ? ( {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="board-project-card board-project-card--overlay">
<div className="flex items-start justify-between gap-3"> <div className="board-project-top">
<span className="min-w-0 truncate text-2xl font-black leading-snug text-slate-900">{activeTask.title}</span> <div className="board-project-main">
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${ <div className="board-project-title-row">
activeTask.progress >= 70 ? 'text-emerald-500' : activeTask.progress >= 40 ? 'text-blue-400' : 'text-orange-400' <span className="board-status-dot board-status-dot--ongoing" aria-hidden />
}`}>{activeTask.progress}%</span> <span className="board-project-title">{activeTask.title}</span>
</div>
</div>
{activeTask.showProgress !== false && (
<span className="board-gauge-value">{activeTask.progress}%</span>
)}
</div>
</div> </div>
</div> ) : null}
) : null} </DragOverlay>
</DragOverlay> </DndContext>
</DndContext> </main>
</main>
{showTaskManager && ( {showTaskManager && (
<TaskManager <TaskManager
tasks={tasks} tasks={tasks}
sectionOptions={sectionOptions} sectionOptions={sectionOptions}
quarter={QUARTER} quarter={QUARTER}
teamMembers={teamMembers} teamMembers={teamMembers}
onClose={() => setShowTaskManager(false)} onClose={() => setShowTaskManager(false)}
/> />
)}
</div>
{!detailPopupOpen && (
<aside className={`app-right ${showDockedDetail ? 'detail-open' : ''}`}>
<TaskDetailShell taskId={selectedTaskId} />
</aside>
)} )}
</div> </div>
); );

View File

@@ -14,6 +14,7 @@ import {
} from '../components/detail/StageModal'; } from '../components/detail/StageModal';
import { sortFilesByOrder } from '../lib/fileDisplay'; import { sortFilesByOrder } from '../lib/fileDisplay';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { formatSectionDisplay } from '../lib/sections';
import type { Task, Milestone, FileRecord, TaskDetail } from '../types'; import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
const STATUS_CONFIG: Record<string, { label: string }> = { 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="h-4 w-px bg-white/25" />
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
<span className="font-semibold text-white/55"></span>{' '} <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> </span>
<Badge className="border border-white/20 bg-white/10 text-white/90">{status.label}</Badge> <Badge className="border border-white/20 bg-white/10 text-white/90">{status.label}</Badge>
</div> </div>
@@ -704,24 +705,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
); );
} }
export default function DetailPage() { export function TaskDetailShell({ taskId }: { taskId: string | null }) {
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;
}, []);
const { data: task, isLoading, isError, error } = useQuery({ const { data: task, isLoading, isError, error } = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
queryFn: async () => { queryFn: async () => {
@@ -734,7 +718,7 @@ export default function DetailPage() {
}); });
return ( 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} />} {task && <DetailHeader task={task} />}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -756,3 +740,40 @@ export default function DetailPage() {
</div> </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} />;
}