Initial commit - EENE Dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
54
frontend/src/components/common/ContextMenu.tsx
Normal file
54
frontend/src/components/common/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/common/EditableSelect.tsx
Normal file
41
frontend/src/components/common/EditableSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/common/EditableText.tsx
Normal file
74
frontend/src/components/common/EditableText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
257
frontend/src/components/common/TaskModal.tsx
Normal file
257
frontend/src/components/common/TaskModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/dashboard/DashboardHeader.tsx
Normal file
113
frontend/src/components/dashboard/DashboardHeader.tsx
Normal 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&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>
|
||||
);
|
||||
}
|
||||
289
frontend/src/components/dashboard/DepartmentColumn.tsx
Normal file
289
frontend/src/components/dashboard/DepartmentColumn.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
frontend/src/components/dashboard/RoutinePanel.tsx
Normal file
140
frontend/src/components/dashboard/RoutinePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
282
frontend/src/components/dashboard/TaskCard.tsx
Normal file
282
frontend/src/components/dashboard/TaskCard.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
155
frontend/src/components/dashboard/TaskDetailPanel.tsx
Normal file
155
frontend/src/components/dashboard/TaskDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user