Initial commit - EENE Dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-05-29 18:07:10 +09:00
commit 22366dde72
64 changed files with 10483 additions and 0 deletions

13
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { BrowserRouter } from 'react-router-dom';
import { AppRouter } from './router';
import { SocketProvider } from './contexts/SocketContext';
export default function App() {
return (
<BrowserRouter>
<SocketProvider>
<AppRouter />
</SocketProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react';
interface MenuItem {
label: string;
icon: string;
onClick: () => void;
danger?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [onClose]);
const adjustedY = Math.min(y, window.innerHeight - items.length * 46 - 20);
const adjustedX = Math.min(x, window.innerWidth - 170);
return (
<div
ref={ref}
style={{ position: 'fixed', top: adjustedY, left: adjustedX, zIndex: 9999 }}
className="bg-white rounded-xl shadow-2xl border border-gray-100 py-1.5 min-w-[155px]"
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item, i) => (
<button
key={i}
onClick={() => { item.onClick(); onClose(); }}
className={`w-full text-left px-4 py-2.5 text-base font-semibold flex items-center gap-2.5 transition-colors ${
item.danger
? 'text-red-600 hover:bg-red-50'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<span className="text-lg leading-none">{item.icon}</span>
{item.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
interface Option { value: string; label: string; color: string; }
interface EditableSelectProps {
value: string;
options: Option[];
onSave: (val: string) => void;
className?: string;
}
export function EditableSelect({ value, options, onSave, className = '' }: EditableSelectProps) {
const [editing, setEditing] = useState(false);
const current = options.find(o => o.value === value);
if (editing) {
return (
<select
autoFocus
value={value}
onChange={(e) => { onSave(e.target.value); setEditing(false); }}
onBlur={() => setEditing(false)}
className={`rounded px-2 py-1 text-sm border border-gray-300 outline-none ${className}`}
>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
return (
<span
onClick={() => setEditing(true)}
title="클릭하여 변경"
className={`cursor-pointer hover:opacity-80 transition-opacity ${current?.color ?? ''} ${className}`}
>
{current?.label ?? value}
</span>
);
}

View File

@@ -0,0 +1,74 @@
import { useState, useRef, useEffect } from 'react';
interface EditableTextProps {
value: string;
onSave: (val: string) => void;
className?: string;
multiline?: boolean;
placeholder?: string;
}
/**
* 클릭하면 편집 가능한 텍스트 컴포넌트
* - 클릭 → 입력 필드로 전환
* - Enter 또는 blur → 저장
* - Escape → 취소
*/
export function EditableText({ value, onSave, className = '', multiline = false, placeholder = '클릭하여 입력' }: EditableTextProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);
useEffect(() => { setDraft(value); }, [value]);
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
const commit = () => {
setEditing(false);
if (draft.trim() !== value) onSave(draft.trim());
};
const cancel = () => {
setEditing(false);
setDraft(value);
};
const sharedClass = `w-full bg-white/90 border-b-2 border-blue-400 outline-none rounded px-1 resize-none ${className}`;
if (editing) {
return multiline ? (
<textarea
ref={inputRef as any}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Escape') cancel(); }}
rows={3}
className={sharedClass}
/>
) : (
<input
ref={inputRef as any}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') cancel(); }}
className={sharedClass}
placeholder={placeholder}
/>
);
}
return (
<span
onClick={() => setEditing(true)}
title="클릭하여 수정"
className={`cursor-text hover:bg-white/30 hover:rounded px-1 -mx-1 transition-colors group relative ${className}`}
>
{value || <span className="text-white/40 italic">{placeholder}</span>}
<span className="opacity-0 group-hover:opacity-60 ml-1 text-xs transition-opacity"></span>
</span>
);
}

View File

@@ -0,0 +1,257 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import type { Task } from '../../types';
const TAG_OPTIONS = ['Growth', 'Policy', 'Performance', 'Culture', 'Asset', 'Space', 'Safety', 'Environment'];
const STATUS_OPTIONS = [
{ value: 'TODO', label: '대기' },
{ value: 'IN_PROGRESS', label: '진행' },
{ value: 'REVIEW', label: '보류' },
{ value: 'DONE', label: '완료' },
];
export interface TaskFormData {
title: string;
section: string;
tag: string;
taskType: string;
status: string;
progress: number;
description: string;
issueNote: string;
quarter: string;
startDate: string;
dueDate: string;
}
interface TaskModalProps {
mode: 'add' | 'edit';
task?: Task;
defaultSection?: string;
defaultQuarter?: string;
sectionOptions?: { value: string; label: string }[];
onSave: (data: TaskFormData) => void;
onClose: () => void;
}
export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter = '2026-Q2', sectionOptions, onSave, onClose }: TaskModalProps) {
const toDateInput = (iso: string | null | undefined) => {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
};
const [form, setForm] = useState<TaskFormData>({
title: task?.title ?? '',
section: task?.section ?? defaultSection,
tag: task?.tag ?? '',
taskType: task?.taskType ?? '상시업무',
status: task?.status ?? 'TODO',
progress: task?.progress ?? 0,
description: task?.description ?? '',
issueNote: task?.issueNote ?? '',
quarter: task?.quarter ?? defaultQuarter,
startDate: toDateInput(task?.startDate),
dueDate: toDateInput(task?.dueDate),
});
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
setForm(prev => ({ ...prev, [field]: value }));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(form);
};
const progressColor =
form.progress >= 70 ? 'bg-emerald-500' :
form.progress >= 40 ? 'bg-blue-400' :
'bg-orange-400';
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-[540px] max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-2xl font-black text-gray-800">
{mode === 'add' ? '✚ 업무 추가' : '✏ 업무 수정'}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none transition-colors"
>
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* 제목 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> *</label>
<input
required
value={form.title}
onChange={(e) => set('title', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="업무 제목을 입력하세요"
/>
</div>
{/* 섹션 + 태그 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.section}
onChange={(e) => set('section', e.target.value)}
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"
>
{(sectionOptions ?? [
{ value: 'HR', label: 'HR' },
{ value: '운영관리', label: '운영관리' },
]).map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.tag}
onChange={(e) => set('tag', e.target.value)}
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>
{TAG_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
{/* 업무유형 + 상태 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> </label>
<select
value={form.taskType}
onChange={(e) => set('taskType', e.target.value)}
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>
<option value="프로젝트"></option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value)}
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"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
{/* 진행률 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={5}
value={form.progress}
onChange={(e) => set('progress', Number(e.target.value))}
className="flex-1 accent-blue-500"
/>
<div className="w-24 h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-3 ${progressColor} rounded-full transition-all`}
style={{ width: `${form.progress}%` }}
/>
</div>
</div>
</div>
{/* 내용 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<textarea
value={form.description}
onChange={(e) => set('description', e.target.value)}
rows={4}
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 resize-none transition"
placeholder="내용을 한 줄씩 입력하세요 (줄바꿈으로 구분)"
/>
</div>
{/* 프로젝트 기간 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
type="date"
value={form.startDate}
onChange={(e) => set('startDate', e.target.value)}
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"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
type="date"
value={form.dueDate}
onChange={(e) => set('dueDate', e.target.value)}
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"
/>
</div>
</div>
{/* 이슈 메모 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> </label>
<input
value={form.issueNote}
onChange={(e) => set('issueNote', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-red-400 focus:ring-2 focus:ring-red-100 transition text-red-600 placeholder:text-gray-300"
placeholder="[날짜] 이슈 내용 (비우면 표시 안 함)"
/>
</div>
{/* 버튼 */}
<div className="flex justify-end gap-2 pt-2 pb-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="submit"
className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition"
>
{mode === 'add' ? '추가하기' : '저장하기'}
</button>
</div>
</form>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,113 @@
interface Stats {
total: number;
inProgress: number;
review: number;
waiting: number;
done: number;
issues: number;
}
interface DashboardHeaderProps {
quarter: string;
stats: Stats;
activeType: string;
onTypeChange: (type: string) => void;
activeStatus: string;
onStatusChange: (status: string) => void;
onOpenDetailWindow: () => void;
}
export function DashboardHeader({ quarter, stats, activeType, onTypeChange, activeStatus, onStatusChange, onOpenDetailWindow }: DashboardHeaderProps) {
const today = new Date();
const todayStr = `${today.getMonth() + 1}${today.getDate()}`;
return (
<header className="bg-[#1e3260] text-white px-5 py-3 shrink-0 relative flex items-center">
{/* ── 왼쪽: 팀명 + 분기 ── */}
<div className="flex items-center gap-3 shrink-0">
<div className="shrink-0">
<span className="text-base font-bold tracking-tight"></span>
<span className="ml-1.5 text-xs text-blue-300 font-medium">EE&amp;E</span>
</div>
<div className="w-px h-5 bg-white/20" />
<span className="text-sm font-semibold text-blue-200 shrink-0">
{quarter.replace(/^(\d{4})-Q(\d)$/, '$1년 $2분기')}
</span>
</div>
{/* ── 가운데: 상태 필터 버튼 (절대 중앙 정렬) ── */}
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-1.5 text-sm">
<StatButton label="전체" value={stats.total} statusKey="전체" activeStatus={activeStatus} onClick={onStatusChange} color="text-white" activeColor="bg-white/20" />
<StatButton label="진행" value={stats.inProgress} statusKey="IN_PROGRESS" activeStatus={activeStatus} onClick={onStatusChange} color="text-blue-300" activeColor="bg-blue-500/40" />
<StatButton label="보류" value={stats.review} statusKey="REVIEW" activeStatus={activeStatus} onClick={onStatusChange} color="text-orange-300" activeColor="bg-orange-500/40" />
<StatButton label="대기" value={stats.waiting} statusKey="TODO" activeStatus={activeStatus} onClick={onStatusChange} color="text-gray-400" activeColor="bg-gray-500/40" />
<StatButton label="완료" value={stats.done} statusKey="DONE" activeStatus={activeStatus} onClick={onStatusChange} color="text-emerald-400" activeColor="bg-emerald-500/40" />
{stats.issues > 0 && (
<>
<span className="text-white/30 mx-0.5">|</span>
<StatButton label="이슈" value={stats.issues} statusKey="ISSUES" activeStatus={activeStatus} onClick={onStatusChange} color="text-red-400" activeColor="bg-red-500/40" />
</>
)}
</div>
{/* ── 오른쪽: 날짜 + 업무유형 필터 ── */}
<div className="ml-auto flex items-center gap-3 shrink-0">
<button
onClick={onOpenDetailWindow}
title="우측 모니터에 상세 창 열기"
className="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors border border-white/10 hover:border-white/30"
>
<span>🖥</span>
<span> </span>
</button>
<div className="w-px h-5 bg-white/20" />
<span className="text-xs text-white/50">{todayStr}</span>
<div className="w-px h-5 bg-white/20" />
<div className="flex gap-1">
{['전체', '상시업무', '프로젝트'].map((type) => (
<button
key={type}
onClick={() => onTypeChange(type)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
activeType === type
? 'bg-blue-500 text-white'
: 'bg-white/10 text-white/70 hover:bg-white/20'
}`}
>
{type}
</button>
))}
</div>
</div>
</header>
);
}
interface StatButtonProps {
label: string;
value: number;
statusKey: string;
activeStatus: string;
onClick: (key: string) => void;
color: string;
activeColor: string;
}
function StatButton({ label, value, statusKey, activeStatus, onClick, color, activeColor }: StatButtonProps) {
const isActive = activeStatus === statusKey;
return (
<button
onClick={() => onClick(isActive ? '전체' : statusKey)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border transition-all cursor-pointer select-none ${
isActive
? `${activeColor} border-white/30 shadow-inner`
: 'border-white/10 hover:border-white/30 hover:bg-white/10 active:scale-95'
}`}
>
<span className="text-white/60 text-xs font-semibold">{label}</span>
<span className={`font-black text-sm ${color}`}>{value}</span>
</button>
);
}

View File

@@ -0,0 +1,289 @@
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { apiClient } from '../../lib/apiClient';
import { SortableTaskCard } from './TaskCard';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
interface DepartmentColumnProps {
title: string;
titleEn?: string;
subtitle?: string;
tasks: Task[];
headerBg: string;
headerStyle?: React.CSSProperties;
storageKey: string;
section: string;
quarter: string;
noHeader?: boolean;
headerAlign?: 'left' | 'right';
onSelectTask?: (task: Task) => void;
sectionOptions?: { value: string; label: string }[];
}
// ── 헤더 편집 팝업 ──────────────────────────────────────────
interface HeaderModalProps {
title: string;
titleEn: string;
subtitle: string;
onSave: (title: string, titleEn: string, subtitle: string) => void;
onClose: () => void;
}
function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalProps) {
const [draftTitle, setDraftTitle] = useState(title);
const [draftTitleEn, setDraftTitleEn] = useState(titleEn);
const [draftSubtitle, setDraftSubtitle] = useState(subtitle);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (draftTitle.trim()) onSave(draftTitle.trim(), draftTitleEn.trim(), draftSubtitle.trim());
};
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-[420px] p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-black text-gray-800"> </h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none"></button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
required
autoFocus
value={draftTitle}
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"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
value={draftTitleEn}
onChange={(e) => setDraftTitleEn(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="Human Resources"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
value={draftSubtitle}
onChange={(e) => setDraftSubtitle(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="부제목 입력"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<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="submit" className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition"></button>
</div>
</form>
</div>
</div>,
document.body
);
}
export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions }: DepartmentColumnProps) {
const queryClient = useQueryClient();
// ── 컬럼 설정 API ─────────────────────────────────────────
const { data: colConfig } = useQuery({
queryKey: ['columns', section],
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(section)}`).then((r) => r.data),
staleTime: 0,
});
const patchColumn = useMutation({
mutationFn: (data: Record<string, string>) =>
apiClient.patch(`/columns/${encodeURIComponent(section)}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }),
});
const title = colConfig?.title ?? initialTitle;
const titleEnState = colConfig?.titleEn ?? (titleEn ?? '');
const subtitle = colConfig?.subtitle ?? initialSubtitle;
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [showHeaderModal, setShowHeaderModal] = useState(false);
// ── 드래그 순서 관리 (DB 저장) ────────────────────────────
const [orderedIds, setOrderedIds] = useState<string[]>([]);
const saveOrderTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// DB에서 불러온 순서 반영
useEffect(() => {
if (colConfig?.cardOrder) {
try { setOrderedIds(JSON.parse(colConfig.cardOrder)); } catch { /* ignore */ }
}
}, [colConfig?.cardOrder]);
// tasks 목록이 바뀌면 새 항목을 순서 목록에 추가
useEffect(() => {
const newIds = tasks.map((t) => t.id);
setOrderedIds((prev) => {
const merged = [...prev.filter((id) => newIds.includes(id)), ...newIds.filter((id) => !prev.includes(id))];
return merged;
});
}, [tasks]);
const orderedTasks = [...tasks].sort((a, b) => {
const ai = orderedIds.indexOf(a.id);
const bi = orderedIds.indexOf(b.id);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setOrderedIds((prev) => {
const oldIdx = prev.indexOf(active.id as string);
const newIdx = prev.indexOf(over.id as string);
const next = arrayMove(prev, oldIdx, newIdx);
// 300ms 디바운스 후 DB 저장
if (saveOrderTimer.current) clearTimeout(saveOrderTimer.current);
saveOrderTimer.current = setTimeout(() => {
patchColumn.mutate({ cardOrder: JSON.stringify(next) });
}, 300);
return next;
});
};
const saveTitle = (v: string) => patchColumn.mutate({ title: v });
const saveTitleEn = (v: string) => patchColumn.mutate({ titleEn: v });
const saveSubtitle = (v: string) => patchColumn.mutate({ subtitle: v });
const create = useMutation({
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
const handleListContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY, type: 'list' });
};
const sectionOptions = externalSectionOptions ?? [{ value: section, label: title }];
const displayTitle = title.replace(/\s*부문$/, '');
const handleAdd = (data: TaskFormData) => {
create.mutate({
title: data.title,
section: data.section || null,
tag: data.tag || null,
taskType: data.taskType || null,
status: data.status as Task['status'],
progress: data.progress,
description: data.description || null,
issueNote: data.issueNote || null,
startDate: data.startDate || null,
dueDate: data.dueDate || null,
quarter: data.quarter,
priority: 'MEDIUM',
creatorId: 'system',
} as any);
setShowAddModal(false);
};
return (
<>
<div className="flex flex-col bg-gray-50 rounded-2xl overflow-hidden border border-gray-200 min-h-0">
{/* 컬럼 헤더 (noHeader 시 숨김) */}
{!noHeader && (
<div
className="h-10 shrink-0 select-none flex items-center px-4 gap-2"
style={headerStyle}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }}
>
<span className="text-white font-black text-base tracking-tight truncate">{displayTitle}</span>
{titleEnState && (
<span className="text-white/60 text-xs font-medium truncate hidden xl:block">{titleEnState}</span>
)}
<span className="ml-auto shrink-0 text-xs font-black bg-white/20 text-white px-2 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
)}
{/* 업무 카드 목록 */}
<div
className="flex-1 overflow-y-auto p-4 min-h-0"
onContextMenu={handleListContextMenu}
>
{orderedTasks.length === 0 ? (
<div className="flex items-center justify-center h-40 text-2xl text-gray-300">
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={orderedTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{orderedTasks.map((task) => <SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />)}
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* 컨텍스트 메뉴 */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={
ctxMenu.type === 'header'
? [{ icon: '✏', label: '헤더 수정', onClick: () => setShowHeaderModal(true) }]
: [{ icon: '✚', label: '업무 추가', onClick: () => setShowAddModal(true) }]
}
/>
)}
{/* 추가 모달 */}
{showAddModal && (
<TaskModal
mode="add"
defaultSection={section}
defaultQuarter={quarter}
sectionOptions={sectionOptions}
onSave={handleAdd}
onClose={() => setShowAddModal(false)}
/>
)}
{/* 헤더 편집 모달 */}
{showHeaderModal && (
<HeaderModal
title={title}
titleEn={titleEnState}
subtitle={subtitle}
onSave={(t, te, s) => { saveTitle(t); saveTitleEn(te); saveSubtitle(s); setShowHeaderModal(false); }}
onClose={() => setShowHeaderModal(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,140 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../../lib/apiClient';
import type { Task } from '../../types';
const SECTIONS = [
{ key: '인사관리', titleEn: 'HR Management', gradient: 'linear-gradient(120deg, #2a4a8a 0%, #3461b8 50%, #3d72d0 100%)' },
{ key: '학습성장', titleEn: 'Learning & Growth', gradient: 'linear-gradient(120deg, #5b2d8a 0%, #7340b8 50%, #8a52d0 100%)' },
{ key: '운영지원', titleEn: 'Operations', gradient: 'linear-gradient(120deg, #0d6080 0%, #0d7a9a 50%, #0e92b8 100%)' },
{ key: '전산관리', titleEn: 'IT Management', gradient: 'linear-gradient(120deg, #0a6040 0%, #0d8050 50%, #10a060 100%)' },
] as const;
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
};
const STATUS_DOT: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500',
REVIEW: 'bg-orange-400',
TODO: 'bg-gray-300',
DONE: 'bg-emerald-500',
CANCELLED: 'bg-gray-300',
};
interface PanelColumnProps {
section: typeof SECTIONS[number];
tasks: Task[];
label: string;
}
function PanelColumn({ section, tasks, label }: PanelColumnProps) {
const issues = tasks.filter((t) => t.issueNote);
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
{/* 헤더 */}
<div className="shrink-0 flex items-center h-10 px-4 gap-2"
style={{ background: section.gradient }}>
<span className="text-white font-black text-sm tracking-tight truncate">{label}</span>
<span className="text-white/60 text-xs font-medium truncate hidden 2xl:block">{section.titleEn}</span>
<span className="ml-auto shrink-0 text-xs font-black bg-white/20 text-white px-2 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
{/* 업무 목록 (flex 4) */}
<div className="flex flex-col min-h-0" style={{ flex: 4 }}>
<div className="px-3 py-1.5 border-b border-gray-100 shrink-0">
<span className="text-xs font-black text-gray-400 uppercase tracking-wider"> </span>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{tasks.length === 0 ? (
<p className="text-xs text-gray-300 py-2 text-center"> </p>
) : (
tasks.map((t) => (
<div key={t.id}
className="flex items-center gap-2 rounded-lg bg-white border border-gray-100 px-2.5 py-1.5 hover:border-gray-200 transition-all">
<span className={`shrink-0 w-2 h-2 rounded-full ${STATUS_DOT[t.status] ?? 'bg-gray-300'}`} />
<span className="flex-1 text-xs font-semibold text-gray-700 truncate">{t.title}</span>
<span className="shrink-0 text-xs font-bold text-gray-400">{STATUS_LABEL[t.status]}</span>
<div className="shrink-0 w-10 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className={`h-1.5 rounded-full ${
t.progress >= 70 ? 'bg-emerald-400' :
t.progress >= 40 ? 'bg-blue-400' : 'bg-orange-400'
}`} style={{ width: `${t.progress}%` }} />
</div>
</div>
))
)}
</div>
</div>
{/* 이슈 (flex 1) */}
<div className="flex flex-col min-h-0 border-t-2 border-red-100" style={{ flex: 1 }}>
<div className="px-3 py-1 bg-red-50 shrink-0 flex items-center gap-1.5 border-b border-red-100">
<span className="text-xs font-black text-red-400 uppercase tracking-wider"></span>
{issues.length > 0 && (
<span className="text-xs font-black bg-red-400 text-white px-1.5 py-0.5 rounded-full leading-none">
{issues.length}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto px-3 py-1 space-y-1">
{issues.length === 0 ? (
<p className="text-xs text-gray-300 py-1 text-center"> </p>
) : (
issues.map((t) => (
<div key={t.id}
className="flex items-start gap-1.5 rounded-lg bg-red-50 border border-red-100 px-2.5 py-1.5">
<span className="shrink-0 text-red-400 text-xs mt-0.5"></span>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-gray-600 truncate">{t.title}</p>
<p className="text-xs text-red-500 truncate">{t.issueNote}</p>
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
interface RoutinePanelProps {
tasks: Task[];
}
export function RoutinePanel({ tasks }: RoutinePanelProps) {
const { data: configs } = useQuery<{ key: string; title: string }[]>({
queryKey: ['columns', 'all-routine'],
queryFn: async () => {
const results = await Promise.all(
SECTIONS.map((s) =>
apiClient.get(`/columns/${encodeURIComponent(s.key)}`).then((r) => ({ key: s.key, ...r.data })),
),
);
return results;
},
staleTime: 0,
});
const getLabel = (key: string) =>
configs?.find((c) => c.key === key)?.title ?? key;
return (
<div className="grid grid-cols-4 h-full min-h-0 divide-x divide-gray-200">
{SECTIONS.map((section) => (
<PanelColumn
key={section.key}
section={section}
tasks={tasks.filter((t) => t.section === section.key)}
label={getLabel(section.key)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { useState, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { sendTaskSelected } from '../../lib/dualMonitor';
import { apiClient } from '../../lib/apiClient';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
// ─── 태그 배지 색상 (카드 배경은 흰색 통일) ──────────────────
const TAG_CONFIG: Record<string, { bg: string; text: string }> = {
Growth: { bg: 'bg-blue-100', text: 'text-blue-800' },
Policy: { bg: 'bg-purple-100', text: 'text-purple-800' },
Performance: { bg: 'bg-emerald-100', text: 'text-emerald-800' },
Culture: { bg: 'bg-amber-100', text: 'text-amber-800' },
Asset: { bg: 'bg-cyan-100', text: 'text-cyan-800' },
Space: { bg: 'bg-indigo-100', text: 'text-indigo-800' },
Safety: { bg: 'bg-red-100', text: 'text-red-800' },
Environment: { bg: 'bg-lime-100', text: 'text-lime-800' },
};
const STATUS_STYLE: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500 text-white',
REVIEW: 'bg-orange-400 text-white',
TODO: 'bg-gray-300 text-gray-700',
DONE: 'bg-emerald-500 text-white',
CANCELLED: 'bg-gray-200 text-gray-500',
};
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
};
// ─── 날짜 포맷 헬퍼 ─────────────────────────────────────────
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}.${m}.${dd}`;
}
type SectionOption = { value: string; label: string };
// ─── 드래그 가능한 래퍼 ──────────────────────────────────────
export function SortableTaskCard({ task, sectionOptions, onSelect }: { task: Task; sectionOptions?: SectionOption[]; onSelect?: (task: Task) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
// dnd-kit이 dragListeners의 onPointerDown을 가로채므로 onClick이 막힐 수 있음.
// pointerDown 시작 위치를 ref로 기억했다가 pointerUp에서 이동이 적으면 클릭으로 판정.
const pointerStart = useRef<{ x: number; y: number } | null>(null);
const handlePointerDown = (e: React.PointerEvent) => {
pointerStart.current = { x: e.clientX, y: e.clientY };
};
const handlePointerUp = (e: React.PointerEvent) => {
if (!pointerStart.current || isDragging) return;
const dx = e.clientX - pointerStart.current.x;
const dy = e.clientY - pointerStart.current.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 6) {
onSelect?.(task);
}
pointerStart.current = null;
};
return (
<TaskCard
task={task}
dragRef={setNodeRef}
dragStyle={style}
dragAttributes={attributes}
dragListeners={listeners}
sectionOptions={sectionOptions}
onCardPointerDown={handlePointerDown}
onCardPointerUp={handlePointerUp}
/>
);
}
// ─── 메인 TaskCard ──────────────────────────────────────────
export function TaskCard({
task,
dragRef,
dragStyle,
dragAttributes,
dragListeners,
sectionOptions,
onCardPointerDown,
onCardPointerUp,
}: {
task: Task;
dragRef?: (node: HTMLElement | null) => void;
dragStyle?: React.CSSProperties;
dragAttributes?: DraggableAttributes;
dragListeners?: SyntheticListenerMap;
sectionOptions?: SectionOption[];
onCardPointerDown?: (e: React.PointerEvent) => void;
onCardPointerUp?: (e: React.PointerEvent) => void;
}) {
const queryClient = useQueryClient();
const tagCfg = TAG_CONFIG[task.tag ?? ''] ?? { bg: 'bg-gray-100', text: 'text-gray-600' };
// ── API mutations ──────────────────────────────────────────
const create = useMutation({
mutationFn: (data: Record<string, unknown>) => apiClient.post('/tasks', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
const patch = useMutation({
mutationFn: (data: Record<string, unknown>) => apiClient.patch(`/tasks/${task.id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
const remove = useMutation({
mutationFn: () => apiClient.delete(`/tasks/${task.id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
// ── 컨텍스트 메뉴 + 모달 상태 ─────────────────────────────
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
const handleAdd = (data: TaskFormData) => {
create.mutate({
title: data.title,
section: data.section || null,
tag: data.tag || null,
taskType: data.taskType || null,
status: data.status,
progress: data.progress,
description: data.description || null,
issueNote: data.issueNote || null,
startDate: data.startDate || null,
dueDate: data.dueDate || null,
quarter: data.quarter,
priority: 'MEDIUM',
creatorId: 'system',
});
setModalMode(null);
};
const handleEdit = (data: TaskFormData) => {
patch.mutate({
title: data.title,
section: data.section || null,
tag: data.tag || null,
taskType: data.taskType || null,
status: data.status,
progress: data.progress,
description: data.description || null,
issueNote: data.issueNote || null,
startDate: data.startDate || null,
dueDate: data.dueDate || null,
});
setModalMode(null);
};
const handleDelete = () => {
if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) {
remove.mutate();
}
};
return (
<>
<div
ref={dragRef}
style={dragStyle}
{...dragAttributes}
{...dragListeners}
className="bg-white rounded-2xl px-5 py-3 mb-3 shadow-sm border border-gray-100 hover:shadow-md hover:border-gray-200 transition-all select-none h-[112px] cursor-grab active:cursor-grabbing overflow-hidden"
onPointerDown={onCardPointerDown}
onPointerUp={onCardPointerUp}
onContextMenu={handleContextMenu}
>
{/* ── 상단: 제목 + 진행률 ── */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0 flex-1">
<span className="text-2xl font-bold text-gray-900 leading-snug min-w-0 truncate">
{task.title}
</span>
</div>
<span className={`shrink-0 text-2xl font-black mt-0.5 min-w-[4rem] text-right ${
task.progress >= 70 ? 'text-emerald-500' :
task.progress >= 40 ? 'text-blue-400' :
'text-orange-400'
}`}>
{task.progress}%
</span>
</div>
{/* ── 태그 + 기간 + 상태 ── */}
<div className="mt-1.5 flex items-center gap-2">
<span
className={`shrink-0 text-sm font-black px-2.5 py-0.5 rounded-full ${tagCfg.bg} ${tagCfg.text} cursor-pointer`}
onClick={() => sendTaskSelected(task.id)}
>
{task.tag}
</span>
<span className="text-base text-gray-400 font-medium flex-1 truncate">
{(task.startDate || task.dueDate)
? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}`
: ''}
</span>
<span className={`text-sm font-bold px-2.5 py-0.5 rounded-full ${STATUS_STYLE[task.status]} shrink-0`}>
{STATUS_LABEL[task.status]}
</span>
</div>
{/* ── 이슈 메모 ── */}
{task.issueNote && (
<div className="mt-2 flex gap-2 text-sm font-semibold text-red-600 min-w-0">
<span className="shrink-0"> :</span>
<span className="truncate">{task.issueNote}</span>
</div>
)}
</div>
{/* ── 컨텍스트 메뉴 ── */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={[
{ icon: '✚', label: '업무 추가', onClick: () => setModalMode('add') },
{ icon: '✏', label: '업무 수정', onClick: () => setModalMode('edit') },
{ icon: '🗑', label: '업무 삭제', onClick: handleDelete, danger: true },
]}
/>
)}
{/* ── 추가 모달 ── */}
{modalMode === 'add' && (
<TaskModal
mode="add"
defaultSection={task.section ?? 'HR'}
defaultQuarter={task.quarter}
sectionOptions={sectionOptions}
onSave={handleAdd}
onClose={() => setModalMode(null)}
/>
)}
{/* ── 수정 모달 ── */}
{modalMode === 'edit' && (
<TaskModal
mode="edit"
task={task}
sectionOptions={sectionOptions}
onSave={handleEdit}
onClose={() => setModalMode(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,155 @@
import type { Task } from '../../types';
const TAG_CONFIG: Record<string, { bg: string; text: string }> = {
Growth: { bg: 'bg-blue-100', text: 'text-blue-800' },
Policy: { bg: 'bg-purple-100', text: 'text-purple-800' },
Performance: { bg: 'bg-emerald-100', text: 'text-emerald-800' },
Culture: { bg: 'bg-amber-100', text: 'text-amber-800' },
Asset: { bg: 'bg-cyan-100', text: 'text-cyan-800' },
Space: { bg: 'bg-indigo-100', text: 'text-indigo-800' },
Safety: { bg: 'bg-red-100', text: 'text-red-800' },
Environment: { bg: 'bg-lime-100', text: 'text-lime-800' },
};
const STATUS_STYLE: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500 text-white',
REVIEW: 'bg-orange-400 text-white',
TODO: 'bg-gray-300 text-gray-700',
DONE: 'bg-emerald-500 text-white',
CANCELLED: 'bg-gray-200 text-gray-500',
};
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
};
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
}
interface Props {
task: Task | null;
onClose: () => void;
}
export function TaskDetailPanel({ task, onClose }: Props) {
const isOpen = !!task;
return (
<div
className={`h-full flex flex-col bg-white border-l border-gray-200 shadow-xl transition-all duration-300 ease-out overflow-hidden ${
isOpen ? 'w-[420px]' : 'w-0'
}`}
>
{task && (
<>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className={`shrink-0 text-sm font-black px-2.5 py-0.5 rounded-full ${TAG_CONFIG[task.tag ?? '']?.bg ?? 'bg-gray-100'} ${TAG_CONFIG[task.tag ?? '']?.text ?? 'text-gray-600'}`}>
{task.tag}
</span>
<span className={`shrink-0 text-sm font-bold px-2.5 py-0.5 rounded-full ${STATUS_STYLE[task.status]}`}>
{STATUS_LABEL[task.status]}
</span>
</div>
<button
onClick={onClose}
className="shrink-0 ml-2 text-gray-400 hover:text-gray-600 text-xl leading-none transition-colors"
>
</button>
</div>
{/* 본문 */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{/* 제목 */}
<div>
<p className="text-xs font-bold text-gray-400 mb-1"></p>
<h2 className="text-2xl font-black text-gray-900 leading-snug">{task.title}</h2>
</div>
{/* 진행률 */}
<div>
<p className="text-xs font-bold text-gray-400 mb-1.5"></p>
<div className="flex items-center gap-3">
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-3 rounded-full transition-all ${
task.progress >= 70 ? 'bg-emerald-500' :
task.progress >= 40 ? 'bg-blue-400' :
'bg-orange-400'
}`}
style={{ width: `${task.progress}%` }}
/>
</div>
<span className={`text-xl font-black min-w-[3rem] text-right ${
task.progress >= 70 ? 'text-emerald-500' :
task.progress >= 40 ? 'text-blue-400' :
'text-orange-400'
}`}>
{task.progress}%
</span>
</div>
</div>
{/* 기간 */}
{(task.startDate || task.dueDate) && (
<div>
<p className="text-xs font-bold text-gray-400 mb-1"></p>
<p className="text-base font-medium text-gray-700">
{fmtDate(task.startDate)} ~ {fmtDate(task.dueDate)}
</p>
</div>
)}
{/* 섹션 / 업무유형 */}
<div className="flex gap-6">
{task.section && (
<div>
<p className="text-xs font-bold text-gray-400 mb-1"></p>
<p className="text-base font-semibold text-gray-700">{task.section}</p>
</div>
)}
{task.taskType && (
<div>
<p className="text-xs font-bold text-gray-400 mb-1"></p>
<p className="text-base font-semibold text-gray-700">{task.taskType}</p>
</div>
)}
</div>
{/* 내용 */}
{task.description && (
<div>
<p className="text-xs font-bold text-gray-400 mb-2"></p>
<ul className="space-y-2">
{task.description.split('\n').filter(Boolean).map((b, i) => (
<li key={i} className="flex gap-2 text-base text-gray-700">
<span className="shrink-0 text-gray-300 mt-0.5"></span>
<span>{b.replace(/^[•·\-]\s*/, '')}</span>
</li>
))}
</ul>
</div>
)}
{/* 이슈 메모 */}
{task.issueNote && (
<div className="rounded-xl bg-red-50 border border-red-100 px-4 py-3">
<p className="text-xs font-bold text-red-400 mb-1"> </p>
<p className="text-base font-semibold text-red-600">{task.issueNote}</p>
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { apiClient } from '../lib/apiClient';
interface User {
id: string;
email: string;
name: string;
role: 'ADMIN' | 'MANAGER' | 'MEMBER';
department: string | null;
}
interface AuthContextValue {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const TOKEN_KEY = 'eee_token';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!token) {
setIsLoading(false);
return;
}
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
apiClient
.get<User>('/auth/me')
.then(({ data }) => setUser(data))
.catch(() => {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
})
.finally(() => setIsLoading(false));
}, [token]);
const login = async (email: string, password: string) => {
const { data } = await apiClient.post<{ token: string; user: User }>('/auth/login', {
email,
password,
});
localStorage.setItem(TOKEN_KEY, data.token);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
setToken(data.token);
setUser(data.user);
};
const logout = () => {
localStorage.removeItem(TOKEN_KEY);
delete apiClient.defaults.headers.common['Authorization'];
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider
value={{ user, token, isAuthenticated: !!user, isLoading, login, logout }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
import { io, type Socket } from 'socket.io-client';
const SocketContext = createContext<Socket | null>(null);
// 같은 네트워크 팀원도 접속 가능: 백엔드 주소를 현재 페이지 호스트에서 자동 감지
const SOCKET_URL =
import.meta.env.VITE_SOCKET_URL ||
`${window.location.protocol}//${window.location.hostname}:4000`;
export function SocketProvider({ children }: { children: ReactNode }) {
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const socket = io(SOCKET_URL, { transports: ['websocket'] });
socketRef.current = socket;
socket.on('connect', () => console.log('[Socket] Connected'));
socket.on('disconnect', () => console.log('[Socket] Disconnected'));
return () => {
socket.disconnect();
socketRef.current = null;
};
}, []);
return (
<SocketContext.Provider value={socketRef.current}>
{children}
</SocketContext.Provider>
);
}
export function useSocket(): Socket | null {
return useContext(SocketContext);
}

View File

@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import type { Task } from '../types';
interface TasksParams {
quarter?: string;
section?: string;
taskType?: string;
}
export function useTasks(params?: TasksParams) {
return useQuery({
queryKey: ['tasks', params],
queryFn: async () => {
const { data } = await apiClient.get<Task[]>('/tasks', { params });
return data;
},
refetchInterval: 30_000,
});
}

1
frontend/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,19 @@
import axios from 'axios';
// 개발: Vite 프록시 → /api (localhost:4000)
// 배포: VITE_API_URL=https://xxx.onrender.com 설정 시 그 주소 사용
const baseURL = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/api`
: '/api';
export const apiClient = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.response.use(
(res) => res,
(error) => Promise.reject(error),
);

View File

@@ -0,0 +1,84 @@
/**
* 듀얼 모니터 연동 유틸리티
* BroadcastChannel API를 사용해 두 브라우저 창 간 실시간 통신
*/
const CHANNEL_NAME = 'eee_dashboard';
const DETAIL_WINDOW_NAME = 'eene_detail';
type DualMonitorEvent =
| { type: 'TASK_SELECTED'; taskId: string }
| { type: 'TASK_DESELECTED' }
| { type: 'REFRESH' };
let channel: BroadcastChannel | null = null;
let detailWindow: Window | null = null;
function getChannel(): BroadcastChannel {
if (!channel) {
channel = new BroadcastChannel(CHANNEL_NAME);
}
return channel;
}
/** 상세 창이 열려 있는지 확인 */
export function isDetailWindowOpen(): boolean {
return !!detailWindow && !detailWindow.closed;
}
/** 상세 창 토글 — 열려 있으면 닫고, 닫혀 있으면 오른쪽 모니터에 열기 */
export function openDetailWindow(): Window | null {
if (isDetailWindowOpen()) {
detailWindow!.close();
detailWindow = null;
return null;
}
// 현재 창이 왼쪽 모니터에 있다고 가정하고
// 오른쪽 모니터의 시작 X 좌표 = 현재 창 X + 현재 화면 너비
const screenW = window.screen.width;
const screenH = window.screen.height;
const rightMonitorLeft = window.screenX + screenW;
detailWindow = window.open(
'/detail',
DETAIL_WINDOW_NAME,
`width=${screenW},height=${screenH},left=${rightMonitorLeft},top=0,resizable=yes,scrollbars=yes`,
);
return detailWindow;
}
/** 좌측 → 우측: 업무 선택 이벤트 전송 (창이 닫혀 있으면 열고 전송) */
export function sendTaskSelected(taskId: string): void {
if (!isDetailWindowOpen()) {
openDetailWindow();
// 창이 로드될 때까지 잠시 대기 후 전송
setTimeout(() => {
getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent);
}, 800);
return;
}
detailWindow!.focus();
getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent);
}
/** 좌측 → 우측: 업무 선택 해제 */
export function sendTaskDeselected(): void {
getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent);
}
/** 이벤트 수신 리스너 등록 */
export function onDualMonitorEvent(
handler: (event: DualMonitorEvent) => void,
): () => void {
const ch = getChannel();
const listener = (e: MessageEvent<DualMonitorEvent>) => handler(e.data);
ch.addEventListener('message', listener);
return () => ch.removeEventListener('message', listener);
}
/** 채널 종료 */
export function closeChannel(): void {
channel?.close();
channel = null;
}

23
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30, // 30초
retry: 1,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,4 @@
// TODO: 관리자 페이지 UI 구현 예정
export default function AdminPage() {
return <div> - </div>;
}

View File

@@ -0,0 +1,168 @@
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import { useTasks } from '../hooks/useTasks';
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
import { RoutinePanel } from '../components/dashboard/RoutinePanel';
import { useSocket } from '../contexts/SocketContext';
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
const QUARTER = '2026-Q2';
export default function DashboardPage() {
const [activeType, setActiveType] = useState('전체');
const [activeStatus, setActiveStatus] = useState('전체');
const [isBottomPanelOpen, setIsBottomPanelOpen] = useState(false);
const queryClient = useQueryClient();
const socket = useSocket();
const { data: tasks = [], isLoading } = useTasks({ quarter: QUARTER });
useEffect(() => {
if (!socket) return;
const refresh = () => queryClient.invalidateQueries({ queryKey: ['tasks'] });
socket.on('tasks:refresh', refresh);
socket.on('task:updated', refresh);
return () => { socket.off('tasks:refresh', refresh); socket.off('task:updated', refresh); };
}, [socket, queryClient]);
const byType = tasks.filter((t) => activeType === '전체' || t.taskType === activeType);
const stats = {
total: byType.length,
inProgress: byType.filter((t) => t.status === 'IN_PROGRESS').length,
review: byType.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED').length,
waiting: byType.filter((t) => t.status === 'TODO').length,
done: byType.filter((t) => t.status === 'DONE').length,
issues: byType.filter((t) => !!t.issueNote).length,
};
const filtered = byType.filter((t) => {
if (activeStatus === '전체') return true;
if (activeStatus === 'ISSUES') return !!t.issueNote;
if (activeStatus === 'REVIEW') return t.status === 'REVIEW' || t.status === 'CANCELLED';
return t.status === activeStatus;
});
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
const sec1Tasks = filtered.filter((t) => t.section === '인사관리');
const sec2Tasks = filtered.filter((t) => t.section === '학습성장');
const sec3Tasks = filtered.filter((t) => t.section === '운영지원');
const sec4Tasks = filtered.filter((t) => t.section === '전산관리');
const routineTasks = tasks.filter((t) => t.taskType === '상시업무');
const { data: colConfigs } = useQuery({
queryKey: ['columns', 'all'],
queryFn: async () => {
const results = await Promise.all(
SECTIONS.map((s) => apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => ({ key: s, ...r.data }))),
);
return results;
},
staleTime: 0,
});
const sectionOptions = SECTIONS.map((s) => ({
value: s,
label: colConfigs?.find((c) => c.key === s)?.title ?? s,
}));
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-slate-100">
<div className="text-3xl text-gray-400"> ...</div>
</div>
);
}
return (
<div className="relative flex flex-col h-screen bg-slate-100 overflow-hidden" style={{ fontSize: '18px' }}>
<DashboardHeader
quarter={QUARTER}
stats={stats}
activeType={activeType}
onTypeChange={(type) => { setActiveType(type); setActiveStatus('전체'); }}
activeStatus={activeStatus}
onStatusChange={setActiveStatus}
onOpenDetailWindow={openDetailWindow}
/>
<main className="relative flex-1 overflow-hidden min-h-0">
<div className="grid h-full grid-cols-4 overflow-hidden min-h-0">
<DepartmentColumn
title="인사관리"
titleEn="HR Management"
tasks={sec1Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #2a4a8a 0%, #3461b8 50%, #3d72d0 100%)' }}
storageKey="col_sec1"
section="인사관리"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="학습성장"
titleEn="Learning & Growth"
tasks={sec2Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #5b2d8a 0%, #7340b8 50%, #8a52d0 100%)' }}
storageKey="col_sec2"
section="학습성장"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="운영지원"
titleEn="Operations"
tasks={sec3Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #0d6080 0%, #0d7a9a 50%, #0e92b8 100%)' }}
storageKey="col_sec3"
section="운영지원"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="전산관리"
titleEn="IT Management"
tasks={sec4Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #0a6040 0%, #0d8050 50%, #10a060 100%)' }}
storageKey="col_sec4"
section="전산관리"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
</div>
{/* 하단 슬라이드 패널 */}
<button
type="button"
onClick={() => setIsBottomPanelOpen((v) => !v)}
className={`absolute left-1/2 z-40 -translate-x-1/2 rounded-t-2xl border border-orange-200 bg-orange-50 px-10 py-1.5 text-orange-600 shadow-md transition-all hover:bg-orange-100 ${
isBottomPanelOpen ? 'top-0' : 'bottom-0'
}`}
aria-label={isBottomPanelOpen ? '하단 정보 닫기' : '하단 정보 열기'}
>
<span className={`block text-2xl font-black leading-none transition-transform ${isBottomPanelOpen ? 'rotate-180' : ''}`}>
^
</span>
</button>
<section
className={`absolute inset-x-0 bottom-0 z-30 h-full rounded-t-3xl border-t border-gray-200 bg-white shadow-2xl transition-transform duration-300 ease-out overflow-hidden ${
isBottomPanelOpen ? 'translate-y-0' : 'translate-y-full'
}`}
>
<RoutinePanel tasks={routineTasks} />
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,468 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import { onDualMonitorEvent } from '../lib/dualMonitor';
import type { Task, Milestone, FileRecord } from '../types';
/* ─── 공통 유틸 ───────────────────────────────── */
const TAG_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
Growth: { bg: '#EFF6FF', text: '#1D4ED8', border: '#BFDBFE' },
Policy: { bg: '#F5F3FF', text: '#6D28D9', border: '#DDD6FE' },
Performance: { bg: '#ECFDF5', text: '#065F46', border: '#A7F3D0' },
Culture: { bg: '#FFFBEB', text: '#92400E', border: '#FDE68A' },
Asset: { bg: '#ECFEFF', text: '#155E75', border: '#A5F3FC' },
Space: { bg: '#EEF2FF', text: '#3730A3', border: '#C7D2FE' },
Safety: { bg: '#FEF2F2', text: '#991B1B', border: '#FECACA' },
Environment: { bg: '#F7FEE7', text: '#3F6212', border: '#D9F99D' },
};
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
IN_PROGRESS: { bg: '#3B82F6', text: '#fff', label: '진행 중' },
REVIEW: { bg: '#F97316', text: '#fff', label: '보류' },
TODO: { bg: '#E5E7EB', text: '#374151', label: '대기' },
DONE: { bg: '#10B981', text: '#fff', label: '완료' },
CANCELLED: { bg: '#D1D5DB', text: '#6B7280', label: '취소' },
};
function fmtDate(iso: string | null | undefined) {
if (!iso) return '';
const d = new Date(iso);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
}
function fileIcon(mime: string) {
if (mime.includes('pdf')) return '📄';
if (mime.includes('sheet') || mime.includes('excel') || mime.includes('csv')) return '📊';
if (mime.includes('word')) return '📝';
if (mime.includes('image')) return '🖼';
if (mime.includes('video')) return '🎬';
return '📎';
}
function fileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-2">
<span className="flex-1 h-px bg-gray-100" />
{children}
<span className="flex-1 h-px bg-gray-100" />
</h3>
);
}
/* ═══════════════════════════════════════════════
대기 화면
═══════════════════════════════════════════════ */
function WaitingScreen() {
return (
<div className="flex flex-col h-full items-center justify-center gap-6 bg-slate-50">
<div className="w-20 h-20 rounded-full bg-blue-100 flex items-center justify-center text-4xl animate-pulse"></div>
<div className="text-center">
<p className="text-2xl font-black text-gray-700"> </p>
<p className="text-base font-medium text-gray-400 mt-2">
<br /> .
</p>
</div>
</div>
);
}
/* ═══════════════════════════════════════════════
메인 상세 뷰 (탭 없이 한 페이지)
═══════════════════════════════════════════════ */
function DetailView({ task, files }: { task: Task; files: FileRecord[] }) {
const qc = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewFile, setPreviewFile] = useState<FileRecord | null>(null);
const [uploading, setUploading] = useState(false);
const [addingMs, setAddingMs] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDate, setNewDate] = useState('');
const [newDesc, setNewDesc] = useState('');
const tag = TAG_CONFIG[task.tag ?? ''] ?? { bg: '#F3F4F6', text: '#374151', border: '#E5E7EB' };
const status = STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO;
const progress = task.progress ?? 0;
const progressColor = progress >= 70 ? '#10B981' : progress >= 40 ? '#3B82F6' : '#F97316';
const bullets = task.description?.split('\n').filter(Boolean) ?? [];
// 기간 타임라인
const start = task.startDate ? new Date(task.startDate) : null;
const end = task.dueDate ? new Date(task.dueDate) : null;
const now = new Date();
const totalDays = start && end ? Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000)) : null;
const elapsedDays = start ? Math.max(0, Math.ceil((now.getTime() - start.getTime()) / 86400000)) : null;
const timePercent = totalDays && elapsedDays !== null
? Math.min(100, Math.round((elapsedDays / totalDays) * 100))
: null;
// 마일스톤
const { data: milestones = [] } = useQuery<Milestone[]>({
queryKey: ['milestones', task.id],
queryFn: async () => (await apiClient.get(`/milestones/${task.id}`)).data,
});
const addMs = useMutation({
mutationFn: (body: object) => apiClient.post(`/milestones/${task.id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['milestones', task.id] });
setAddingMs(false); setNewTitle(''); setNewDate(''); setNewDesc('');
},
});
const toggleMs = useMutation({
mutationFn: ({ id, completed }: { id: string; completed: boolean }) =>
apiClient.patch(`/milestones/item/${id}`, { completed }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['milestones', task.id] }),
});
const deleteMs = useMutation({
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['milestones', task.id] }),
});
const deleteFile = useMutation({
mutationFn: (id: string) => apiClient.delete(`/files/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['task'] }),
});
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const form = new FormData();
form.append('file', file);
form.append('uploadedBy', 'system');
try {
await apiClient.post(`/files/upload/${task.id}`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
qc.invalidateQueries({ queryKey: ['task'] });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const msDone = milestones.filter((m) => m.completedAt).length;
const msTotal = milestones.length;
return (
<div className="flex-1 overflow-y-auto">
{/* ── 헤더 배너 ─────────────────────────────── */}
<div className="px-8 pt-7 pb-6"
style={{ background: 'linear-gradient(135deg, #1e3260 0%, #1e4fa0 60%, #2a6dd0 100%)' }}>
<div className="flex flex-wrap gap-2 mb-4">
{task.tag && (
<span className="text-sm font-black px-3 py-1 rounded-full border"
style={{ background: tag.bg, color: tag.text, borderColor: tag.border }}>
{task.tag}
</span>
)}
<span className="text-sm font-bold px-3 py-1 rounded-full"
style={{ background: status.bg, color: status.text }}>
{status.label}
</span>
{task.taskType && (
<span className="text-sm font-medium px-3 py-1 rounded-full bg-white/15 text-white/80">{task.taskType}</span>
)}
{task.section && (
<span className="text-sm font-medium px-3 py-1 rounded-full bg-white/10 text-blue-200 ml-auto">{task.section}</span>
)}
</div>
<h1 className="text-3xl font-black text-white leading-snug mb-3">{task.title}</h1>
{(task.startDate || task.dueDate) && (
<p className="text-blue-200 text-base font-medium">
{fmtDate(task.startDate)} ~ {fmtDate(task.dueDate)}
</p>
)}
</div>
<div className="px-8 py-6 space-y-8">
{/* ── 진행률 ───────────────────────────────── */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-gray-500"></span>
<span className="text-2xl font-black" style={{ color: progressColor }}>{progress}%</span>
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div className="h-3 rounded-full transition-all duration-500"
style={{ width: `${progress}%`, background: progressColor }} />
</div>
</div>
{/* ── 기간 타임라인 ────────────────────────── */}
{start && end && timePercent !== null && (
<div className="bg-white border border-gray-100 rounded-2xl p-5 shadow-sm">
<div className="flex justify-between text-sm font-semibold text-gray-400 mb-3">
<span>{fmtDate(task.startDate)}</span>
<span className="font-black text-gray-600"> {totalDays}</span>
<span>{fmtDate(task.dueDate)}</span>
</div>
<div className="relative h-5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-5 rounded-full bg-gradient-to-r from-blue-400 to-blue-600 transition-all"
style={{ width: `${timePercent}%` }} />
<div className="absolute inset-0 flex items-center justify-center text-xs font-black text-white drop-shadow">
{timePercent}%
</div>
</div>
{now > end && (
<p className="mt-2 text-xs font-bold text-orange-500 text-center"> </p>
)}
</div>
)}
{/* ── 내용 ─────────────────────────────────── */}
{bullets.length > 0 && (
<div>
<SectionTitle></SectionTitle>
<ul className="space-y-2">
{bullets.map((b, i) => (
<li key={i} className="flex gap-3 bg-white rounded-xl px-5 py-3 shadow-sm border border-gray-100">
<span className="shrink-0 text-blue-300 mt-0.5"></span>
<span className="text-base text-gray-700">{b.replace(/^[•·\-]\s*/, '')}</span>
</li>
))}
</ul>
</div>
)}
{/* ── 이슈 ─────────────────────────────────── */}
{task.issueNote && (
<div className="bg-red-50 border border-red-200 rounded-xl px-5 py-4">
<p className="text-xs font-black text-red-400 mb-1"> </p>
<p className="text-base font-semibold text-red-700">{task.issueNote}</p>
</div>
)}
{/* ── 프로세스 단계 ─────────────────────────── */}
<div>
<SectionTitle> </SectionTitle>
{msTotal > 0 && (
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-2 bg-emerald-500 rounded-full transition-all"
style={{ width: `${Math.round((msDone / msTotal) * 100)}%` }} />
</div>
<span className="text-sm font-black text-gray-500 shrink-0">{msDone}/{msTotal} </span>
</div>
)}
<ul className="space-y-2 mb-3">
{milestones.map((m, idx) => (
<li key={m.id}
className={`flex gap-4 items-start bg-white rounded-2xl px-5 py-4 border shadow-sm transition-all ${
m.completedAt ? 'border-emerald-200 bg-emerald-50/40' : 'border-gray-100'
}`}
>
<button
onClick={() => toggleMs.mutate({ id: m.id, completed: !m.completedAt })}
className={`shrink-0 w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-black transition-all ${
m.completedAt
? 'bg-emerald-500 border-emerald-500 text-white'
: 'border-gray-300 text-gray-400 hover:border-blue-400'
}`}
>
{m.completedAt ? '✓' : idx + 1}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-base font-bold ${m.completedAt ? 'line-through text-gray-400' : 'text-gray-800'}`}>
{m.title}
</span>
{m.dueDate && (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
m.completedAt ? 'bg-emerald-100 text-emerald-600' :
new Date(m.dueDate) < now ? 'bg-red-100 text-red-600' : 'bg-blue-50 text-blue-500'
}`}>
{fmtDate(m.dueDate)}
</span>
)}
</div>
{m.description && <p className="mt-1 text-sm text-gray-500">{m.description}</p>}
{m.completedAt && <p className="mt-1 text-xs text-emerald-500 font-semibold">: {fmtDate(m.completedAt)}</p>}
</div>
<button
onClick={() => { if (window.confirm('삭제하시겠습니까?')) deleteMs.mutate(m.id); }}
className="shrink-0 text-gray-300 hover:text-red-400 transition-colors text-xl leading-none"
>×</button>
</li>
))}
</ul>
{addingMs ? (
<div className="bg-white border border-blue-200 rounded-2xl px-5 py-4 space-y-3 shadow-sm">
<input autoFocus placeholder="단계 제목 *" value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="w-full text-base font-semibold border-b border-gray-200 pb-2 focus:outline-none focus:border-blue-400" />
<input placeholder="설명 (선택)" value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="w-full text-sm text-gray-600 border-b border-gray-100 pb-2 focus:outline-none" />
<div className="flex items-center gap-3">
<input type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)}
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-400" />
<div className="ml-auto flex gap-2">
<button onClick={() => setAddingMs(false)} className="text-sm text-gray-400 hover:text-gray-600 px-3 py-1.5"></button>
<button
onClick={() => { if (newTitle.trim()) addMs.mutate({ title: newTitle.trim(), description: newDesc || undefined, dueDate: newDate || undefined }); }}
className="text-sm font-bold bg-blue-500 text-white px-4 py-1.5 rounded-lg hover:bg-blue-600 transition-colors"
></button>
</div>
</div>
</div>
) : (
<button onClick={() => setAddingMs(true)}
className="w-full py-3 rounded-2xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-blue-300 hover:text-blue-400 transition-colors text-sm font-bold">
+
</button>
)}
</div>
{/* ── 첨부파일 ─────────────────────────────── */}
<div>
<SectionTitle></SectionTitle>
{/* 미리보기 뷰어 */}
{previewFile && (
<div className="mb-4 rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm font-bold text-gray-700 truncate">{previewFile.originalName}</span>
<div className="flex gap-3 shrink-0 ml-3">
<a href={`/api/files/${previewFile.id}/download`}
className="text-xs font-semibold text-blue-500 hover:underline"></a>
<button onClick={() => setPreviewFile(null)}
className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
</div>
<div style={{ height: '420px' }}>
{previewFile.mimetype.includes('image') ? (
<img src={`/api/files/${previewFile.id}/view`} alt={previewFile.originalName}
className="w-full h-full object-contain bg-gray-50 p-4" />
) : (
<iframe src={`/api/files/${previewFile.id}/view`} title={previewFile.originalName}
className="w-full h-full border-0" />
)}
</div>
</div>
)}
<div className="space-y-2 mb-3">
{files.length === 0 && !uploading && (
<p className="text-center text-gray-400 text-sm py-4"> </p>
)}
{files.map((f) => (
<div key={f.id}
className={`flex items-center gap-4 bg-white rounded-xl px-4 py-3 border shadow-sm hover:border-blue-200 transition-all cursor-pointer group ${
previewFile?.id === f.id ? 'border-blue-300 bg-blue-50/30' : 'border-gray-100'
}`}
onClick={() => setPreviewFile(previewFile?.id === f.id ? null : f)}
>
<span className="text-2xl shrink-0">{fileIcon(f.mimetype)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-gray-800 truncate">{f.originalName}</p>
<p className="text-xs text-gray-400">{fileSize(f.size)} · {fmtDate(f.createdAt)}</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<a href={`/api/files/${f.id}/download`} onClick={(e) => e.stopPropagation()}
className="text-xs font-semibold text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100">
</a>
<button
onClick={(e) => { e.stopPropagation(); if (window.confirm('삭제하시겠습니까?')) deleteFile.mutate(f.id); }}
className="text-xs font-semibold text-red-400 hover:text-red-600 px-2 py-1 rounded hover:bg-red-50">
</button>
</div>
</div>
))}
{uploading && (
<div className="flex items-center gap-3 bg-blue-50 rounded-xl px-4 py-3 border border-blue-200">
<span className="text-sm font-semibold text-blue-600 animate-pulse"> ...</span>
</div>
)}
</div>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleUpload} />
<button onClick={() => fileInputRef.current?.click()}
className="w-full py-3 rounded-2xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-blue-300 hover:text-blue-400 transition-colors text-sm font-bold">
+
</button>
</div>
<div className="h-8" /> {/* 하단 여백 */}
</div>
</div>
);
}
/* ═══════════════════════════════════════════════
메인 페이지
═══════════════════════════════════════════════ */
export default function DetailPage() {
const [taskId, setTaskId] = useState<string | null>(null);
useEffect(() => {
const unsub = onDualMonitorEvent((evt) => {
if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId);
if (evt.type === 'TASK_DESELECTED') setTaskId(null);
});
return unsub;
}, []);
const { data: task, isLoading } = useQuery({
queryKey: ['task', taskId],
queryFn: async () => {
const { data } = await apiClient.get<Task & { files: FileRecord[] }>(`/tasks/${taskId}`);
return data;
},
enabled: !!taskId,
staleTime: 10_000,
});
const tag = task ? (TAG_CONFIG[task.tag ?? ''] ?? { bg: '#F3F4F6', text: '#374151', border: '#E5E7EB' }) : null;
const status = task ? (STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO) : null;
return (
<div className="flex flex-col h-screen bg-slate-50 overflow-hidden" style={{ fontSize: '18px' }}>
{/* 최상단 바 */}
<div className="shrink-0 flex items-center gap-3 px-6 h-12"
style={{ background: 'linear-gradient(90deg, #1e3260 0%, #1e4fa0 100%)' }}>
<span className="text-white font-black text-base tracking-wide">EENE </span>
{task && tag && status && (
<>
<span className="w-px h-4 bg-white/20" />
<span className="text-white/80 text-sm font-bold truncate max-w-[300px]">{task.title}</span>
<div className="ml-auto flex gap-2 shrink-0">
{task.tag && (
<span className="text-xs font-black px-2 py-0.5 rounded-full border"
style={{ background: tag.bg, color: tag.text, borderColor: tag.border }}>
{task.tag}
</span>
)}
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
style={{ background: status.bg, color: status.text }}>
{status.label}
</span>
</div>
</>
)}
</div>
{/* 본문 */}
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center text-gray-400 text-xl"> ...</div>
) : !task ? (
<WaitingScreen />
) : (
<DetailView task={task} files={(task as any).files ?? []} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
// TODO: 로그인 화면 UI 구현 예정
export default function LoginPage() {
return <div> ( )</div>;
}

View File

@@ -0,0 +1,10 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<h1>404 - </h1>
<Link to="/"> </Link>
</div>
);
}

23
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Routes, Route } from 'react-router-dom';
import DashboardPage from './pages/DashboardPage';
import DetailPage from './pages/DetailPage';
import AdminPage from './pages/AdminPage';
import NotFoundPage from './pages/NotFoundPage';
export function AppRouter() {
return (
<Routes>
{/* 좌측 모니터: 업무 목록 대시보드 */}
<Route path="/" element={<DashboardPage />} />
{/* 우측 모니터: 업무 상세 패널 */}
<Route path="/detail" element={<DetailPage />} />
<Route path="/detail/:taskId" element={<DetailPage />} />
{/* 관리자 전용 */}
<Route path="/admin" element={<AdminPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}

View File

@@ -0,0 +1,86 @@
export type Role = 'ADMIN' | 'MANAGER' | 'MEMBER';
export type TaskStatus = 'TODO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' | 'CANCELLED';
export type Priority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
export interface User {
id: string;
email: string;
name: string;
role: Role;
department: string | null;
isActive: boolean;
createdAt: string;
}
export interface Task {
id: string;
title: string;
description: string | null;
status: TaskStatus;
priority: Priority;
quarter: string;
category: string | null;
section: string | null; // HR | 운영관리
tag: string | null; // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
taskType: string | null; // 상시업무 | 프로젝트
progress: number; // 0-100
issueNote: string | null;
startDate: string | null;
dueDate: string | null;
creatorId: string;
assigneeId: string | null;
createdAt: string;
updatedAt: string;
assignee?: Pick<User, 'id' | 'name' | 'department'> | null;
creator?: Pick<User, 'id' | 'name'>;
_count?: { files: number; details: number };
}
export interface TaskDetail {
id: string;
taskId: string;
content: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface KpiMetric {
id: string;
taskId: string;
quarter: string;
target: number;
actual: number;
unit: string | null;
}
export interface FileRecord {
id: string;
taskId: string;
filename: string;
originalName: string;
mimetype: string;
size: number;
path: string;
uploadedBy: string;
createdAt: string;
}
export interface Milestone {
id: string;
taskId: string;
title: string;
description: string | null;
dueDate: string | null;
completedAt: string | null;
order: number;
createdAt: string;
updatedAt: string;
}
export interface TaskFull extends Task {
details: TaskDetail[];
kpiMetrics: KpiMetric[];
files: FileRecord[];
milestones: Milestone[];
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />