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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user