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: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",
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
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 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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user