feat: 3-section dashboard, reference dual-monitor layout, and detail dock
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -59,8 +59,7 @@ export interface MappedTask {
|
||||
const SECTION_MAP: Record<string, string> = {
|
||||
인사관리: '인사관리',
|
||||
성장지원: '학습성장',
|
||||
운영지원: '운영지원',
|
||||
전산관리: '전산관리',
|
||||
운영관리: '운영관리',
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, TaskStatus> = {
|
||||
|
||||
66
backend/scripts/migrate-sections.ts
Normal file
66
backend/scripts/migrate-sections.ts
Normal 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());
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
@@ -112,9 +117,10 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }),
|
||||
});
|
||||
|
||||
const title = colConfig?.title ?? initialTitle;
|
||||
const titleEnState = colConfig?.titleEn ?? (titleEn ?? '');
|
||||
const subtitle = colConfig?.subtitle ?? initialSubtitle;
|
||||
const title = colConfig?.title ?? initialTitle;
|
||||
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}
|
||||
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>
|
||||
{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>
|
||||
</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
|
||||
className="board-dept-header"
|
||||
style={{ borderBottomColor: accentColor }}
|
||||
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }}
|
||||
>
|
||||
<div className="board-dept-header-main">
|
||||
<div className="board-dept-title-wrap">
|
||||
<h2 className="board-dept-title" style={{ color: accentColor }}>
|
||||
{title.replace(/\s*부문$/, '')}
|
||||
</h2>
|
||||
{titleEnState && (
|
||||
<span className="board-dept-title-en" style={{ color: accentColor }}>
|
||||
{titleEnState}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{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 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 }}
|
||||
onContextMenu={handleListContextMenu}
|
||||
>
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
{routineTasks.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-base text-slate-300">
|
||||
기반업무 없음
|
||||
</div>
|
||||
) : (
|
||||
<SortableContext items={routineTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
{routineTasks.map((task) => (
|
||||
<SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
ref={setProjectDropRef}
|
||||
className={`board-project-list ${isProjectOver ? 'is-over' : ''}`}
|
||||
onContextMenu={handleListContextMenu}
|
||||
>
|
||||
{projectTasks.length === 0 ? (
|
||||
<div className="board-empty">해당 업무 없음</div>
|
||||
) : (
|
||||
<SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
{projectTasks.map((task) => (
|
||||
<SortableTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
variant="project"
|
||||
sectionOptions={sectionOptions}
|
||||
onSelect={onSelectTask}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={setRoutineDropRef}
|
||||
className={`board-routine-section ${isRoutineOver ? 'is-over' : ''}`}
|
||||
style={{
|
||||
borderTopColor: `${accentColor}80`,
|
||||
background: COLUMN_META[section].routineBg,
|
||||
}}
|
||||
onContextMenu={handleListContextMenu}
|
||||
>
|
||||
<div className="board-routine-list">
|
||||
{routineTasks.length === 0 ? (
|
||||
<div className="board-routine-empty" aria-hidden />
|
||||
) : (
|
||||
<SortableContext items={routineTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
{routineTasks.map((task) => (
|
||||
<SortableTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
variant="routine"
|
||||
sectionOptions={sectionOptions}
|
||||
onSelect={onSelectTask}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 우클릭 메뉴 (추가/수정/삭제) */}
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,64 @@ 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}
|
||||
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 (
|
||||
<div
|
||||
ref={dragRef}
|
||||
@@ -76,60 +185,26 @@ 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-project-card"
|
||||
{...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>
|
||||
)}
|
||||
</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 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>
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
const height = window.screen.availHeight;
|
||||
interface WindowPlacement {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
68
frontend/src/lib/sections.ts
Normal file
68
frontend/src/lib/sections.ts
Normal 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%)',
|
||||
},
|
||||
};
|
||||
@@ -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'] }),
|
||||
});
|
||||
@@ -131,21 +177,19 @@ export default function DashboardPage() {
|
||||
if (!over) return;
|
||||
|
||||
const activeId = String(active.id);
|
||||
const overId = String(over.id);
|
||||
const overId = String(over.id);
|
||||
|
||||
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,10 +222,10 @@ export default function DashboardPage() {
|
||||
if (dstSection !== srcSection) return;
|
||||
}
|
||||
|
||||
// ── 같은 컬럼 내 순서 변경 ───────────────────────────────────
|
||||
const current = columnOrders[srcSection] ?? tasks.filter((t) => t.section === srcSection).map((t) => t.id);
|
||||
const oldIdx = current.indexOf(activeId);
|
||||
const newIdx = current.indexOf(overId);
|
||||
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;
|
||||
|
||||
const newOrder = arrayMove(current, oldIdx, newIdx);
|
||||
@@ -195,11 +238,11 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
total: tasks.length,
|
||||
inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length,
|
||||
review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO').length,
|
||||
done: tasks.filter((t) => t.status === 'DONE').length,
|
||||
issues: tasks.filter((t) => !!t.issueNote).length,
|
||||
review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO').length,
|
||||
done: tasks.filter((t) => t.status === 'DONE').length,
|
||||
issues: tasks.filter((t) => !!t.issueNote).length,
|
||||
};
|
||||
|
||||
const filtered = tasks.filter((t) => {
|
||||
@@ -229,120 +272,143 @@ 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' }}>
|
||||
<DashboardHeader
|
||||
quarter={QUARTER}
|
||||
stats={stats}
|
||||
activeFilters={activeFilters}
|
||||
issueFilterActive={issueFilterActive}
|
||||
onToggleAll={handleToggleAll}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onToggleIssue={handleToggleIssue}
|
||||
onOpenDetailWindow={() => { openDetailWindow(); }}
|
||||
onOpenTaskManager={() => setShowTaskManager(true)}
|
||||
teamPanelOpen={teamPanelOpen}
|
||||
onToggleTeamPanel={() => {
|
||||
setTeamPanelOpen((open) => {
|
||||
if (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);
|
||||
<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}
|
||||
activeFilters={activeFilters}
|
||||
issueFilterActive={issueFilterActive}
|
||||
onToggleAll={handleToggleAll}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onToggleIssue={handleToggleIssue}
|
||||
onOpenDetailWindow={handleOpenDetailWindow}
|
||||
onOpenTaskManager={() => setShowTaskManager(true)}
|
||||
teamPanelOpen={teamPanelOpen}
|
||||
onToggleTeamPanel={() => {
|
||||
setTeamPanelOpen((open) => {
|
||||
if (open) {
|
||||
setActiveTeamProjectId(null);
|
||||
setShowAllTeamTasks(false);
|
||||
}
|
||||
return !open;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{teamPanelOpen && (
|
||||
<TeamStatusPanel
|
||||
members={teamMembers}
|
||||
tasks={tasks}
|
||||
showAllTasks={showAllTeamTasks}
|
||||
activeProjectId={activeTeamProjectId}
|
||||
onToggleShowAll={() => setShowAllTeamTasks((v) => !v)}
|
||||
onProjectClick={setActiveTeamProjectId}
|
||||
onClose={() => {
|
||||
setTeamPanelOpen(false);
|
||||
setActiveTeamProjectId(null);
|
||||
setShowAllTeamTasks(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 }) => (
|
||||
<DepartmentColumn
|
||||
key={section}
|
||||
title={section}
|
||||
titleEn={titleEn}
|
||||
tasks={filtered.filter((t) => t.section === section)}
|
||||
orderedIds={columnOrders[section] ?? []}
|
||||
headerBg=""
|
||||
headerStyle={headerStyle}
|
||||
storageKey={`col_${section}`}
|
||||
section={section}
|
||||
quarter={QUARTER}
|
||||
onSelectTask={(t) => sendTaskSelected(t.id)}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="board-grid">
|
||||
{SECTIONS.map((section) => {
|
||||
const meta = COLUMN_META[section];
|
||||
return (
|
||||
<DepartmentColumn
|
||||
key={section}
|
||||
title={meta.displayTitle}
|
||||
titleEn={meta.titleEn}
|
||||
accent={meta.accent}
|
||||
tasks={filtered.filter((t) => taskBelongsToSection(t.section, section))}
|
||||
orderedIds={columnOrders[section] ?? []}
|
||||
section={section}
|
||||
quarter={QUARTER}
|
||||
onSelectTask={(t) => handleSelectTask(t.id)}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
||||
{activeTask ? (
|
||||
<div className="board-project-card board-project-card--overlay">
|
||||
<div className="board-project-top">
|
||||
<div className="board-project-main">
|
||||
<div className="board-project-title-row">
|
||||
<span className="board-status-dot board-status-dot--ongoing" aria-hidden />
|
||||
<span className="board-project-title">{activeTask.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
{activeTask.showProgress !== false && (
|
||||
<span className="board-gauge-value">{activeTask.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</main>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</main>
|
||||
|
||||
{showTaskManager && (
|
||||
<TaskManager
|
||||
tasks={tasks}
|
||||
sectionOptions={sectionOptions}
|
||||
quarter={QUARTER}
|
||||
teamMembers={teamMembers}
|
||||
onClose={() => setShowTaskManager(false)}
|
||||
/>
|
||||
{showTaskManager && (
|
||||
<TaskManager
|
||||
tasks={tasks}
|
||||
sectionOptions={sectionOptions}
|
||||
quarter={QUARTER}
|
||||
teamMembers={teamMembers}
|
||||
onClose={() => setShowTaskManager(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!detailPopupOpen && (
|
||||
<aside className={`app-right ${showDockedDetail ? 'detail-open' : ''}`}>
|
||||
<TaskDetailShell taskId={selectedTaskId} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user