feat: quarter board theme, hub column, and team panel UX
Apply preview-style 4-dept layout with center hub, PM/assignee team status linking, task type label updates, and remove task keywords. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,7 +41,6 @@ export interface MappedTask {
|
|||||||
issueNote: string | null;
|
issueNote: string | null;
|
||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
keywords: string | null;
|
|
||||||
showDate: boolean;
|
showDate: boolean;
|
||||||
showDescription: boolean;
|
showDescription: boolean;
|
||||||
showStatus: boolean;
|
showStatus: boolean;
|
||||||
@@ -194,7 +193,6 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
|
|||||||
issueNote: pickIssueNote(p),
|
issueNote: pickIssueNote(p),
|
||||||
startDate: parseDate(p.startDate),
|
startDate: parseDate(p.startDate),
|
||||||
dueDate: parseDate(p.endDate),
|
dueDate: parseDate(p.endDate),
|
||||||
keywords: p.keywords?.length ? p.keywords.join(', ') : null,
|
|
||||||
showDate: visible,
|
showDate: visible,
|
||||||
showDescription: visible,
|
showDescription: visible,
|
||||||
showStatus: visible,
|
showStatus: visible,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 키워드 필드 제거 (데이터 삭제 후 컬럼 drop)
|
||||||
|
UPDATE "tasks" SET "keywords" = NULL WHERE "keywords" IS NOT NULL;
|
||||||
|
ALTER TABLE "tasks" DROP COLUMN IF EXISTS "keywords";
|
||||||
@@ -80,7 +80,6 @@ model Task {
|
|||||||
showStatus Boolean @default(true)
|
showStatus Boolean @default(true)
|
||||||
showIssue Boolean @default(true)
|
showIssue Boolean @default(true)
|
||||||
showProgress Boolean @default(true)
|
showProgress Boolean @default(true)
|
||||||
keywords String?
|
|
||||||
creatorId String
|
creatorId String
|
||||||
assigneeId String?
|
assigneeId String?
|
||||||
pmMemberId String?
|
pmMemberId String?
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ type RemoteTask = {
|
|||||||
showStatus: boolean;
|
showStatus: boolean;
|
||||||
showIssue: boolean;
|
showIssue: boolean;
|
||||||
showProgress: boolean;
|
showProgress: boolean;
|
||||||
keywords?: string | null;
|
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
pmMemberId?: string | null;
|
pmMemberId?: string | null;
|
||||||
@@ -314,7 +313,6 @@ async function main() {
|
|||||||
showStatus: remote.showStatus,
|
showStatus: remote.showStatus,
|
||||||
showIssue: remote.showIssue,
|
showIssue: remote.showIssue,
|
||||||
showProgress: remote.showProgress,
|
showProgress: remote.showProgress,
|
||||||
keywords: remote.keywords ?? null,
|
|
||||||
creatorId,
|
creatorId,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
pmMemberId,
|
pmMemberId,
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ async function syncTasks(memberIdMap: Map<string, string>) {
|
|||||||
showStatus: task.showStatus,
|
showStatus: task.showStatus,
|
||||||
showIssue: task.showIssue,
|
showIssue: task.showIssue,
|
||||||
showProgress: task.showProgress,
|
showProgress: task.showProgress,
|
||||||
keywords: task.keywords,
|
|
||||||
pmMemberId,
|
pmMemberId,
|
||||||
assigneeMemberIds,
|
assigneeMemberIds,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
const body = req.body as Record<string, any>;
|
const body = req.body as Record<string, any>;
|
||||||
const { title, description, status, priority, quarter, category,
|
const { title, description, status, priority, quarter, category,
|
||||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
||||||
showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body;
|
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
||||||
|
|
||||||
if (!title || !quarter) {
|
if (!title || !quarter) {
|
||||||
throw new AppError(400, '제목과 분기는 필수입니다.');
|
throw new AppError(400, '제목과 분기는 필수입니다.');
|
||||||
@@ -85,7 +85,6 @@ router.post('/', async (req, res, next) => {
|
|||||||
showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === true : true,
|
showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === true : true,
|
||||||
showIssue: showIssue !== undefined ? showIssue === 'true' || showIssue === true : true,
|
showIssue: showIssue !== undefined ? showIssue === 'true' || showIssue === true : true,
|
||||||
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
|
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
|
||||||
keywords: keywords || null,
|
|
||||||
assigneeId: assigneeId || null,
|
assigneeId: assigneeId || null,
|
||||||
pmMemberId: pmMemberId || null,
|
pmMemberId: pmMemberId || null,
|
||||||
creatorId,
|
creatorId,
|
||||||
@@ -118,7 +117,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
const body = req.body as Record<string, any>;
|
const body = req.body as Record<string, any>;
|
||||||
const { title, description, status, priority, quarter, category,
|
const { title, description, status, priority, quarter, category,
|
||||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
||||||
showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body;
|
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
||||||
|
|
||||||
const assigneeMemberIds = parseMemberIds(body);
|
const assigneeMemberIds = parseMemberIds(body);
|
||||||
|
|
||||||
@@ -145,7 +144,6 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }),
|
...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }),
|
||||||
...(showIssue !== undefined && { showIssue: showIssue === true || showIssue === 'true' }),
|
...(showIssue !== undefined && { showIssue: showIssue === true || showIssue === 'true' }),
|
||||||
...(showProgress !== undefined && { showProgress: showProgress === true || showProgress === 'true' }),
|
...(showProgress !== undefined && { showProgress: showProgress === true || showProgress === 'true' }),
|
||||||
...(keywords !== undefined && { keywords: keywords || null }),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
1951
frontend/public/quarter-dashboard-preview.html
Normal file
1951
frontend/public/quarter-dashboard-preview.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { Task, TeamMember } from '../../types';
|
import type { Task, TeamMember } from '../../types';
|
||||||
import { normalizeTaskType, displayFlagsForTaskType } from '../../lib/taskType';
|
import { normalizeTaskType, displayFlagsForTaskType, TASK_TYPE_OPTIONS } from '../../lib/taskType';
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'TODO', label: '대기' },
|
{ value: 'TODO', label: '대기' },
|
||||||
@@ -27,7 +27,6 @@ export interface TaskFormData {
|
|||||||
showStatus: boolean;
|
showStatus: boolean;
|
||||||
showIssue: boolean;
|
showIssue: boolean;
|
||||||
showProgress: boolean;
|
showProgress: boolean;
|
||||||
keywords: string;
|
|
||||||
pmMemberId: string;
|
pmMemberId: string;
|
||||||
assigneeMemberIds: string[];
|
assigneeMemberIds: string[];
|
||||||
}
|
}
|
||||||
@@ -76,7 +75,6 @@ export function TaskModal({
|
|||||||
showStatus: task?.showStatus ?? true,
|
showStatus: task?.showStatus ?? true,
|
||||||
showIssue: task?.showIssue ?? true,
|
showIssue: task?.showIssue ?? true,
|
||||||
showProgress: task?.showProgress ?? true,
|
showProgress: task?.showProgress ?? true,
|
||||||
keywords: task?.keywords ?? '',
|
|
||||||
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
|
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
|
||||||
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
|
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
|
||||||
});
|
});
|
||||||
@@ -168,8 +166,9 @@ export function TaskModal({
|
|||||||
}}
|
}}
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
|
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
|
||||||
>
|
>
|
||||||
<option value="기반업무">기반업무</option>
|
{TASK_TYPE_OPTIONS.map((opt) => (
|
||||||
<option value="실행과제">실행과제</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,17 +298,6 @@ export function TaskModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 키워드 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">키워드 (콤마로 구분)</label>
|
|
||||||
<input
|
|
||||||
value={form.keywords}
|
|
||||||
onChange={(e) => set('keywords', e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
|
||||||
placeholder="예: 인사정보, ERP, 입퇴사 분석"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 프로젝트 기간 */}
|
{/* 프로젝트 기간 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
|||||||
11
frontend/src/components/dashboard/BoardConnectors.tsx
Normal file
11
frontend/src/components/dashboard/BoardConnectors.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useBoardConnectors } from '../../hooks/useBoardConnectors';
|
||||||
|
|
||||||
|
export function BoardConnectors({ enabled = true }: { enabled?: boolean }) {
|
||||||
|
const { svgRef, lineGroupRef } = useBoardConnectors(enabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg ref={svgRef} className="connectors" aria-hidden="true">
|
||||||
|
<g ref={lineGroupRef} id="connector-lines" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
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 } from '../../lib/taskType';
|
||||||
import { COLUMN_META, type SectionKey } from '../../lib/sections';
|
import { type BoardSlotConfig, slotSectionLabel } from '../../lib/boardLayout';
|
||||||
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';
|
||||||
@@ -13,13 +13,29 @@ import type { TaskFormData } from '../common/TaskModal';
|
|||||||
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
||||||
import type { Task, TeamMember } from '../../types';
|
import type { Task, TeamMember } from '../../types';
|
||||||
|
|
||||||
|
const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1';
|
||||||
|
|
||||||
|
type DummyHeaders = Record<string, { title: string; titleEn: string; subtitle: string }>;
|
||||||
|
|
||||||
|
function loadDummyHeaders(): DummyHeaders {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DUMMY_HEADER_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDummyHeader(slotId: string, data: { title: string; titleEn: string; subtitle: string }) {
|
||||||
|
const all = loadDummyHeaders();
|
||||||
|
all[slotId] = data;
|
||||||
|
localStorage.setItem(DUMMY_HEADER_KEY, JSON.stringify(all));
|
||||||
|
}
|
||||||
|
|
||||||
interface DepartmentColumnProps {
|
interface DepartmentColumnProps {
|
||||||
title: string;
|
slot: BoardSlotConfig;
|
||||||
titleEn?: string;
|
|
||||||
accent?: string;
|
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
orderedIds: string[];
|
orderedIds: string[];
|
||||||
section: SectionKey;
|
|
||||||
quarter: string;
|
quarter: string;
|
||||||
onSelectTask?: (task: Task) => void;
|
onSelectTask?: (task: Task) => void;
|
||||||
sectionOptions?: { value: string; label: string }[];
|
sectionOptions?: { value: string; label: string }[];
|
||||||
@@ -59,7 +75,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={draftTitle}
|
value={draftTitle}
|
||||||
onChange={(e) => setDraftTitle(e.target.value)}
|
onChange={(e) => setDraftTitle(e.target.value)}
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-emerald-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -67,7 +83,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
|||||||
<input
|
<input
|
||||||
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-emerald-400"
|
||||||
placeholder="HRM"
|
placeholder="HRM"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,12 +92,12 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
|||||||
<input
|
<input
|
||||||
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-emerald-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50 transition">취소</button>
|
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50">취소</button>
|
||||||
<button type="submit" className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition">저장</button>
|
<button type="submit" className="px-6 py-2.5 rounded-xl bg-emerald-700 text-white font-bold hover:bg-emerald-800">저장</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,36 +107,44 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DepartmentColumn({
|
export function DepartmentColumn({
|
||||||
title: initialTitle,
|
slot,
|
||||||
titleEn,
|
|
||||||
accent,
|
|
||||||
tasks,
|
tasks,
|
||||||
orderedIds,
|
orderedIds,
|
||||||
section,
|
|
||||||
quarter,
|
quarter,
|
||||||
onSelectTask,
|
onSelectTask,
|
||||||
sectionOptions: externalSectionOptions,
|
sectionOptions: externalSectionOptions,
|
||||||
teamMembers = [],
|
teamMembers = [],
|
||||||
}: DepartmentColumnProps) {
|
}: DepartmentColumnProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const meta = COLUMN_META[section];
|
const section = slotSectionLabel(slot);
|
||||||
|
const isDummySlot = !slot.sectionKey;
|
||||||
|
|
||||||
const { data: colConfig } = useQuery({
|
const { data: colConfig } = useQuery({
|
||||||
queryKey: ['columns', section],
|
queryKey: ['columns', slot.sectionKey],
|
||||||
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(section)}`).then((r) => r.data),
|
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(slot.sectionKey!)}`).then((r) => r.data),
|
||||||
|
enabled: !!slot.sectionKey,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [dummyHeader, setDummyHeader] = useState(() => loadDummyHeaders()[slot.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDummyHeader(loadDummyHeaders()[slot.id]);
|
||||||
|
}, [slot.id]);
|
||||||
|
|
||||||
const patchColumn = useMutation({
|
const patchColumn = useMutation({
|
||||||
mutationFn: (data: Record<string, string>) =>
|
mutationFn: (data: Record<string, string>) =>
|
||||||
apiClient.patch(`/columns/${encodeURIComponent(section)}`, data),
|
apiClient.patch(`/columns/${encodeURIComponent(slot.sectionKey!)}`, data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', slot.sectionKey] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = colConfig?.title ?? initialTitle;
|
const title = isDummySlot
|
||||||
const titleEnState = colConfig?.titleEn ?? (titleEn ?? meta.titleEn);
|
? dummyHeader?.title ?? slot.defaultTitle
|
||||||
const subtitle = colConfig?.subtitle ?? '';
|
: colConfig?.title ?? slot.defaultTitle;
|
||||||
const accentColor = accent ?? meta.accent;
|
const titleEnState = isDummySlot
|
||||||
|
? dummyHeader?.titleEn ?? slot.defaultTitleEn
|
||||||
|
: colConfig?.titleEn ?? slot.defaultTitleEn;
|
||||||
|
const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? '';
|
||||||
|
|
||||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
|
const [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);
|
||||||
@@ -129,8 +153,9 @@ export function DepartmentColumn({
|
|||||||
const [showHeaderModal, setShowHeaderModal] = useState(false);
|
const [showHeaderModal, setShowHeaderModal] = useState(false);
|
||||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` });
|
const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({
|
||||||
const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({ id: `drop::routine::${section}` });
|
id: `drop::project::${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);
|
||||||
@@ -142,9 +167,6 @@ export function DepartmentColumn({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType));
|
const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType));
|
||||||
const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType));
|
|
||||||
|
|
||||||
const patchColumnField = (field: string, value: string) => patchColumn.mutate({ [field]: value });
|
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
|
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
|
||||||
@@ -211,33 +233,41 @@ export function DepartmentColumn({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveHeader = (t: string, te: string, s: string) => {
|
||||||
|
if (isDummySlot) {
|
||||||
|
saveDummyHeader(slot.id, { title: t, titleEn: te, subtitle: s });
|
||||||
|
setDummyHeader({ title: t, titleEn: te, subtitle: s });
|
||||||
|
} else {
|
||||||
|
patchColumn.mutate({ title: t, titleEn: te, subtitle: s });
|
||||||
|
}
|
||||||
|
setShowHeaderModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<section
|
||||||
className="board-dept-column"
|
className={`dept-card ${slot.cssClass}`}
|
||||||
onContextMenuCapture={handleColumnContextMenuCapture}
|
onContextMenuCapture={handleColumnContextMenuCapture}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="board-dept-header"
|
className="dept-head"
|
||||||
style={{ borderBottomColor: accentColor }}
|
onContextMenu={(e) => {
|
||||||
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }}
|
e.preventDefault();
|
||||||
|
setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="board-dept-header-main">
|
<div className="dept-head-main">
|
||||||
<div className="board-dept-title-wrap">
|
<div className="board-dept-header-main">
|
||||||
<h2 className="board-dept-title" style={{ color: accentColor }}>
|
<div className="board-dept-title-wrap">
|
||||||
{title.replace(/\s*부문$/, '')}
|
<h2 className="board-dept-title">{title.replace(/\s*부문$/, '')}</h2>
|
||||||
</h2>
|
{titleEnState && <span className="board-dept-title-en">{titleEnState}</span>}
|
||||||
{titleEnState && (
|
</div>
|
||||||
<span className="board-dept-title-en" style={{ color: accentColor }}>
|
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
|
||||||
{titleEnState}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="board-dept-count-badge" style={{ color: accentColor }}>
|
<div className="dept-head-count" aria-label={`${projectTasks.length}건`}>
|
||||||
<span className="board-dept-count-val">{tasks.length}</span>
|
<span className="poly-stat-val">{projectTasks.length}</span>
|
||||||
<span className="board-dept-count-unit">건</span>
|
<span className="poly-stat-unit"> 건</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -262,35 +292,7 @@ export function DepartmentColumn({
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div
|
|
||||||
ref={setRoutineDropRef}
|
|
||||||
className={`board-routine-section ${isRoutineOver ? 'is-over' : ''}`}
|
|
||||||
style={{
|
|
||||||
borderTopColor: `${accentColor}80`,
|
|
||||||
background: COLUMN_META[section].routineBg,
|
|
||||||
}}
|
|
||||||
onContextMenu={handleListContextMenu}
|
|
||||||
>
|
|
||||||
<div className="board-routine-list">
|
|
||||||
{routineTasks.length === 0 ? (
|
|
||||||
<div className="board-routine-empty" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<SortableContext items={routineTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
{routineTasks.map((task) => (
|
|
||||||
<SortableTaskCard
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
variant="routine"
|
|
||||||
sectionOptions={sectionOptions}
|
|
||||||
onSelect={onSelectTask}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cardMenu && (
|
{cardMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -346,12 +348,7 @@ export function DepartmentColumn({
|
|||||||
title={title}
|
title={title}
|
||||||
titleEn={titleEnState}
|
titleEn={titleEnState}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
onSave={(t, te, s) => {
|
onSave={saveHeader}
|
||||||
patchColumnField('title', t);
|
|
||||||
patchColumnField('titleEn', te);
|
|
||||||
patchColumnField('subtitle', s);
|
|
||||||
setShowHeaderModal(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setShowHeaderModal(false)}
|
onClose={() => setShowHeaderModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
19
frontend/src/components/dashboard/DonutGauge.tsx
Normal file
19
frontend/src/components/dashboard/DonutGauge.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Task } from '../../types';
|
||||||
|
import { getDonutDisplay } from '../../lib/taskStatusVisual';
|
||||||
|
|
||||||
|
export function DonutGauge({ task }: { task: Pick<Task, 'status' | 'progress'> }) {
|
||||||
|
const display = getDonutDisplay(task);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="donut"
|
||||||
|
style={{
|
||||||
|
['--pct' as string]: display.pct,
|
||||||
|
['--color' as string]: display.ringColor,
|
||||||
|
}}
|
||||||
|
aria-label={display.ariaLabel}
|
||||||
|
>
|
||||||
|
<span style={{ color: display.labelColor }}>{display.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
402
frontend/src/components/dashboard/HubColumn.tsx
Normal file
402
frontend/src/components/dashboard/HubColumn.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
import type { Task } from '../../types';
|
||||||
|
import { useHubConfig, type HubConfig, type HubScheduleItem } from '../../lib/hubConfig';
|
||||||
|
import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSchedule';
|
||||||
|
import { HubScheduleCarousel } from './HubScheduleCarousel';
|
||||||
|
import { ContextMenu } from '../common/ContextMenu';
|
||||||
|
|
||||||
|
interface HubColumnProps {
|
||||||
|
routineTasks: Task[];
|
||||||
|
quarter?: string;
|
||||||
|
onSelectRoutine?: (task: Task) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HubEditSection = 'slogan' | 'routine' | 'schedule';
|
||||||
|
|
||||||
|
function ModalShell({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
}) {
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl w-[min(480px,92vw)] max-h-[90vh] overflow-y-auto p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-xl font-black text-gray-800">{title}</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
{children}
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="px-6 py-2.5 rounded-xl bg-emerald-700 text-white font-bold hover:bg-emerald-800">
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SloganEditModal({
|
||||||
|
config,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
config: HubConfig;
|
||||||
|
onSave: (patch: Pick<HubConfig, 'sloganTitle' | 'sloganLines'>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(config.sloganTitle);
|
||||||
|
const [lines, setLines] = useState(config.sloganLines.join('\n'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
title="분기 슬로건 수정"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({ sloganTitle: title.trim() || '분기 슬로건', sloganLines: lines.split('\n') });
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-500 mb-1.5">제목</label>
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-500 mb-1.5">내용 (줄마다 Enter)</label>
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
value={lines}
|
||||||
|
onChange={(e) => setLines(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoutineEditModal({
|
||||||
|
config,
|
||||||
|
hasRoutineTasks,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
config: HubConfig;
|
||||||
|
hasRoutineTasks: boolean;
|
||||||
|
onSave: (patch: Pick<HubConfig, 'routineLabels'>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [labels, setLabels] = useState(config.routineLabels.join(', '));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
title="상시업무 수정"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
routineLabels: labels
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 6),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasRoutineTasks && (
|
||||||
|
<p className="text-sm text-gray-500 leading-relaxed">
|
||||||
|
등록된 기반업무가 있으면 다이아몬드에 업무 제목이 우선 표시됩니다. 아래 라벨은 빈 칸·업무 없을 때 사용됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-500 mb-1.5">표시 라벨 (쉼표 구분, 최대 6개)</label>
|
||||||
|
<input
|
||||||
|
value={labels}
|
||||||
|
onChange={(e) => setLabels(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
|
||||||
|
placeholder="채용, 교육, 소통, 시설, 자산, 행정"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleEditModal({
|
||||||
|
config,
|
||||||
|
quarter,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
config: HubConfig;
|
||||||
|
quarter: string;
|
||||||
|
onSave: (patch: Pick<HubConfig, 'scheduleTitle' | 'scheduleItems'>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { min, max } = quarterDateBounds(quarter);
|
||||||
|
const [title, setTitle] = useState(config.scheduleTitle);
|
||||||
|
const [items, setItems] = useState<HubScheduleItem[]>(() => structuredClone(config.scheduleItems));
|
||||||
|
|
||||||
|
const updateItem = (id: string, patch: Partial<HubScheduleItem>) => {
|
||||||
|
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setItems((prev) => [...prev, { id: String(Date.now()), date: todayIso(), text: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
setItems((prev) => (prev.length <= 1 ? prev : prev.filter((item) => item.id !== id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
title="분기 주요 일정 수정"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const valid = items.filter((item) => item.date && item.text.trim());
|
||||||
|
onSave({
|
||||||
|
scheduleTitle: title.trim() || '분기 주요 일정',
|
||||||
|
scheduleItems: sortScheduleItems(valid),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-500 mb-1.5">제목</label>
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-bold text-gray-500">일정 항목 (날짜 · 내용)</label>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={item.date}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
onChange={(e) => updateItem(item.id, { date: e.target.value })}
|
||||||
|
className="shrink-0 w-[140px] border border-gray-200 rounded-lg px-2 py-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => updateItem(item.id, { text: e.target.value })}
|
||||||
|
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-3 py-2 text-sm"
|
||||||
|
placeholder="일정 내용"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
className="shrink-0 px-2 text-gray-400 hover:text-red-500"
|
||||||
|
title="항목 삭제"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addItem}
|
||||||
|
className="text-sm font-semibold text-emerald-700 hover:text-emerald-800"
|
||||||
|
>
|
||||||
|
+ 항목 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSectionContextMenu(
|
||||||
|
e: React.MouseEvent,
|
||||||
|
section: HubEditSection,
|
||||||
|
setCtxMenu: (menu: { x: number; y: number; section: HubEditSection } | null) => void,
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCtxMenu({ x: e.clientX, y: e.clientY, section });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HubColumn({ routineTasks, quarter = '2026-Q2', onSelectRoutine }: HubColumnProps) {
|
||||||
|
const { config, setConfig } = useHubConfig();
|
||||||
|
const [editSection, setEditSection] = useState<HubEditSection | null>(null);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; section: HubEditSection } | null>(null);
|
||||||
|
const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({
|
||||||
|
id: 'drop::routine::hub',
|
||||||
|
});
|
||||||
|
|
||||||
|
const routineLabels =
|
||||||
|
routineTasks.length > 0
|
||||||
|
? routineTasks.slice(0, 6).map((t) => ({ label: t.title, task: t }))
|
||||||
|
: config.routineLabels.map((label) => ({ label, task: null as Task | null }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hub-column" id="hub-column">
|
||||||
|
<div
|
||||||
|
className="hub-box hub-box--message"
|
||||||
|
onContextMenu={(e) => openSectionContextMenu(e, 'slogan', setCtxMenu)}
|
||||||
|
>
|
||||||
|
<div className="hub-postit-stack">
|
||||||
|
<div className="hub-postit-sheet hub-postit-sheet--back" aria-hidden="true" />
|
||||||
|
<div className="hub-postit-sheet hub-postit-sheet--front">
|
||||||
|
<span className="hub-postit-pin" aria-hidden="true" />
|
||||||
|
<span className="hub-postit-fold" aria-hidden="true" />
|
||||||
|
<div className="hub-postit-content">
|
||||||
|
<div className="hub-postit-header">
|
||||||
|
<div className="hub-box-title-row">
|
||||||
|
<span className="hub-message-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 4v5" />
|
||||||
|
<path d="M12 15v5" />
|
||||||
|
<path d="M4 12h5" />
|
||||||
|
<path d="M15 12h5" />
|
||||||
|
<path d="M18 5v2" />
|
||||||
|
<path d="M17 6h2" />
|
||||||
|
<path d="M5 17v2" />
|
||||||
|
<path d="M4 18h2" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="board-project-title">{config.sloganTitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hub-postit-message">
|
||||||
|
{config.sloganLines.filter(Boolean).map((line, i) => (
|
||||||
|
<p key={i} className="board-project-desc">
|
||||||
|
{line}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hub-diamond-wrap"
|
||||||
|
id="hub-diamond-wrap"
|
||||||
|
onContextMenu={(e) => openSectionContextMenu(e, 'routine', setCtxMenu)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={setRoutineDropRef}
|
||||||
|
className={`hub-diamond ${isRoutineOver ? 'is-over' : ''}`}
|
||||||
|
id="hub-diamond"
|
||||||
|
>
|
||||||
|
<div className="hub-diamond-inner">
|
||||||
|
<div className="hub-diamond-head">
|
||||||
|
<span className="hub-diamond-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="m17 2 4 4-4 4" />
|
||||||
|
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
|
||||||
|
<path d="m7 22-4-4 4-4" />
|
||||||
|
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="board-project-title">상시업무</div>
|
||||||
|
</div>
|
||||||
|
<div className="hub-diamond-divider" aria-hidden="true" />
|
||||||
|
<div className="hub-routine-grid">
|
||||||
|
{routineLabels.map(({ label, task }, i) => (
|
||||||
|
<button
|
||||||
|
key={task?.id ?? `label-${i}`}
|
||||||
|
type="button"
|
||||||
|
className="hub-routine-item"
|
||||||
|
onClick={() => task && onSelectRoutine?.(task)}
|
||||||
|
onContextMenu={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hub-box hub-box--focus"
|
||||||
|
onContextMenu={(e) => openSectionContextMenu(e, 'schedule', setCtxMenu)}
|
||||||
|
>
|
||||||
|
<div className="hub-schedule-planner">
|
||||||
|
<div className="hub-schedule-head">
|
||||||
|
<span className="hub-schedule-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M16 2v4" />
|
||||||
|
<path d="M8 2v4" />
|
||||||
|
<path d="M3 10h18" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="board-project-title">{config.scheduleTitle}</div>
|
||||||
|
</div>
|
||||||
|
<HubScheduleCarousel items={config.scheduleItems} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
icon: '✏',
|
||||||
|
label: '수정',
|
||||||
|
onClick: () => setEditSection(ctxMenu.section),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editSection === 'slogan' && (
|
||||||
|
<SloganEditModal
|
||||||
|
config={config}
|
||||||
|
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
|
||||||
|
onClose={() => setEditSection(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editSection === 'routine' && (
|
||||||
|
<RoutineEditModal
|
||||||
|
config={config}
|
||||||
|
hasRoutineTasks={routineTasks.length > 0}
|
||||||
|
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
|
||||||
|
onClose={() => setEditSection(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editSection === 'schedule' && (
|
||||||
|
<ScheduleEditModal
|
||||||
|
config={config}
|
||||||
|
quarter={quarter}
|
||||||
|
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
|
||||||
|
onClose={() => setEditSection(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
frontend/src/components/dashboard/HubScheduleCarousel.tsx
Normal file
95
frontend/src/components/dashboard/HubScheduleCarousel.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { HubScheduleItem } from '../../lib/hubConfig';
|
||||||
|
import {
|
||||||
|
findScheduleIndexForToday,
|
||||||
|
formatScheduleDateLabel,
|
||||||
|
isSchedulePast,
|
||||||
|
isScheduleToday,
|
||||||
|
scheduleWindowStart,
|
||||||
|
sortScheduleItems,
|
||||||
|
} from '../../lib/hubSchedule';
|
||||||
|
|
||||||
|
const VISIBLE_COUNT = 3;
|
||||||
|
|
||||||
|
interface HubScheduleCarouselProps {
|
||||||
|
items: HubScheduleItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HubScheduleCarousel({ items }: HubScheduleCarouselProps) {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const todayIndex = useMemo(() => findScheduleIndexForToday(sorted), [sorted]);
|
||||||
|
const initialStart = useMemo(
|
||||||
|
() => scheduleWindowStart(sorted.length, todayIndex, VISIBLE_COUNT),
|
||||||
|
[sorted.length, todayIndex],
|
||||||
|
);
|
||||||
|
const [startIndex, setStartIndex] = useState(initialStart);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStartIndex(initialStart);
|
||||||
|
}, [initialStart, sorted.length]);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="hub-schedule-viewport hub-schedule-viewport--empty">
|
||||||
|
<p className="board-project-desc hub-schedule-empty">등록된 일정 없음</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxStart = Math.max(0, sorted.length - VISIBLE_COUNT);
|
||||||
|
const canPrev = startIndex > 0;
|
||||||
|
const canNext = startIndex < maxStart;
|
||||||
|
|
||||||
|
const stepPrev = () => setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
|
||||||
|
const stepNext = () => setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
|
||||||
|
|
||||||
|
const visible = sorted.slice(startIndex, startIndex + VISIBLE_COUNT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hub-schedule-viewport">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hub-schedule-nav hub-schedule-nav--prev"
|
||||||
|
disabled={!canPrev}
|
||||||
|
onClick={stepPrev}
|
||||||
|
onContextMenu={(e) => e.stopPropagation()}
|
||||||
|
aria-label="이전 일정"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className="hub-schedule-list hub-list">
|
||||||
|
{visible.map((item) => {
|
||||||
|
const past = isSchedulePast(item.date);
|
||||||
|
const today = isScheduleToday(item.date);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="hub-schedule-date board-project-desc">
|
||||||
|
{formatScheduleDateLabel(item.date)}
|
||||||
|
</span>
|
||||||
|
<span className="board-project-desc">{item.text}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hub-schedule-nav hub-schedule-nav--next"
|
||||||
|
disabled={!canNext}
|
||||||
|
onClick={stepNext}
|
||||||
|
onContextMenu={(e) => e.stopPropagation()}
|
||||||
|
aria-label="다음 일정"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ export function MemberTaskTooltip({
|
|||||||
const badge = taskStatusBadge(task);
|
const badge = taskStatusBadge(task);
|
||||||
const isActive = activeProjectId === task.id;
|
const isActive = activeProjectId === task.id;
|
||||||
const subtitle = taskSubtitle(task);
|
const subtitle = taskSubtitle(task);
|
||||||
|
const pmName = task.pmMember?.name ?? '미정';
|
||||||
|
const assigneeNames = task.assigneeMembers?.length
|
||||||
|
? task.assigneeMembers.map((m) => m.name).join(', ')
|
||||||
|
: '미정';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,15 +62,11 @@ export function MemberTaskTooltip({
|
|||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="tooltip-project-detail">
|
<div className="tooltip-project-detail">
|
||||||
<span>
|
<span>
|
||||||
PM: <strong>{task.pmMember?.name ?? '미정'}</strong>
|
PM: <strong>{pmName}</strong>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="tooltip-detail-sep" aria-hidden>·</span>
|
||||||
<span>
|
<span>
|
||||||
담당자:{' '}
|
담당자: <strong>{assigneeNames}</strong>
|
||||||
<strong>
|
|
||||||
{task.assigneeMembers?.length
|
|
||||||
? task.assigneeMembers.map((m) => m.name).join(', ')
|
|
||||||
: '미정'}
|
|
||||||
</strong>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,14 +4,7 @@ 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';
|
||||||
|
import { DonutGauge } from './DonutGauge';
|
||||||
const STATUS_DOT: Record<string, string> = {
|
|
||||||
IN_PROGRESS: 'ongoing',
|
|
||||||
REVIEW: 'hold',
|
|
||||||
TODO: 'hold',
|
|
||||||
CANCELLED: 'hold',
|
|
||||||
DONE: 'done',
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmtDate(iso: string | null | undefined): string {
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -32,51 +25,6 @@ function firstDescriptionLine(text: string | null | undefined): string {
|
|||||||
return line ?? '';
|
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({
|
||||||
@@ -87,6 +35,7 @@ export function SortableTaskCard({
|
|||||||
task: Task;
|
task: Task;
|
||||||
variant?: 'project' | 'routine';
|
variant?: 'project' | 'routine';
|
||||||
sectionOptions?: SectionOption[];
|
sectionOptions?: SectionOption[];
|
||||||
|
accent?: string;
|
||||||
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 });
|
||||||
@@ -155,9 +104,8 @@ export function TaskCard({
|
|||||||
onKeyDown: dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined,
|
onKeyDown: dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dotClass = statusDotClass(task.status);
|
|
||||||
|
|
||||||
if (variant === 'routine') {
|
if (variant === 'routine') {
|
||||||
|
const descLine = firstDescriptionLine(task.description);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={dragRef}
|
ref={dragRef}
|
||||||
@@ -168,8 +116,8 @@ export function TaskCard({
|
|||||||
className="board-routine-item"
|
className="board-routine-item"
|
||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
>
|
>
|
||||||
<span className={`board-status-dot board-status-dot--${dotClass}`} aria-hidden />
|
<span className="board-project-title">{task.title}</span>
|
||||||
<span className="board-routine-name">{task.title}</span>
|
{descLine && <p className="board-project-desc">• {descLine}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -179,33 +127,45 @@ export function TaskCard({
|
|||||||
const showProgress = task.showProgress !== false;
|
const showProgress = task.showProgress !== false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<article
|
||||||
ref={dragRef}
|
ref={dragRef}
|
||||||
style={dragStyle}
|
style={dragStyle}
|
||||||
{...dragAttributes}
|
{...dragAttributes}
|
||||||
data-task-card="true"
|
data-task-card="true"
|
||||||
data-task-id={task.id}
|
data-task-id={task.id}
|
||||||
className="board-project-card"
|
className="project-sub-card"
|
||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
>
|
>
|
||||||
<div className="board-project-top">
|
<div className="project-sub-body">
|
||||||
<div className="board-project-main">
|
<div className="project-fields">
|
||||||
<div className="board-project-title-row">
|
<div className="project-sub-title">{task.title}</div>
|
||||||
<span className={`board-status-dot board-status-dot--${dotClass}`} aria-hidden />
|
{dateRange && (
|
||||||
<span className="board-project-title">{task.title}</span>
|
<div className="project-field">
|
||||||
</div>
|
<span className="project-field-label">작업 기간</span>
|
||||||
{dateRange && <p className="board-project-date">{dateRange}</p>}
|
<span className="project-field-value">{dateRange}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{descLine && (
|
||||||
|
<div className="project-field">
|
||||||
|
<span className="project-field-label">주요 내용</span>
|
||||||
|
<span className="project-field-value">{descLine}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.showIssue && task.issueNote && (
|
||||||
|
<div className="project-field">
|
||||||
|
<span className="project-field-label">이슈</span>
|
||||||
|
<span className="project-field-value" style={{ color: '#c0392b' }}>
|
||||||
|
{task.issueNote}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showProgress && <SemiCircleGauge value={task.progress} />}
|
{showProgress && (
|
||||||
|
<div className="progress-col">
|
||||||
|
<DonutGauge task={task} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
{descLine && (
|
|
||||||
<p className="board-project-desc">• {descLine}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.showIssue && task.issueNote && (
|
|
||||||
<p className="board-project-issue">▶ {task.issueNote}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
234
frontend/src/hooks/useBoardConnectors.ts
Normal file
234
frontend/src/hooks/useBoardConnectors.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relBox(el: Element, parent: Element): Box {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const p = parent.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: r.left - p.left,
|
||||||
|
top: r.top - p.top,
|
||||||
|
right: r.right - p.left,
|
||||||
|
bottom: r.bottom - p.top,
|
||||||
|
cx: r.left - p.left + r.width / 2,
|
||||||
|
cy: r.top - p.top + r.height / 2,
|
||||||
|
width: r.width,
|
||||||
|
height: r.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap(n: number) {
|
||||||
|
return Math.round(n * 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointsToPath(points: Point[]) {
|
||||||
|
return points
|
||||||
|
.map((p, i) => `${i === 0 ? 'M' : 'L'}${snap(p.x).toFixed(1)} ${snap(p.y).toFixed(1)}`)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function diamondVertex(cx: number, cy: number, size: number, vertex: 'top' | 'right' | 'bottom' | 'left'): Point {
|
||||||
|
const r = size / Math.SQRT2;
|
||||||
|
if (vertex === 'top') return { x: cx, y: cy - r };
|
||||||
|
if (vertex === 'right') return { x: cx + r, y: cy };
|
||||||
|
if (vertex === 'bottom') return { x: cx, y: cy + r };
|
||||||
|
return { x: cx - r, y: cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function diamondEdgeMidpoint(cx: number, cy: number, size: number, edge: string): Point {
|
||||||
|
const d = size / (2 * Math.SQRT2);
|
||||||
|
if (edge === 'top-left') return { x: cx - d, y: cy - d };
|
||||||
|
if (edge === 'top-right') return { x: cx + d, y: cy - d };
|
||||||
|
if (edge === 'bottom-left') return { x: cx - d, y: cy + d };
|
||||||
|
return { x: cx + d, y: cy + d };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardEdgeAnchor(cardEl: Element, layout: Element, side: 'left' | 'right', y: number): Point {
|
||||||
|
const cardBox = relBox(cardEl, layout);
|
||||||
|
const clampedY = Math.max(cardBox.top + 10, Math.min(cardBox.bottom - 10, y));
|
||||||
|
return { x: side === 'right' ? cardBox.right : cardBox.left, y: clampedY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACE_LINKS = [
|
||||||
|
{ edge: 'top-left', card: '.dept-card--hrm', side: 'right' as const, vert: 'top' as const, knee: 'left' as const },
|
||||||
|
{ edge: 'top-right', card: '.dept-card--hrd', side: 'left' as const, vert: 'top' as const, knee: 'right' as const },
|
||||||
|
{ edge: 'bottom-left', card: '.dept-card--ex', side: 'right' as const, vert: 'bottom' as const, knee: 'left' as const },
|
||||||
|
{ edge: 'bottom-right', card: '.dept-card--ga', side: 'left' as const, vert: 'bottom' as const, knee: 'right' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildBentPath(
|
||||||
|
cardAnchor: Point,
|
||||||
|
edgeMid: Point,
|
||||||
|
side: 'left' | 'right',
|
||||||
|
kneeX: number,
|
||||||
|
approachX: number,
|
||||||
|
): Point[] {
|
||||||
|
const minLeg = 18;
|
||||||
|
let x1 = kneeX;
|
||||||
|
if (side === 'right') {
|
||||||
|
if (x1 < cardAnchor.x + minLeg) x1 = cardAnchor.x + minLeg;
|
||||||
|
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
|
||||||
|
}
|
||||||
|
if (x1 > cardAnchor.x - minLeg) x1 = cardAnchor.x - minLeg;
|
||||||
|
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorYForSymmetricFace(faceY: number, runX: number, vert: 'top' | 'bottom') {
|
||||||
|
return vert === 'top' ? faceY + runX : faceY - runX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null) {
|
||||||
|
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
|
||||||
|
const wrap = diamond.parentElement;
|
||||||
|
if (!wrap) return;
|
||||||
|
const rowW = wrap.clientWidth;
|
||||||
|
const rowH = wrap.clientHeight;
|
||||||
|
const fitInRow = 1.04 / Math.SQRT2;
|
||||||
|
let size = Math.min(rowW * fitInRow, rowH * fitInRow);
|
||||||
|
let scale = 0.9;
|
||||||
|
const v = getComputedStyle(hubColumn).getPropertyValue('--hub-diamond-scale').trim();
|
||||||
|
if (v) scale = parseFloat(v) || scale;
|
||||||
|
size = Math.max(Math.floor(size * scale), Math.floor(150 * scale));
|
||||||
|
diamond.style.width = `${size}px`;
|
||||||
|
diamond.style.height = `${size}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardConnectors(enabled = true) {
|
||||||
|
const lineGroupRef = useRef<SVGGElement>(null);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const drawConnectors = () => {
|
||||||
|
const layout = document.querySelector('.board-layout');
|
||||||
|
const diamond = document.getElementById('hub-diamond');
|
||||||
|
const hubColumn = document.getElementById('hub-column');
|
||||||
|
const lineGroup = lineGroupRef.current;
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!layout || !diamond || !lineGroup || !svg) return;
|
||||||
|
|
||||||
|
fitDiamond(hubColumn, diamond);
|
||||||
|
|
||||||
|
if (window.innerWidth <= 1200) {
|
||||||
|
lineGroup.innerHTML = '';
|
||||||
|
svg.removeAttribute('viewBox');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutBox = layout.getBoundingClientRect();
|
||||||
|
const diamondBox = relBox(diamond, layout);
|
||||||
|
const diamondSize = diamond.offsetWidth;
|
||||||
|
const { cx, cy } = diamondBox;
|
||||||
|
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
|
||||||
|
lineGroup.innerHTML = '';
|
||||||
|
|
||||||
|
const topV = diamondVertex(cx, cy, diamondSize, 'top');
|
||||||
|
const bottomV = diamondVertex(cx, cy, diamondSize, 'bottom');
|
||||||
|
|
||||||
|
const msgBox = document.querySelector('.hub-postit-sheet--front');
|
||||||
|
const focusBox = document.querySelector('.hub-schedule-planner');
|
||||||
|
|
||||||
|
const hubBox = hubColumn ? relBox(hubColumn, layout) : null;
|
||||||
|
const hrmBox = document.querySelector('.dept-card--hrm');
|
||||||
|
const exBox = document.querySelector('.dept-card--ex');
|
||||||
|
const hrdBox = document.querySelector('.dept-card--hrd');
|
||||||
|
const gaBox = document.querySelector('.dept-card--ga');
|
||||||
|
const kneeXs = { left: 0, right: 0 };
|
||||||
|
|
||||||
|
if (hubBox && hrmBox && exBox) {
|
||||||
|
const innerLeft = Math.max(relBox(hrmBox, layout).right, relBox(exBox, layout).right);
|
||||||
|
kneeXs.left = (innerLeft + hubBox.left) / 2;
|
||||||
|
}
|
||||||
|
if (hubBox && hrdBox && gaBox) {
|
||||||
|
const innerRight = Math.min(relBox(hrdBox, layout).left, relBox(gaBox, layout).left);
|
||||||
|
kneeXs.right = (innerRight + hubBox.right) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = diamondSize / (2 * Math.SQRT2);
|
||||||
|
const approachGap = 32;
|
||||||
|
const leftApproachX = cx - d - approachGap;
|
||||||
|
const rightApproachX = cx + d + approachGap;
|
||||||
|
const leftRunX = Math.max(12, leftApproachX - kneeXs.left);
|
||||||
|
const rightRunX = Math.max(12, kneeXs.right - rightApproachX);
|
||||||
|
|
||||||
|
const appendPath = (points: Point[]) => {
|
||||||
|
const path = document.createElementNS(SVG_NS, 'path');
|
||||||
|
path.setAttribute('d', pointsToPath(points));
|
||||||
|
path.setAttribute('stroke', '#b0bcc8');
|
||||||
|
path.setAttribute('stroke-width', '2.5');
|
||||||
|
path.setAttribute('opacity', '0.85');
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
lineGroup.appendChild(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
FACE_LINKS.forEach((link) => {
|
||||||
|
const cardEl = document.querySelector(link.card);
|
||||||
|
if (!cardEl) return;
|
||||||
|
const edgeMid = diamondEdgeMidpoint(cx, cy, diamondSize, link.edge);
|
||||||
|
const runX = link.knee === 'left' ? leftRunX : rightRunX;
|
||||||
|
const approachX = link.knee === 'left' ? leftApproachX : rightApproachX;
|
||||||
|
const anchorY = anchorYForSymmetricFace(edgeMid.y, runX, link.vert);
|
||||||
|
const cardAnchor = cardEdgeAnchor(cardEl, layout, link.side, anchorY);
|
||||||
|
appendPath(buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (msgBox) {
|
||||||
|
const msg = relBox(msgBox, layout);
|
||||||
|
appendPath([topV, { x: cx, y: msg.bottom }]);
|
||||||
|
}
|
||||||
|
if (focusBox) {
|
||||||
|
const focus = relBox(focusBox, layout);
|
||||||
|
appendPath([bottomV, { x: cx, y: focus.top }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleDraw = () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(drawConnectors, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', scheduleDraw);
|
||||||
|
drawConnectors();
|
||||||
|
|
||||||
|
if (document.fonts?.ready) {
|
||||||
|
document.fonts.ready.then(() => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(drawConnectors));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutEl = document.querySelector('.board-layout');
|
||||||
|
let ro: ResizeObserver | undefined;
|
||||||
|
if (layoutEl && typeof ResizeObserver !== 'undefined') {
|
||||||
|
ro = new ResizeObserver(scheduleDraw);
|
||||||
|
ro.observe(layoutEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', scheduleDraw);
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
ro?.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { svgRef, lineGroupRef };
|
||||||
|
}
|
||||||
@@ -661,7 +661,7 @@ body,
|
|||||||
left: auto;
|
left: auto;
|
||||||
grid-area: tasks;
|
grid-area: tasks;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 2px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
@@ -672,6 +672,14 @@ body,
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.team-tree-scroll.show-all-tooltips .member-tooltip.is-static {
|
||||||
|
grid-area: tooltip;
|
||||||
|
align-self: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 8px 12px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.tooltip-header {
|
.tooltip-header {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
@@ -757,18 +765,33 @@ body,
|
|||||||
|
|
||||||
.tooltip-project-detail {
|
.tooltip-project-detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
|
gap: 4px 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-tooltip.is-static .tooltip-project-detail {
|
||||||
|
color: #5a7a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-tooltip:not(.is-static) .tooltip-project-detail {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #ffffff18;
|
background: #ffffff18;
|
||||||
font-size: 12px;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-tooltip.is-static .tooltip-project-detail {
|
.tooltip-detail-sep {
|
||||||
background: #d4ede2;
|
opacity: 0.5;
|
||||||
color: #064b36;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-project-detail strong {
|
.tooltip-project-detail strong {
|
||||||
@@ -814,16 +837,16 @@ body,
|
|||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 업무 전체보기 모드 */
|
/* 업무 전체보기 모드 — 좌: 프로필, 우: 참여 업무 */
|
||||||
.team-tree-scroll.show-all-tooltips .tree-member-card,
|
.team-tree-scroll.show-all-tooltips .tree-member-card,
|
||||||
.team-tree-scroll.show-all-tooltips .tree-leader-card {
|
.team-tree-scroll.show-all-tooltips .tree-leader-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"avatar tooltip"
|
"avatar tooltip"
|
||||||
"info tooltip";
|
"info tooltip";
|
||||||
grid-template-columns: 96px 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
align-items: flex-start;
|
align-items: start;
|
||||||
gap: 12px 18px;
|
gap: 10px 16px;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: unset;
|
min-height: unset;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
@@ -1606,7 +1629,7 @@ body,
|
|||||||
|
|
||||||
.board-project-list {
|
.board-project-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1776,12 +1799,31 @@ body,
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 기반업무 — 3부문 동일: 약 4줄 높이 고정, 초과 시 스크롤 */
|
||||||
.board-routine-section {
|
.board-routine-section {
|
||||||
flex-shrink: 0;
|
--routine-row-h: 40px;
|
||||||
|
--routine-visible-rows: 4;
|
||||||
|
--routine-row-gap: 6px;
|
||||||
|
--routine-pad-y: 28px;
|
||||||
|
flex: 0 0 calc(
|
||||||
|
var(--routine-row-h) * var(--routine-visible-rows) +
|
||||||
|
var(--routine-row-gap) * (var(--routine-visible-rows) - 1) +
|
||||||
|
var(--routine-pad-y) + 2px
|
||||||
|
);
|
||||||
|
height: calc(
|
||||||
|
var(--routine-row-h) * var(--routine-visible-rows) +
|
||||||
|
var(--routine-row-gap) * (var(--routine-visible-rows) - 1) +
|
||||||
|
var(--routine-pad-y) + 2px
|
||||||
|
);
|
||||||
|
max-height: calc(
|
||||||
|
var(--routine-row-h) * var(--routine-visible-rows) +
|
||||||
|
var(--routine-row-gap) * (var(--routine-visible-rows) - 1) +
|
||||||
|
var(--routine-pad-y) + 2px
|
||||||
|
);
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 200px;
|
overflow: hidden;
|
||||||
max-height: 42%;
|
|
||||||
padding: 12px 16px 16px;
|
padding: 12px 16px 16px;
|
||||||
border-top: 2px solid #c8dfd5;
|
border-top: 2px solid #c8dfd5;
|
||||||
transition: background 0.2s, filter 0.2s;
|
transition: background 0.2s, filter 0.2s;
|
||||||
@@ -1794,11 +1836,12 @@ body,
|
|||||||
|
|
||||||
.board-routine-list {
|
.board-routine-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--routine-row-gap, 6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-routine-empty {
|
.board-routine-empty {
|
||||||
@@ -1808,16 +1851,27 @@ body,
|
|||||||
.board-routine-item {
|
.board-routine-item {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
flex-shrink: 0;
|
||||||
gap: 8px;
|
padding: 10px 12px;
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #c8dfd5;
|
border: 1px solid #c8dfd5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
transition: background 0.2s, transform 0.2s, border-color 0.2s;
|
transition: background 0.2s, transform 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-routine-item .board-project-title-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-routine-item .board-status-dot {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-routine-item .board-project-desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.board-routine-item:hover {
|
.board-routine-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
border-color: #a8cfc0;
|
border-color: #a8cfc0;
|
||||||
@@ -1828,21 +1882,6 @@ body,
|
|||||||
cursor: grabbing;
|
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-project-list::-webkit-scrollbar,
|
||||||
.board-routine-list::-webkit-scrollbar {
|
.board-routine-list::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
74
frontend/src/lib/boardLayout.ts
Normal file
74
frontend/src/lib/boardLayout.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { SectionKey } from './sections';
|
||||||
|
import { taskBelongsToSection } from './sections';
|
||||||
|
|
||||||
|
/** 4분면 보드 슬롯 (preview 레이아웃) */
|
||||||
|
export type BoardSlotId = 'hrm' | 'hrd' | 'ex' | 'ga';
|
||||||
|
|
||||||
|
export interface BoardSlotConfig {
|
||||||
|
id: BoardSlotId;
|
||||||
|
cssClass: `dept-card--${BoardSlotId}`;
|
||||||
|
/** API 컬럼 키 — null이면 더미 슬롯(로컬 헤더·section 문자열) */
|
||||||
|
sectionKey: SectionKey | null;
|
||||||
|
/** 더미 슬롯·API 미연동 시 task.section 값 */
|
||||||
|
dummySection?: string;
|
||||||
|
defaultTitle: string;
|
||||||
|
defaultTitleEn: string;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOARD_SLOTS: BoardSlotConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'hrm',
|
||||||
|
cssClass: 'dept-card--hrm',
|
||||||
|
sectionKey: '인사관리',
|
||||||
|
defaultTitle: '인사관리',
|
||||||
|
defaultTitleEn: 'HRM',
|
||||||
|
accent: '#29724f',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hrd',
|
||||||
|
cssClass: 'dept-card--hrd',
|
||||||
|
sectionKey: '학습성장',
|
||||||
|
defaultTitle: '인재육성',
|
||||||
|
defaultTitleEn: 'HRD',
|
||||||
|
accent: '#37a184',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ex',
|
||||||
|
cssClass: 'dept-card--ex',
|
||||||
|
sectionKey: null,
|
||||||
|
dummySection: '조직문화',
|
||||||
|
defaultTitle: '조직문화',
|
||||||
|
defaultTitleEn: 'EX',
|
||||||
|
accent: '#4a9480',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ga',
|
||||||
|
cssClass: 'dept-card--ga',
|
||||||
|
sectionKey: '운영관리',
|
||||||
|
defaultTitle: '총무관리',
|
||||||
|
defaultTitleEn: 'GA',
|
||||||
|
accent: '#0d4a38',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function slotSectionLabel(slot: BoardSlotConfig): string {
|
||||||
|
return slot.sectionKey ?? slot.dummySection ?? slot.defaultTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taskBelongsToSlot(
|
||||||
|
taskSection: string | null | undefined,
|
||||||
|
slot: BoardSlotConfig,
|
||||||
|
): boolean {
|
||||||
|
if (!taskSection) return false;
|
||||||
|
const label = slotSectionLabel(slot);
|
||||||
|
if (taskSection.trim() === label) return true;
|
||||||
|
if (slot.sectionKey && taskBelongsToSection(taskSection, slot.sectionKey)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOARD_SLOT_ORDER: BoardSlotId[] = ['hrm', 'hrd', 'ex', 'ga'];
|
||||||
|
|
||||||
|
export function getBoardSlot(id: BoardSlotId): BoardSlotConfig {
|
||||||
|
return BOARD_SLOTS.find((s) => s.id === id)!;
|
||||||
|
}
|
||||||
90
frontend/src/lib/hubConfig.ts
Normal file
90
frontend/src/lib/hubConfig.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { migrateScheduleItem, quarterDateBounds } from './hubSchedule';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'eene-quarter-hub-config-v1';
|
||||||
|
|
||||||
|
export interface HubScheduleItem {
|
||||||
|
id: string;
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
date: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubConfig {
|
||||||
|
sloganTitle: string;
|
||||||
|
sloganLines: string[];
|
||||||
|
scheduleTitle: string;
|
||||||
|
scheduleItems: HubScheduleItem[];
|
||||||
|
routineLabels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HUB_CONFIG: HubConfig = {
|
||||||
|
sloganTitle: '분기 슬로건',
|
||||||
|
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
|
||||||
|
scheduleTitle: '분기 주요 일정',
|
||||||
|
scheduleItems: [
|
||||||
|
{ id: '1', date: '2026-04-01', text: '상반기 채용·온보딩' },
|
||||||
|
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
|
||||||
|
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
|
||||||
|
],
|
||||||
|
routineLabels: ['채용', '교육', '소통', '시설', '자산', '행정'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function migrateConfig(raw: Record<string, unknown>): HubConfig {
|
||||||
|
const { year } = quarterDateBounds('2026-Q2');
|
||||||
|
const scheduleItems = Array.isArray(raw.scheduleItems)
|
||||||
|
? (raw.scheduleItems as (HubScheduleItem & { month?: string })[]).map((item) =>
|
||||||
|
migrateScheduleItem(item, year),
|
||||||
|
)
|
||||||
|
: DEFAULT_HUB_CONFIG.scheduleItems;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sloganTitle: (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle,
|
||||||
|
sloganLines: (raw.sloganLines as string[]) ?? DEFAULT_HUB_CONFIG.sloganLines,
|
||||||
|
scheduleTitle: (raw.scheduleTitle as string) ?? DEFAULT_HUB_CONFIG.scheduleTitle,
|
||||||
|
scheduleItems,
|
||||||
|
routineLabels: (raw.routineLabels as string[]) ?? DEFAULT_HUB_CONFIG.routineLabels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig(): HubConfig {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_HUB_CONFIG;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return DEFAULT_HUB_CONFIG;
|
||||||
|
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...JSON.parse(raw) });
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_HUB_CONFIG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(config: HubConfig) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHubConfig() {
|
||||||
|
const [config, setConfigState] = useState<HubConfig>(loadConfig);
|
||||||
|
|
||||||
|
const setConfig = useCallback((patch: Partial<HubConfig> | ((prev: HubConfig) => HubConfig)) => {
|
||||||
|
setConfigState((prev) => {
|
||||||
|
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch };
|
||||||
|
saveConfig(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetConfig = useCallback(() => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setConfigState(DEFAULT_HUB_CONFIG);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === STORAGE_KEY) setConfigState(loadConfig());
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => window.removeEventListener('storage', onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { config, setConfig, resetConfig };
|
||||||
|
}
|
||||||
130
frontend/src/lib/hubSchedule.ts
Normal file
130
frontend/src/lib/hubSchedule.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import type { HubScheduleItem } from './hubConfig';
|
||||||
|
|
||||||
|
const KOR_MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||||
|
|
||||||
|
export function parseScheduleDate(dateStr: string): Date | null {
|
||||||
|
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return null;
|
||||||
|
const [y, m, d] = dateStr.split('-').map(Number);
|
||||||
|
const dt = new Date(y, m - 1, d);
|
||||||
|
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfDay(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "6월 8일" */
|
||||||
|
export function formatScheduleDateLabel(dateStr: string): string {
|
||||||
|
const dt = parseScheduleDate(dateStr);
|
||||||
|
if (!dt) return dateStr;
|
||||||
|
return `${dt.getMonth() + 1}월 ${dt.getDate()}일`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortScheduleItems(items: HubScheduleItem[]): HubScheduleItem[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const da = parseScheduleDate(a.date)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const db = parseScheduleDate(b.date)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3건 창에서 오늘(포커스) 일정이 가운데 오도록 시작 인덱스 */
|
||||||
|
export function scheduleWindowStart(total: number, focusIndex: number, visible = 3): number {
|
||||||
|
if (total <= visible) return 0;
|
||||||
|
const middle = Math.floor(visible / 2);
|
||||||
|
let start = focusIndex - middle;
|
||||||
|
start = Math.max(0, start);
|
||||||
|
start = Math.min(start, total - visible);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 오늘(또는 가장 가까운 일정) 기준 시작 인덱스 — 과거는 왼쪽, 미래는 오른쪽 */
|
||||||
|
export function findScheduleIndexForToday(items: HubScheduleItem[], today = new Date()): number {
|
||||||
|
const sorted = sortScheduleItems(items.filter((i) => i.date && i.text.trim()));
|
||||||
|
if (sorted.length === 0) return 0;
|
||||||
|
|
||||||
|
const todayMs = startOfDay(today).getTime();
|
||||||
|
|
||||||
|
const exact = sorted.findIndex((item) => {
|
||||||
|
const d = parseScheduleDate(item.date);
|
||||||
|
return d && startOfDay(d).getTime() === todayMs;
|
||||||
|
});
|
||||||
|
if (exact >= 0) return exact;
|
||||||
|
|
||||||
|
const next = sorted.findIndex((item) => {
|
||||||
|
const d = parseScheduleDate(item.date);
|
||||||
|
return d && startOfDay(d).getTime() > todayMs;
|
||||||
|
});
|
||||||
|
if (next >= 0) return next;
|
||||||
|
|
||||||
|
return sorted.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSchedulePast(dateStr: string, today = new Date()): boolean {
|
||||||
|
const d = parseScheduleDate(dateStr);
|
||||||
|
if (!d) return false;
|
||||||
|
return startOfDay(d).getTime() < startOfDay(today).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isScheduleToday(dateStr: string, today = new Date()): boolean {
|
||||||
|
const d = parseScheduleDate(dateStr);
|
||||||
|
if (!d) return false;
|
||||||
|
return startOfDay(d).getTime() === startOfDay(today).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레거시 month 필드 → date (2026-Q2 가정) */
|
||||||
|
export function migrateScheduleItem(
|
||||||
|
item: HubScheduleItem & { month?: string },
|
||||||
|
defaultYear = 2026,
|
||||||
|
): HubScheduleItem {
|
||||||
|
if (item.date && parseScheduleDate(item.date)) {
|
||||||
|
return { id: item.id, date: item.date, text: item.text ?? '' };
|
||||||
|
}
|
||||||
|
const legacy = item.month?.trim() ?? '';
|
||||||
|
const monthMatch = legacy.match(/(\d{1,2})\s*월/);
|
||||||
|
if (monthMatch) {
|
||||||
|
const m = Number(monthMatch[1]);
|
||||||
|
if (m >= 1 && m <= 12) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
date: `${defaultYear}-${String(m).padStart(2, '0')}-01`,
|
||||||
|
text: item.text ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < KOR_MONTHS.length; i++) {
|
||||||
|
if (legacy.includes(KOR_MONTHS[i])) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
date: `${defaultYear}-${String(i + 1).padStart(2, '0')}-01`,
|
||||||
|
text: item.text ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: item.id, date: item.date ?? '', text: item.text ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayIso(): string {
|
||||||
|
const t = new Date();
|
||||||
|
return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 분기 문자열(2026-Q2) → date input min/max */
|
||||||
|
export function quarterDateBounds(quarter = '2026-Q2'): { min: string; max: string; year: number } {
|
||||||
|
const m = quarter.match(/^(\d{4})-Q([1-4])$/);
|
||||||
|
if (!m) {
|
||||||
|
const y = new Date().getFullYear();
|
||||||
|
return { min: `${y}-01-01`, max: `${y}-12-31`, year: y };
|
||||||
|
}
|
||||||
|
const year = Number(m[1]);
|
||||||
|
const q = Number(m[2]);
|
||||||
|
const startMonth = (q - 1) * 3 + 1;
|
||||||
|
const endMonth = startMonth + 2;
|
||||||
|
const lastDay = new Date(year, endMonth, 0).getDate();
|
||||||
|
return {
|
||||||
|
min: `${year}-${String(startMonth).padStart(2, '0')}-01`,
|
||||||
|
max: `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`,
|
||||||
|
year,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ export function taskFormToApiPayload(data: TaskFormData): Record<string, unknown
|
|||||||
showStatus: data.showStatus,
|
showStatus: data.showStatus,
|
||||||
showIssue: data.showIssue,
|
showIssue: data.showIssue,
|
||||||
showProgress: data.showProgress,
|
showProgress: data.showProgress,
|
||||||
keywords: data.keywords || null,
|
|
||||||
quarter: data.quarter,
|
quarter: data.quarter,
|
||||||
pmMemberId: data.pmMemberId || null,
|
pmMemberId: data.pmMemberId || null,
|
||||||
assigneeMemberIds: data.assigneeMemberIds,
|
assigneeMemberIds: data.assigneeMemberIds,
|
||||||
|
|||||||
66
frontend/src/lib/taskStatusVisual.ts
Normal file
66
frontend/src/lib/taskStatusVisual.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Task, TaskStatus } from '../types';
|
||||||
|
|
||||||
|
/** DashboardHeader STAT_ACCENT 와 동일 */
|
||||||
|
export const TASK_STAT_COLORS = {
|
||||||
|
IN_PROGRESS: '#10b981',
|
||||||
|
REVIEW: '#ff9f0a',
|
||||||
|
TODO: '#ff9f0a',
|
||||||
|
CANCELLED: '#ff9f0a',
|
||||||
|
DONE: '#b0b0b0',
|
||||||
|
} as const satisfies Record<TaskStatus, string>;
|
||||||
|
|
||||||
|
export interface DonutDisplay {
|
||||||
|
pct: number;
|
||||||
|
ringColor: string;
|
||||||
|
label: string;
|
||||||
|
labelColor: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDonutDisplay(task: Pick<Task, 'status' | 'progress'>): DonutDisplay {
|
||||||
|
const p = Math.min(100, Math.max(0, task.progress));
|
||||||
|
|
||||||
|
switch (task.status) {
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return {
|
||||||
|
pct: p,
|
||||||
|
ringColor: TASK_STAT_COLORS.IN_PROGRESS,
|
||||||
|
label: `${p}%`,
|
||||||
|
labelColor: TASK_STAT_COLORS.IN_PROGRESS,
|
||||||
|
ariaLabel: `진행 ${p}%`,
|
||||||
|
};
|
||||||
|
case 'TODO':
|
||||||
|
return {
|
||||||
|
pct: 0,
|
||||||
|
ringColor: TASK_STAT_COLORS.TODO,
|
||||||
|
label: '대기',
|
||||||
|
labelColor: TASK_STAT_COLORS.TODO,
|
||||||
|
ariaLabel: '대기',
|
||||||
|
};
|
||||||
|
case 'REVIEW':
|
||||||
|
case 'CANCELLED':
|
||||||
|
return {
|
||||||
|
pct: p,
|
||||||
|
ringColor: TASK_STAT_COLORS.REVIEW,
|
||||||
|
label: '보류',
|
||||||
|
labelColor: TASK_STAT_COLORS.REVIEW,
|
||||||
|
ariaLabel: p > 0 ? `보류 ${p}%` : '보류',
|
||||||
|
};
|
||||||
|
case 'DONE':
|
||||||
|
return {
|
||||||
|
pct: 100,
|
||||||
|
ringColor: TASK_STAT_COLORS.DONE,
|
||||||
|
label: '완료',
|
||||||
|
labelColor: TASK_STAT_COLORS.DONE,
|
||||||
|
ariaLabel: '완료',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
pct: p,
|
||||||
|
ringColor: TASK_STAT_COLORS.IN_PROGRESS,
|
||||||
|
label: `${p}%`,
|
||||||
|
labelColor: TASK_STAT_COLORS.IN_PROGRESS,
|
||||||
|
ariaLabel: `진행률 ${p}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,17 @@ export function normalizeTaskType(taskType: string | null | undefined): string {
|
|||||||
return taskType;
|
return taskType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 업무 수정·추가 폼 — 표시 순서·라벨 */
|
||||||
|
export const TASK_TYPE_OPTIONS = [
|
||||||
|
{ value: '실행과제', label: '프로젝트' },
|
||||||
|
{ value: '기반업무', label: '상시업무' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function taskTypeDisplayLabel(taskType: string | null | undefined): string {
|
||||||
|
if (isRoutineTask(taskType)) return '상시업무';
|
||||||
|
return '프로젝트';
|
||||||
|
}
|
||||||
|
|
||||||
/** 실행과제 카드에 표시할 필드 플래그 (기반업무는 모두 숨김) */
|
/** 실행과제 카드에 표시할 필드 플래그 (기반업무는 모두 숨김) */
|
||||||
export function displayFlagsForTaskType(taskType: string | null | undefined) {
|
export function displayFlagsForTaskType(taskType: string | null | undefined) {
|
||||||
const visible = isProjectTask(taskType);
|
const visible = isProjectTask(taskType);
|
||||||
|
|||||||
@@ -87,18 +87,46 @@ export function groupTeamMembers(members: TeamMember[]) {
|
|||||||
return { groups, cellKeys };
|
return { groups, cellKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isTaskPm(memberId: string, task: Task): boolean {
|
||||||
|
return task.pmMember?.id === memberId || task.pmMemberId === memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTaskAssignee(memberId: string, task: Task): boolean {
|
||||||
|
return task.assigneeMembers?.some((a) => a.id === memberId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 해당 팀원이 이 업무에서 맡은 역할 */
|
||||||
|
export function getMemberTaskRole(
|
||||||
|
memberId: string,
|
||||||
|
task: Task,
|
||||||
|
): 'pm' | 'assignee' | 'both' | null {
|
||||||
|
const pm = isTaskPm(memberId, task);
|
||||||
|
const assignee = isTaskAssignee(memberId, task);
|
||||||
|
if (pm && assignee) return 'both';
|
||||||
|
if (pm) return 'pm';
|
||||||
|
if (assignee) return 'assignee';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemberTaskRoleLabel(role: ReturnType<typeof getMemberTaskRole>): string | null {
|
||||||
|
if (role === 'both') return 'PM · 담당';
|
||||||
|
if (role === 'pm') return 'PM';
|
||||||
|
if (role === 'assignee') return '담당';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getMemberTasks(memberId: string, tasks: Task[]): Task[] {
|
export function getMemberTasks(memberId: string, tasks: Task[]): Task[] {
|
||||||
return tasks.filter((t) => {
|
return tasks.filter((t) => {
|
||||||
if (t.status === 'DONE' || t.status === 'CANCELLED') return false;
|
if (t.status === 'DONE' || t.status === 'CANCELLED') return false;
|
||||||
if (t.pmMember?.id === memberId) return true;
|
return isTaskPm(memberId, t) || isTaskAssignee(memberId, t);
|
||||||
return t.assigneeMembers?.some((a) => a.id === memberId) ?? false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
|
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
|
||||||
if (!task) return [];
|
if (!task) return [];
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
if (task.pmMember?.id) ids.push(task.pmMember.id);
|
const pmId = task.pmMember?.id ?? task.pmMemberId;
|
||||||
|
if (pmId) ids.push(pmId);
|
||||||
task.assigneeMembers?.forEach((m) => {
|
task.assigneeMembers?.forEach((m) => {
|
||||||
if (m.id && !ids.includes(m.id)) ids.push(m.id);
|
if (m.id && !ids.includes(m.id)) ids.push(m.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import './styles/quarter-board.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -17,11 +17,14 @@ import { useTeamMembers } from '../hooks/useTeamMembers';
|
|||||||
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
|
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
|
||||||
import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel';
|
import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel';
|
||||||
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
|
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
|
||||||
|
import { HubColumn } from '../components/dashboard/HubColumn';
|
||||||
|
import { BoardConnectors } from '../components/dashboard/BoardConnectors';
|
||||||
|
import { DonutGauge } from '../components/dashboard/DonutGauge';
|
||||||
import { TaskManager } from '../components/dashboard/TaskManager';
|
import { TaskManager } from '../components/dashboard/TaskManager';
|
||||||
import { useSocket } from '../contexts/SocketContext';
|
import { useSocket } from '../contexts/SocketContext';
|
||||||
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen } from '../lib/dualMonitor';
|
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen } from '../lib/dualMonitor';
|
||||||
import { TaskDetailShell } from '../pages/DetailPage';
|
import { TaskDetailShell } from '../pages/DetailPage';
|
||||||
import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType';
|
import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType';
|
||||||
import {
|
import {
|
||||||
DEFAULT_STATUS_FILTERS,
|
DEFAULT_STATUS_FILTERS,
|
||||||
taskMatchesStatusFilters,
|
taskMatchesStatusFilters,
|
||||||
@@ -31,12 +34,17 @@ import {
|
|||||||
} from '../lib/statusFilters';
|
} from '../lib/statusFilters';
|
||||||
import {
|
import {
|
||||||
SECTIONS,
|
SECTIONS,
|
||||||
COLUMN_META,
|
|
||||||
LEGACY_COLUMN_KEYS,
|
LEGACY_COLUMN_KEYS,
|
||||||
taskBelongsToSection,
|
taskBelongsToSection,
|
||||||
normalizeSection,
|
|
||||||
type SectionKey,
|
type SectionKey,
|
||||||
} from '../lib/sections';
|
} from '../lib/sections';
|
||||||
|
import {
|
||||||
|
BOARD_SLOTS,
|
||||||
|
BOARD_SLOT_ORDER,
|
||||||
|
getBoardSlot,
|
||||||
|
slotSectionLabel,
|
||||||
|
taskBelongsToSlot,
|
||||||
|
} from '../lib/boardLayout';
|
||||||
|
|
||||||
const QUARTER = '2026-Q2';
|
const QUARTER = '2026-Q2';
|
||||||
|
|
||||||
@@ -58,6 +66,13 @@ function mergeCardOrders(primary: string | null | undefined, extras: (string | n
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sectionKeyForLabel(label: string): SectionKey | null {
|
||||||
|
for (const s of SECTIONS) {
|
||||||
|
if (label === s || taskBelongsToSection(label, s)) return s;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [activeFilters, setActiveFilters] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
|
const [activeFilters, setActiveFilters] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
|
||||||
const [filtersBeforeIssue, setFiltersBeforeIssue] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
|
const [filtersBeforeIssue, setFiltersBeforeIssue] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
|
||||||
@@ -122,9 +137,17 @@ export default function DashboardPage() {
|
|||||||
if (!colConfigs) return;
|
if (!colConfigs) return;
|
||||||
setColumnOrders((prev) => {
|
setColumnOrders((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
colConfigs.forEach((c) => {
|
BOARD_SLOTS.forEach((slot) => {
|
||||||
if (c.cardOrder && !next[c.key]) {
|
const label = slotSectionLabel(slot);
|
||||||
try { next[c.key] = JSON.parse(c.cardOrder); } catch { /* ignore */ }
|
if (next[label]) return;
|
||||||
|
if (!slot.sectionKey) return;
|
||||||
|
const cfg = colConfigs.find((c) => c.key === slot.sectionKey);
|
||||||
|
if (cfg?.cardOrder) {
|
||||||
|
try {
|
||||||
|
next[label] = JSON.parse(cfg.cardOrder);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@@ -132,16 +155,17 @@ export default function DashboardPage() {
|
|||||||
}, [colConfigs]);
|
}, [colConfigs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
SECTIONS.forEach((section) => {
|
BOARD_SLOTS.forEach((slot) => {
|
||||||
const ids = tasks.filter((t) => taskBelongsToSection(t.section, section)).map((t) => t.id);
|
const label = slotSectionLabel(slot);
|
||||||
|
const ids = tasks.filter((t) => taskBelongsToSlot(t.section, slot)).map((t) => t.id);
|
||||||
setColumnOrders((prev) => {
|
setColumnOrders((prev) => {
|
||||||
const existing = prev[section] ?? [];
|
const existing = prev[label] ?? [];
|
||||||
const merged = [
|
const merged = [
|
||||||
...existing.filter((id) => ids.includes(id)),
|
...existing.filter((id) => ids.includes(id)),
|
||||||
...ids.filter((id) => !existing.includes(id)),
|
...ids.filter((id) => !existing.includes(id)),
|
||||||
];
|
];
|
||||||
if (JSON.stringify(merged) === JSON.stringify(existing)) return prev;
|
if (JSON.stringify(merged) === JSON.stringify(existing)) return prev;
|
||||||
return { ...prev, [section]: merged };
|
return { ...prev, [label]: merged };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
@@ -182,58 +206,74 @@ export default function DashboardPage() {
|
|||||||
const draggedTask = tasks.find((t) => t.id === activeId);
|
const draggedTask = tasks.find((t) => t.id === activeId);
|
||||||
if (!draggedTask) return;
|
if (!draggedTask) return;
|
||||||
|
|
||||||
const srcSection = normalizeSection(draggedTask.section) ?? '인사관리';
|
const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(draggedTask.section, s));
|
||||||
|
const srcLabel = srcSlot ? slotSectionLabel(srcSlot) : (draggedTask.section ?? '인사관리');
|
||||||
|
|
||||||
if (overId.startsWith('drop::')) {
|
if (overId.startsWith('drop::')) {
|
||||||
const parts = overId.split('::');
|
const parts = overId.split('::');
|
||||||
const areaType = parts[1];
|
const areaType = parts[1];
|
||||||
const targetSection = parts[2] as SectionKey;
|
const targetSection = parts[2];
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (targetSection !== srcSection) updateData.section = targetSection;
|
|
||||||
if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) {
|
if (areaType === 'routine' && targetSection === 'hub') {
|
||||||
updateData.taskType = '기반업무';
|
if (!isRoutineTask(draggedTask.taskType)) {
|
||||||
Object.assign(updateData, displayFlagsForTaskType('기반업무'));
|
updateData.taskType = '기반업무';
|
||||||
} else if (areaType === 'project' && isRoutineTask(draggedTask.taskType)) {
|
Object.assign(updateData, displayFlagsForTaskType('기반업무'));
|
||||||
updateData.taskType = '실행과제';
|
}
|
||||||
Object.assign(updateData, displayFlagsForTaskType('실행과제'));
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
patchTask.mutate({ id: activeId, data: updateData });
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (Object.keys(updateData).length > 0) {
|
|
||||||
patchTask.mutate({ id: activeId, data: updateData });
|
if (areaType === 'project') {
|
||||||
|
if (targetSection !== srcLabel) updateData.section = targetSection;
|
||||||
|
if (isRoutineTask(draggedTask.taskType)) {
|
||||||
|
updateData.taskType = '실행과제';
|
||||||
|
Object.assign(updateData, displayFlagsForTaskType('실행과제'));
|
||||||
|
}
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
patchTask.mutate({ id: activeId, data: updateData });
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const overTask = tasks.find((t) => t.id === overId);
|
const overTask = tasks.find((t) => t.id === overId);
|
||||||
if (!overTask) return;
|
if (!overTask) return;
|
||||||
|
|
||||||
const dstSection = normalizeSection(overTask.section) ?? '인사관리';
|
const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(overTask.section, s));
|
||||||
|
const dstLabel = dstSlot ? slotSectionLabel(dstSlot) : (overTask.section ?? '인사관리');
|
||||||
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
|
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
|
||||||
|
|
||||||
if (dstSection !== srcSection || typeChanged) {
|
if (dstLabel !== srcLabel || typeChanged) {
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (dstSection !== srcSection) updateData.section = dstSection;
|
if (dstLabel !== srcLabel) updateData.section = dstLabel;
|
||||||
if (typeChanged) {
|
if (typeChanged) {
|
||||||
const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제';
|
const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제';
|
||||||
updateData.taskType = newType;
|
updateData.taskType = newType;
|
||||||
Object.assign(updateData, displayFlagsForTaskType(newType));
|
Object.assign(updateData, displayFlagsForTaskType(newType));
|
||||||
}
|
}
|
||||||
patchTask.mutate({ id: activeId, data: updateData });
|
patchTask.mutate({ id: activeId, data: updateData });
|
||||||
if (dstSection !== srcSection) return;
|
if (dstLabel !== srcLabel) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = columnOrders[srcSection]
|
const current = columnOrders[srcLabel]
|
||||||
?? tasks.filter((t) => taskBelongsToSection(t.section, srcSection)).map((t) => t.id);
|
?? tasks.filter((t) => t.section === srcLabel).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);
|
||||||
setColumnOrders((prev) => ({ ...prev, [srcSection]: newOrder }));
|
setColumnOrders((prev) => ({ ...prev, [srcLabel]: newOrder }));
|
||||||
|
|
||||||
if (saveTimers.current[srcSection]) clearTimeout(saveTimers.current[srcSection]);
|
const apiKey = sectionKeyForLabel(srcLabel);
|
||||||
saveTimers.current[srcSection] = setTimeout(() => {
|
if (!apiKey) return;
|
||||||
patchColumn.mutate({ section: srcSection, data: { cardOrder: JSON.stringify(newOrder) } });
|
|
||||||
|
if (saveTimers.current[srcLabel]) clearTimeout(saveTimers.current[srcLabel]);
|
||||||
|
saveTimers.current[srcLabel] = setTimeout(() => {
|
||||||
|
patchColumn.mutate({ section: apiKey, data: { cardOrder: JSON.stringify(newOrder) } });
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,6 +290,11 @@ export default function DashboardPage() {
|
|||||||
return taskMatchesStatusFilters(t, activeFilters);
|
return taskMatchesStatusFilters(t, activeFilters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const routineTasks = useMemo(
|
||||||
|
() => filtered.filter((t) => isRoutineTask(t.taskType)),
|
||||||
|
[filtered],
|
||||||
|
);
|
||||||
|
|
||||||
const handleToggleAll = () => {
|
const handleToggleAll = () => {
|
||||||
setIssueFilterActive(false);
|
setIssueFilterActive(false);
|
||||||
setActiveFilters((prev) => toggleAllFilter(prev));
|
setActiveFilters((prev) => toggleAllFilter(prev));
|
||||||
@@ -270,20 +315,21 @@ export default function DashboardPage() {
|
|||||||
setIssueFilterActive(true);
|
setIssueFilterActive(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sectionOptions = SECTIONS.map((s) => ({
|
const sectionOptions = BOARD_SLOTS.map((slot) => ({
|
||||||
value: s,
|
value: slotSectionLabel(slot),
|
||||||
label: colConfigs?.find((c) => c.key === s)?.title ?? COLUMN_META[s].displayTitle,
|
label: colConfigs?.find((c) => c.key === slot.sectionKey)?.title
|
||||||
|
?? slot.defaultTitle,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null;
|
const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null;
|
||||||
|
|
||||||
const ultraWideLayout = viewportWidth >= 3800;
|
const ultraWideLayout = viewportWidth >= 3800;
|
||||||
/** 팝업이 열려 있으면 우측 도킹 숨김 — 팝업 차단 시 같은 페이지 도킹 fallback */
|
|
||||||
const showDockedDetail = !!selectedTaskId && !detailPopupOpen;
|
const showDockedDetail = !!selectedTaskId && !detailPopupOpen;
|
||||||
const detailDocked = showDockedDetail && ultraWideLayout;
|
const detailDocked = showDockedDetail && ultraWideLayout;
|
||||||
|
|
||||||
const handleSelectTask = (taskId: string) => {
|
const handleSelectTask = (taskId: string) => {
|
||||||
setSelectedTaskId(taskId);
|
setSelectedTaskId(taskId);
|
||||||
|
if (teamPanelOpen) setActiveTeamProjectId(taskId);
|
||||||
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then(() => {
|
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then(() => {
|
||||||
setDetailPopupOpen(isDetailWindowOpen());
|
setDetailPopupOpen(isDetailWindowOpen());
|
||||||
});
|
});
|
||||||
@@ -295,9 +341,26 @@ export default function DashboardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderDeptSlot = (slotId: typeof BOARD_SLOT_ORDER[number]) => {
|
||||||
|
const slot = getBoardSlot(slotId);
|
||||||
|
const label = slotSectionLabel(slot);
|
||||||
|
return (
|
||||||
|
<DepartmentColumn
|
||||||
|
key={slot.id}
|
||||||
|
slot={slot}
|
||||||
|
tasks={filtered.filter((t) => taskBelongsToSlot(t.section, slot))}
|
||||||
|
orderedIds={columnOrders[label] ?? []}
|
||||||
|
quarter={QUARTER}
|
||||||
|
onSelectTask={(t) => handleSelectTask(t.id)}
|
||||||
|
sectionOptions={sectionOptions}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-[#eef4f1]">
|
<div className="flex h-screen items-center justify-center bg-[#e9eef2]">
|
||||||
<div className="text-lg text-gray-400">데이터를 불러오는 중...</div>
|
<div className="text-lg text-gray-400">데이터를 불러오는 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -307,7 +370,7 @@ export default function DashboardPage() {
|
|||||||
<div
|
<div
|
||||||
className={`app-shell ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
|
className={`app-shell ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#eef4f1]">
|
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#e9eef2]">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
quarter={QUARTER}
|
quarter={QUARTER}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
@@ -324,6 +387,8 @@ export default function DashboardPage() {
|
|||||||
if (open) {
|
if (open) {
|
||||||
setActiveTeamProjectId(null);
|
setActiveTeamProjectId(null);
|
||||||
setShowAllTeamTasks(false);
|
setShowAllTeamTasks(false);
|
||||||
|
} else if (selectedTaskId) {
|
||||||
|
setActiveTeamProjectId(selectedTaskId);
|
||||||
}
|
}
|
||||||
return !open;
|
return !open;
|
||||||
});
|
});
|
||||||
@@ -347,51 +412,48 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
|
<main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
|
||||||
<DndContext
|
<div className="page-content">
|
||||||
sensors={sensors}
|
<DndContext
|
||||||
collisionDetection={closestCenter}
|
sensors={sensors}
|
||||||
onDragStart={handleDragStart}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={handleDragEnd}
|
onDragStart={handleDragStart}
|
||||||
>
|
onDragEnd={handleDragEnd}
|
||||||
<div className="board-grid">
|
>
|
||||||
{SECTIONS.map((section) => {
|
<div className="board-layout">
|
||||||
const meta = COLUMN_META[section];
|
{renderDeptSlot('hrm')}
|
||||||
return (
|
{renderDeptSlot('hrd')}
|
||||||
<DepartmentColumn
|
<HubColumn
|
||||||
key={section}
|
routineTasks={routineTasks}
|
||||||
title={meta.displayTitle}
|
quarter={QUARTER}
|
||||||
titleEn={meta.titleEn}
|
onSelectRoutine={(t) => handleSelectTask(t.id)}
|
||||||
accent={meta.accent}
|
/>
|
||||||
tasks={filtered.filter((t) => taskBelongsToSection(t.section, section))}
|
{renderDeptSlot('ex')}
|
||||||
orderedIds={columnOrders[section] ?? []}
|
{renderDeptSlot('ga')}
|
||||||
section={section}
|
<BoardConnectors enabled={!teamPanelOpen} />
|
||||||
quarter={QUARTER}
|
</div>
|
||||||
onSelectTask={(t) => handleSelectTask(t.id)}
|
|
||||||
sectionOptions={sectionOptions}
|
|
||||||
teamMembers={teamMembers}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
||||||
{activeTask ? (
|
{activeTask && isProjectTask(activeTask.taskType) ? (
|
||||||
<div className="board-project-card board-project-card--overlay">
|
<article className="project-sub-card board-project-card--overlay">
|
||||||
<div className="board-project-top">
|
<div className="project-sub-body">
|
||||||
<div className="board-project-main">
|
<div className="project-fields">
|
||||||
<div className="board-project-title-row">
|
<div className="project-sub-title">{activeTask.title}</div>
|
||||||
<span className="board-status-dot board-status-dot--ongoing" aria-hidden />
|
|
||||||
<span className="board-project-title">{activeTask.title}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{activeTask.showProgress !== false && (
|
||||||
|
<div className="progress-col">
|
||||||
|
<DonutGauge task={activeTask} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeTask.showProgress !== false && (
|
</article>
|
||||||
<span className="board-gauge-value">{activeTask.progress}%</span>
|
) : activeTask ? (
|
||||||
)}
|
<div className="board-routine-item board-project-card--overlay">
|
||||||
|
<span className="board-project-title">{activeTask.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</DragOverlay>
|
||||||
</DragOverlay>
|
</DndContext>
|
||||||
</DndContext>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{showTaskManager && (
|
{showTaskManager && (
|
||||||
|
|||||||
1278
frontend/src/styles/quarter-board.css
Normal file
1278
frontend/src/styles/quarter-board.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,6 @@ export interface Task {
|
|||||||
showStatus: boolean;
|
showStatus: boolean;
|
||||||
showIssue: boolean;
|
showIssue: boolean;
|
||||||
showProgress: boolean;
|
showProgress: boolean;
|
||||||
keywords: string | null;
|
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
pmMemberId?: string | null;
|
pmMemberId?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user