feat: team org panel, admin CRUD, local deploy tools, bidirectional data sync

Add TeamMember model and APIs, team status UI, /admin page, local server bats,
and scripts to sync data between local PostgreSQL and Render.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-06 01:41:00 +09:00
parent d14ff1997c
commit fb2956b0ac
45 changed files with 4104 additions and 376 deletions

View File

@@ -0,0 +1,7 @@
# 로컬 백엔드 강제 지정 (선택 — 미설정 시 자동 감지)
# 개발(npm run dev): Vite 프록시 /api → localhost:4000
# 사설망 IP 접속: 자동으로 http://<이PC IP>:4000 연결
# Vercel 배포: Render API 사용
# VITE_API_URL=http://localhost:4000
# VITE_SOCKET_URL=http://localhost:4000

View File

@@ -0,0 +1,195 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import type { TeamMemberForm } from '../../hooks/useTeamMembersAdmin';
import { EMPTY_MEMBER_FORM } from '../../hooks/useTeamMembersAdmin';
import { TeamMemberAvatar } from '../dashboard/TeamMemberAvatar';
import { getCellLabel } from '../../lib/teamStatus';
import type { TeamMemberBrief } from '../../types';
const CELL_OPTIONS = [
{ value: '리더', label: '리더 (팀장)' },
{ value: 'HR', label: '인사' },
{ value: '총무', label: '총무' },
];
const RANK_OPTIONS = ['수석연구원', '책임연구원', '선임연구원', '연구원', '주임', '사원'];
const ROLE_OPTIONS = ['팀장', '셀장', '팀원'];
interface TeamMemberFormModalProps {
mode: 'add' | 'edit';
initial?: TeamMemberForm;
onSave: (form: TeamMemberForm) => void | Promise<void>;
onClose: () => void;
saving?: boolean;
}
export function TeamMemberFormModal({
mode,
initial = EMPTY_MEMBER_FORM,
onSave,
onClose,
saving = false,
}: TeamMemberFormModalProps) {
const [form, setForm] = useState<TeamMemberForm>(initial);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const set = <K extends keyof TeamMemberForm>(key: K, value: TeamMemberForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handlePhotoFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingPhoto(true);
try {
const fd = new FormData();
fd.append('photo', file);
const { data } = await apiClient.post<{ url: string }>('/team-members/photo', fd);
set('photoUrl', data.url);
} catch (err) {
alert(getApiErrorMessage(err, '사진 업로드에 실패했습니다.'));
} finally {
setUploadingPhoto(false);
e.target.value = '';
}
};
const preview: TeamMemberBrief = {
id: 'preview',
name: form.name || '이름',
rank: form.rank || null,
role: form.role || null,
cell: form.cell || null,
contact: form.contact || null,
photoUrl: form.photoUrl || null,
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) return;
await onSave(form);
};
return createPortal(
<div className="admin-modal-backdrop" onClick={onClose}>
<div className="admin-modal" onClick={(e) => e.stopPropagation()}>
<div className="admin-modal-header">
<h2>{mode === 'add' ? '팀원 추가' : '팀원 수정'}</h2>
<button type="button" className="admin-modal-close" onClick={onClose} aria-label="닫기">
</button>
</div>
<form onSubmit={handleSubmit} className="admin-modal-body">
<div className="admin-form-preview">
<TeamMemberAvatar member={preview} size="leader" />
<div>
<div className="admin-preview-name">{form.name || '이름'}</div>
<div className="admin-preview-sub">
{[form.rank, form.role].filter(Boolean).join(' · ') || '직급 · 직책'}
</div>
<div className="admin-preview-cell">{getCellLabel(form.cell) || '셀'}</div>
</div>
</div>
<div className="admin-form-grid">
<label className="admin-field admin-field-full">
<span> *</span>
<input
required
value={form.name}
onChange={(e) => set('name', e.target.value)}
placeholder="홍길동"
/>
</label>
<label className="admin-field">
<span></span>
<input
list="rank-options"
value={form.rank}
onChange={(e) => set('rank', e.target.value)}
placeholder="선임연구원"
/>
</label>
<label className="admin-field">
<span></span>
<input
list="role-options"
value={form.role}
onChange={(e) => set('role', e.target.value)}
placeholder="팀원"
/>
</label>
<label className="admin-field">
<span> </span>
<select value={form.cell} onChange={(e) => set('cell', e.target.value)}>
{CELL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="admin-field">
<span> </span>
<input
type="number"
min={0}
value={form.sortOrder}
onChange={(e) => set('sortOrder', Number(e.target.value))}
/>
</label>
<label className="admin-field admin-field-full">
<span></span>
<input
value={form.contact}
onChange={(e) => set('contact', e.target.value)}
placeholder="010-0000-0000"
/>
</label>
<div className="admin-field admin-field-full admin-photo-field">
<span> </span>
<div className="admin-photo-upload-row">
<label className="admin-photo-file-btn">
{uploadingPhoto ? '업로드 중…' : '📷 사진 파일 선택'}
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="sr-only"
disabled={uploadingPhoto}
onChange={handlePhotoFile}
/>
</label>
<span className="admin-photo-hint"> uploads/team/ </span>
</div>
<input
value={form.photoUrl}
onChange={(e) => set('photoUrl', e.target.value)}
placeholder="또는 URL 직접 입력 (/uploads/team/...)"
/>
</div>
</div>
<datalist id="rank-options">
{RANK_OPTIONS.map((r) => <option key={r} value={r} />)}
</datalist>
<datalist id="role-options">
{ROLE_OPTIONS.map((r) => <option key={r} value={r} />)}
</datalist>
<div className="admin-modal-footer">
<button type="button" className="admin-btn-ghost" onClick={onClose}></button>
<button type="submit" className="admin-btn-primary" disabled={saving || !form.name.trim()}>
{saving ? '저장 중…' : mode === 'add' ? '추가하기' : '저장하기'}
</button>
</div>
</form>
</div>
</div>,
document.body,
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import type { Task } from '../../types';
import type { Task, TeamMember } from '../../types';
import { normalizeTaskType, displayFlagsForTaskType } from '../../lib/taskType';
const STATUS_OPTIONS = [
@@ -28,6 +28,8 @@ export interface TaskFormData {
showIssue: boolean;
showProgress: boolean;
keywords: string;
pmMemberId: string;
assigneeMemberIds: string[];
}
interface TaskModalProps {
@@ -36,11 +38,21 @@ interface TaskModalProps {
defaultSection?: string;
defaultQuarter?: string;
sectionOptions?: { value: string; label: string }[];
teamMembers?: TeamMember[];
onSave: (data: TaskFormData) => void;
onClose: () => void;
}
export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter = '2026-Q2', sectionOptions, onSave, onClose }: TaskModalProps) {
export function TaskModal({
mode,
task,
defaultSection = 'HR',
defaultQuarter = '2026-Q2',
sectionOptions,
teamMembers = [],
onSave,
onClose,
}: TaskModalProps) {
const toDateInput = (iso: string | null | undefined) => {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
@@ -65,8 +77,22 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
showIssue: task?.showIssue ?? true,
showProgress: task?.showProgress ?? true,
keywords: task?.keywords ?? '',
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
});
const toggleAssignee = (memberId: string) => {
setForm((prev) => {
const has = prev.assigneeMemberIds.includes(memberId);
return {
...prev,
assigneeMemberIds: has
? prev.assigneeMemberIds.filter((id) => id !== memberId)
: [...prev.assigneeMemberIds, memberId],
};
});
};
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
setForm(prev => ({ ...prev, [field]: value }));
@@ -226,6 +252,53 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
/>
</div>
{/* PM + 담당자 */}
{teamMembers.length > 0 && (
<div className="space-y-3 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">PM</label>
<select
value={form.pmMemberId}
onChange={(e) => set('pmMemberId', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
>
<option value=""> </option>
{teamMembers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}{m.rank ? ` · ${m.rank}` : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> ( )</label>
<div className="flex flex-wrap gap-2">
{teamMembers.map((m) => {
const checked = form.assigneeMemberIds.includes(m.id);
return (
<label
key={m.id}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border cursor-pointer select-none text-sm font-semibold transition ${
checked
? 'bg-emerald-600 text-white border-emerald-600'
: 'bg-white text-gray-600 border-gray-200 hover:border-emerald-300'
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleAssignee(m.id)}
/>
{m.name}
</label>
);
})}
</div>
</div>
</div>
)}
{/* 키워드 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> ( )</label>

View File

@@ -1,186 +1,382 @@
import { useState } from 'react';
import { isDetailWindowOpen } from '../../lib/dualMonitor';
import {
FILTER_ALL,
isStatusChipActive,
type CoreStatusFilter,
} from '../../lib/statusFilters';
import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
interface Stats {
total: number;
inProgress: number;
review: number;
done: number;
issues: number;
}
interface DashboardHeaderProps {
quarter: string;
stats: Stats;
activeFilters: string[];
issueFilterActive: boolean;
onToggleAll: () => void;
onToggleStatus: (key: CoreStatusFilter) => void;
onToggleIssue: () => void;
onOpenDetailWindow: () => void | Promise<void>;
onOpenTaskManager: () => void;
}
const STAT_ACCENT = {
: 'text-[#ffdb3a]',
IN_PROGRESS: 'text-[#10b981]',
REVIEW: 'text-[#ff9f0a]',
DONE: 'text-[#b0b0b0]',
ISSUES: 'text-[#ff5252]',
} as const;
type StatKey = keyof typeof STAT_ACCENT;
export function DashboardHeader({
quarter,
stats,
activeFilters,
issueFilterActive,
onToggleAll,
onToggleStatus,
onToggleIssue,
onOpenDetailWindow,
onOpenTaskManager,
}: DashboardHeaderProps) {
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
const handleOpenDetailWindow = () => {
void Promise.resolve(onOpenDetailWindow()).then(() => {
setDetailViewActive(isDetailWindowOpen());
});
};
const statItems: Array<{
label: string;
value: number;
statusKey: StatKey;
onClick: () => void;
isActive: boolean;
}> = [
{
label: '전체',
value: stats.total,
statusKey: '전체',
onClick: onToggleAll,
isActive: isStatusChipActive(FILTER_ALL, activeFilters, issueFilterActive),
},
{
label: '진행',
value: stats.inProgress,
statusKey: 'IN_PROGRESS',
onClick: () => onToggleStatus('IN_PROGRESS'),
isActive: isStatusChipActive('IN_PROGRESS', activeFilters, issueFilterActive),
},
{
label: '보류',
value: stats.review,
statusKey: 'REVIEW',
onClick: () => onToggleStatus('REVIEW'),
isActive: isStatusChipActive('REVIEW', activeFilters, issueFilterActive),
},
{
label: '완료',
value: stats.done,
statusKey: 'DONE',
onClick: () => onToggleStatus('DONE'),
isActive: isStatusChipActive('DONE', activeFilters, issueFilterActive),
import { useState } from 'react';
import { isDetailWindowOpen } from '../../lib/dualMonitor';
import {
FILTER_ALL,
isStatusChipActive,
type CoreStatusFilter,
} from '../../lib/statusFilters';
import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
interface Stats {
total: number;
inProgress: number;
review: number;
done: number;
issues: number;
}
interface DashboardHeaderProps {
quarter: string;
stats: Stats;
activeFilters: string[];
issueFilterActive: boolean;
onToggleAll: () => void;
onToggleStatus: (key: CoreStatusFilter) => void;
onToggleIssue: () => void;
onOpenDetailWindow: () => void | Promise<void>;
onOpenTaskManager: () => void;
teamPanelOpen: boolean;
onToggleTeamPanel: () => void;
}
const STAT_ACCENT = {
: 'text-[#ffdb3a]',
IN_PROGRESS: 'text-[#10b981]',
REVIEW: 'text-[#ff9f0a]',
DONE: 'text-[#b0b0b0]',
ISSUES: 'text-[#ff5252]',
} as const;
type StatKey = keyof typeof STAT_ACCENT;
export function DashboardHeader({
quarter,
stats,
activeFilters,
issueFilterActive,
onToggleAll,
onToggleStatus,
onToggleIssue,
onOpenDetailWindow,
onOpenTaskManager,
teamPanelOpen,
onToggleTeamPanel,
}: DashboardHeaderProps) {
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
const handleOpenDetailWindow = () => {
void Promise.resolve(onOpenDetailWindow()).then(() => {
setDetailViewActive(isDetailWindowOpen());
});
};
const statItems: Array<{
label: string;
value: number;
statusKey: StatKey;
onClick: () => void;
isActive: boolean;
}> = [
{
label: '전체',
value: stats.total,
statusKey: '전체',
onClick: onToggleAll,
isActive: isStatusChipActive(FILTER_ALL, activeFilters, issueFilterActive),
},
{
label: '진행',
value: stats.inProgress,
statusKey: 'IN_PROGRESS',
onClick: () => onToggleStatus('IN_PROGRESS'),
isActive: isStatusChipActive('IN_PROGRESS', activeFilters, issueFilterActive),
},
{
label: '보류',
value: stats.review,
statusKey: 'REVIEW',
onClick: () => onToggleStatus('REVIEW'),
isActive: isStatusChipActive('REVIEW', activeFilters, issueFilterActive),
},
{
label: '완료',
value: stats.done,
statusKey: 'DONE',
onClick: () => onToggleStatus('DONE'),
isActive: isStatusChipActive('DONE', activeFilters, issueFilterActive),
},
{
label: '이슈',
value: stats.issues,
statusKey: 'ISSUES',
onClick: onToggleIssue,
isActive: issueFilterActive,
},
];
return (
<header className="dashboard-header-bar shrink-0">
<div className="side-left-group min-w-0 shrink-0">
<span className="side-title-main main_tit flex shrink-0 items-center gap-[10px] text-[20px] font-bold tracking-[-0.5px] text-[#bad8ca]">
<span></span>
<span>|</span>
<span>People Growth Hub</span>
</span>
<button
type="button"
title="팀 현황"
className={`team-status-btn-new ${teamPanelOpen ? 'active' : ''}`}
onClick={onToggleTeamPanel}
>
<UsersIcon size={16} />
</button>
</div>
<div className="header-stats-bar side-polygon-stats">
<div className="poly-stat-item" style={{ fontSize: '18px', gap: '8px' }}>
<span className="poly-stat-quarter header-stat-text">{quarterLabel}</span>
<span className="poly-stat-bullet header-stat-text">·</span>
{statItems.map((item, index) => (
<span key={item.statusKey} className="contents">
{(index === 1 || index === 4) && <StatDivider />}
<StatClick
label={item.label}
value={item.value}
accent={STAT_ACCENT[item.statusKey]}
isActive={item.isActive}
onClick={item.onClick}
/>
</span>
))}
</div>
</div>
<div className="side-right-actions shrink-0">
<button type="button" onClick={onOpenTaskManager} title="신규 프로젝트 추가" className="header-action-btn-new">
<PlusIcon size={16} />
</button>
<button
type="button"
onClick={handleOpenDetailWindow}
title="듀얼뷰"
className={`header-view-btn-new ${detailViewActive ? 'active' : ''}`}
>
<DualMonitorIcon size={16} />
</button>
</div>
</header>
);
}
function StatDivider() {
return <div className="poly-stat-divider" aria-hidden />;
}
interface StatClickProps {
label: string;
value: number;
accent: string;
isActive: boolean;
onClick: () => void;
}
function StatClick({ label, value, accent, isActive, onClick }: StatClickProps) {
return (
<span
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
className={`poly-click-stat header-stat-text ${isActive ? 'active' : ''}`}
style={{ cursor: 'pointer', padding: '2px 6px', borderRadius: '4px' }}
>
{label}{' '}
<span className={`poly-stat-val ${accent}`}>{value}</span>
<span className="poly-stat-unit"> </span>
</span>
);
}

View File

@@ -9,7 +9,8 @@ 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';
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
import type { Task, TeamMember } from '../../types';
interface DepartmentColumnProps {
title: string;
@@ -26,6 +27,7 @@ interface DepartmentColumnProps {
headerAlign?: 'left' | 'right';
onSelectTask?: (task: Task) => void;
sectionOptions?: { value: string; label: string }[];
teamMembers?: TeamMember[];
}
// ── 헤더 편집 팝업 ──────────────────────────────────────────
@@ -94,7 +96,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
);
}
export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, orderedIds, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions }: DepartmentColumnProps) {
export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, orderedIds, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions, teamMembers = [] }: DepartmentColumnProps) {
const queryClient = useQueryClient();
// ── 컬럼 설정 API ─────────────────────────────────────────
@@ -184,23 +186,8 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const handleAdd = async (data: TaskFormData) => {
try {
await create.mutateAsync({
title: data.title,
section: data.section || 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,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
quarter: data.quarter,
priority: 'MEDIUM',
...taskFormToApiPayload(data),
priority: 'MEDIUM',
} as Partial<Task>);
setShowAddModal(false);
} catch (err: unknown) {
@@ -212,16 +199,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
if (!editingTask) return;
patch.mutate({
id: editingTask.id,
data: {
title: data.title, section: data.section || 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,
showDate: data.showDate, showDescription: data.showDescription,
showStatus: data.showStatus, showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
},
data: taskFormToApiPayload(data),
});
setShowEditModal(false);
setEditingTask(null);
@@ -343,6 +321,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
defaultSection={section}
defaultQuarter={quarter}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleAdd}
onClose={() => setShowAddModal(false)}
/>
@@ -353,6 +332,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
mode="edit"
task={editingTask}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleEdit}
onClose={() => { setShowEditModal(false); setEditingTask(null); }}
/>

View File

@@ -0,0 +1,84 @@
import type { Task } from '../../types';
import { getMemberTasks, taskStatusBadge, taskSubtitle } from '../../lib/teamStatus';
interface MemberTaskTooltipProps {
memberId: string;
tasks: Task[];
isStatic: boolean;
activeProjectId: string | null;
onProjectClick: (taskId: string | null) => void;
}
export function MemberTaskTooltip({
memberId,
tasks,
isStatic,
activeProjectId,
onProjectClick,
}: MemberTaskTooltipProps) {
const memberTasks = getMemberTasks(memberId, tasks);
if (memberTasks.length === 0) return null;
return (
<div className={`member-tooltip ${isStatic ? 'is-static' : ''}`}>
<div className="tooltip-header">
({memberTasks.length})
</div>
<div className="tooltip-list">
{memberTasks.map((task) => {
const badge = taskStatusBadge(task);
const isActive = activeProjectId === task.id;
const subtitle = taskSubtitle(task);
return (
<div
key={task.id}
className={`tooltip-item ${isActive ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onProjectClick(isActive ? null : task.id);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onProjectClick(isActive ? null : task.id);
}
}}
>
<div className="tooltip-item-row">
<span className="tooltip-dot" aria-hidden />
<div className="tooltip-item-body">
<div className="tooltip-title">
{task.title}
{subtitle && <span className="tooltip-sub">{subtitle}</span>}
</div>
{isActive && (
<div className="tooltip-project-detail">
<span>
PM: <strong>{task.pmMember?.name ?? '미정'}</strong>
</span>
<span>
:{' '}
<strong>
{task.assigneeMembers?.length
? task.assigneeMembers.map((m) => m.name).join(', ')
: '미정'}
</strong>
</span>
</div>
)}
</div>
<span className={`tooltip-badge tooltip-badge-${badge.variant}`}>
{badge.label}
</span>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -4,8 +4,9 @@ import { createPortal } from 'react-dom';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
import type { Task, TeamMember } from '../../types';
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
@@ -24,10 +25,11 @@ interface TaskManagerProps {
tasks: Task[];
sectionOptions: { value: string; label: string }[];
quarter: string;
teamMembers?: TeamMember[];
onClose: () => void;
}
export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskManagerProps) {
export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [], onClose }: TaskManagerProps) {
const queryClient = useQueryClient();
const [filterSection, setFilterSection] = useState<string>('전체');
const [filterType, setFilterType] = useState<string>('전체');
@@ -64,18 +66,8 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
const handleAdd = async (data: TaskFormData) => {
try {
await create.mutateAsync({
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,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
quarter: data.quarter,
priority: 'MEDIUM',
...taskFormToApiPayload(data),
priority: 'MEDIUM',
});
setModalMode(null);
} catch (err: unknown) {
@@ -87,18 +79,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
if (!editingTask) return;
patch.mutate({
id: editingTask.id,
data: {
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,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
},
data: taskFormToApiPayload(data),
});
setModalMode(null);
setEditingTask(null);
@@ -232,6 +213,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
defaultSection={filterSection !== '전체' ? filterSection : '인사관리'}
defaultQuarter={quarter}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleAdd}
onClose={() => setModalMode(null)}
/>
@@ -241,6 +223,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
mode="edit"
task={editingTask}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleEdit}
onClose={() => { setModalMode(null); setEditingTask(null); }}
/>

View File

@@ -0,0 +1,34 @@
import { useState } from 'react';
import { staticAssetUrl } from '../../lib/apiBase';
import type { TeamMemberBrief } from '../../types';
interface TeamMemberAvatarProps {
member: TeamMemberBrief;
className?: string;
size?: 'leader' | 'member';
}
export function TeamMemberAvatar({ member, className = '', size = 'member' }: TeamMemberAvatarProps) {
const [imgError, setImgError] = useState(false);
const initial = member.name?.charAt(0) ?? '?';
const isLeader = size === 'leader';
const photoSrc = staticAssetUrl(member.photoUrl);
if (photoSrc && !imgError) {
return (
<img
src={photoSrc}
alt={member.name}
className={`${isLeader ? 'leader-avatar' : 'member-avatar'} ${className}`}
onError={() => setImgError(true)}
/>
);
}
return (
<div className={`${isLeader ? 'leader-avatar' : 'member-avatar'} ${className}`}>
{initial}
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import type { Task, TeamMember } from '../../types';
import {
getCellLabel,
getHighlightMemberIds,
groupTeamMembers,
} from '../../lib/teamStatus';
import { TeamMemberAvatar } from './TeamMemberAvatar';
import { MemberTaskTooltip } from './MemberTaskTooltip';
import { UsersIcon } from './HeaderIcons';
function LayersIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>
);
}
function CloseIcon({ size = 20 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
interface TeamStatusPanelProps {
members: TeamMember[];
tasks: Task[];
showAllTasks: boolean;
activeProjectId: string | null;
onToggleShowAll: () => void;
onProjectClick: (taskId: string | null) => void;
onClose: () => void;
}
function MemberInfo({
member,
showAllTasks,
isLeader,
}: {
member: TeamMember;
showAllTasks: boolean;
isLeader: boolean;
}) {
if (showAllTasks) {
return (
<>
<span className="member-name">{member.name}</span>
{member.rank && <span className="member-rank">{member.rank}</span>}
{member.role && <span className="member-role">{member.role}</span>}
{member.contact && <span className="member-contact">{member.contact}</span>}
</>
);
}
if (isLeader) {
return (
<>
<div className="leader-name-row">
<span className="leader-name">{member.name}</span>
<span className="leader-sub">
{[member.rank, member.role].filter(Boolean).join(' · ')}
</span>
</div>
{member.contact && <span className="member-contact">{member.contact}</span>}
</>
);
}
return (
<>
<div className="member-name-row">
<span className="member-name">{member.name}</span>
<span className="member-role">
{[member.rank, member.role].filter(Boolean).join(' · ')}
</span>
</div>
{member.contact && <span className="member-contact">{member.contact}</span>}
</>
);
}
export function TeamStatusPanel({
members,
tasks,
showAllTasks,
activeProjectId,
onToggleShowAll,
onProjectClick,
onClose,
}: TeamStatusPanelProps) {
const activeTask = activeProjectId ? tasks.find((t) => t.id === activeProjectId) : null;
const highlightIds = useMemo(() => getHighlightMemberIds(activeTask), [activeTask]);
const { groups, cellKeys } = useMemo(() => groupTeamMembers(members), [members]);
const visibleCells = cellKeys.filter((key) => (groups[key]?.length ?? 0) > 0);
const leaders = groups. ?? [];
if (members.length === 0) {
return (
<div className="team-overlay">
<div className="team-panel-header">
<div className="team-panel-title">
<UsersIcon size={20} />
<span> </span>
<span className="team-total-badge">0</span>
</div>
<button type="button" className="team-close-btn" onClick={onClose} aria-label="닫기">
<CloseIcon />
</button>
</div>
<div className="team-tree-scroll team-empty-state">
<p> .</p>
<p className="team-empty-hint">
<Link to="/admin" className="admin-team-manage-link"> </Link>
.
</p>
</div>
</div>
);
}
const renderMemberCard = (member: TeamMember, isLeader: boolean) => {
const highlighted = highlightIds.includes(member.id);
const cardClass = isLeader ? 'tree-leader-card' : 'tree-member-card';
return (
<div
key={member.id}
className={`${cardClass} ${highlighted ? 'highlighted-member' : ''}`}
onClick={(e) => e.stopPropagation()}
>
<TeamMemberAvatar member={member} size={isLeader ? 'leader' : 'member'} />
<div className={isLeader ? 'leader-info' : 'member-info-wrap'}>
<MemberInfo member={member} showAllTasks={showAllTasks} isLeader={isLeader} />
</div>
<MemberTaskTooltip
memberId={member.id}
tasks={tasks}
isStatic={showAllTasks}
activeProjectId={activeProjectId}
onProjectClick={onProjectClick}
/>
</div>
);
};
return (
<div className="team-overlay">
<div className="team-panel-header">
<div className="team-panel-title">
<UsersIcon size={20} />
<span> </span>
<span className="team-total-badge">{members.length}</span>
</div>
<div className="team-panel-actions">
<button
type="button"
className={`team-view-toggle ${showAllTasks ? 'active' : ''}`}
onClick={onToggleShowAll}
>
<LayersIcon size={14} />
<span> </span>
</button>
<button type="button" className="team-close-btn" onClick={onClose} aria-label="닫기">
<CloseIcon />
</button>
</div>
</div>
<div
className={`team-tree-scroll ${showAllTasks ? 'show-all-tooltips' : ''}`}
onClick={() => onProjectClick(null)}
>
{leaders.length > 0 && (
<>
<div className="tree-leaders-row">
{leaders.map((m) => renderMemberCard(m, true))}
</div>
{visibleCells.length > 0 && <div className="tree-root-vline" />}
</>
)}
{visibleCells.length > 0 && (
<div className="tree-cells-row">
{visibleCells.map((cellKey, index, arr) => {
const cellMembers = groups[cellKey] ?? [];
return (
<div
key={cellKey}
className={`tree-cell-col ${index === arr.length - 1 ? 'last' : ''}`}
>
<div className="tree-cell-hline-wrap" aria-hidden />
<div className="tree-cell-card static">
<span className="tree-cell-name">{getCellLabel(cellKey)}</span>
<span className="tree-badge">{cellMembers.length}</span>
</div>
<div className="tree-members-list">
{cellMembers.map((m) => renderMemberCard(m, false))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,24 +1,17 @@
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
import { io, type Socket } from 'socket.io-client';
import { getSocketUrl } from '../lib/apiBase';
const SocketContext = createContext<Socket | null>(null);
const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
const SOCKET_URL =
import.meta.env.VITE_SOCKET_URL ||
(import.meta.env.PROD
? RENDER_API
: `${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'] });
const socket = io(getSocketUrl(), { transports: ['websocket'] });
socketRef.current = socket;
socket.on('connect', () => console.log('[Socket] Connected'));
socket.on('connect', () => console.log('[Socket] Connected', getSocketUrl()));
socket.on('disconnect', () => console.log('[Socket] Disconnected'));
return () => {

View File

@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import type { TeamMember } from '../types';
export function useTeamMembers() {
return useQuery({
queryKey: ['team-members'],
queryFn: async () => {
const { data } = await apiClient.get<TeamMember[]>('/team-members');
return data;
},
staleTime: 60_000,
});
}

View File

@@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../lib/apiClient';
import type { TeamMember } from '../types';
export interface TeamMemberForm {
name: string;
rank: string;
role: string;
cell: string;
contact: string;
photoUrl: string;
sortOrder: number;
}
export const EMPTY_MEMBER_FORM: TeamMemberForm = {
name: '',
rank: '',
role: '',
cell: 'HR',
contact: '',
photoUrl: '',
sortOrder: 0,
};
export function memberToForm(member: TeamMember): TeamMemberForm {
return {
name: member.name,
rank: member.rank ?? '',
role: member.role ?? '',
cell: member.cell ?? '리더',
contact: member.contact ?? '',
photoUrl: member.photoUrl ?? '',
sortOrder: member.sortOrder ?? 0,
};
}
export function formToPayload(form: TeamMemberForm) {
return {
name: form.name.trim(),
rank: form.rank.trim() || null,
role: form.role.trim() || null,
cell: form.cell.trim() || null,
contact: form.contact.trim() || null,
photoUrl: form.photoUrl.trim() || null,
sortOrder: Number(form.sortOrder) || 0,
};
}
export function useTeamMembersAdmin() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['team-members', 'admin'],
queryFn: async () => {
const { data } = await apiClient.get<TeamMember[]>('/team-members', {
params: { all: '1' },
});
return data.filter((m) => m.isActive !== false);
},
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['team-members'] });
queryClient.invalidateQueries({ queryKey: ['team-members', 'admin'] });
};
const create = useMutation({
mutationFn: (form: TeamMemberForm) =>
apiClient.post('/team-members', formToPayload(form)),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, form }: { id: string; form: TeamMemberForm }) =>
apiClient.patch(`/team-members/${id}`, formToPayload(form)),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => apiClient.delete(`/team-members/${id}`),
onSuccess: invalidate,
});
return { query, create, update, remove, getApiErrorMessage };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
/** 배포(Render) 백엔드 — Vercel 등 외부 호스팅용 */
export const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
/** 사설망·로컬 IP 여부 (로컬 서버 우선 연결) */
export function isLocalNetworkHost(hostname: string): boolean {
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^10\./.test(hostname)
);
}
/** API·소켓·정적 파일이 붙는 백엔드 origin (프로토콜+호스트+포트) */
export function getBackendOrigin(): string {
const envUrl = import.meta.env.VITE_API_URL || import.meta.env.VITE_SOCKET_URL;
if (envUrl) {
return String(envUrl).replace(/\/$/, '');
}
if (import.meta.env.DEV && typeof window !== 'undefined') {
return `${window.location.protocol}//${window.location.hostname}:4000`;
}
if (typeof window !== 'undefined' && isLocalNetworkHost(window.location.hostname)) {
return `${window.location.protocol}//${window.location.hostname}:4000`;
}
return RENDER_API;
}
/** REST API base (/api 포함) */
export function getApiBaseUrl(): string {
if (import.meta.env.VITE_API_URL) {
return `${import.meta.env.VITE_API_URL.replace(/\/$/, '')}/api`;
}
if (import.meta.env.DEV) {
return '/api';
}
if (typeof window !== 'undefined' && isLocalNetworkHost(window.location.hostname)) {
return `${getBackendOrigin()}/api`;
}
return `${RENDER_API}/api`;
}
export function getSocketUrl(): string {
if (import.meta.env.VITE_SOCKET_URL) {
return import.meta.env.VITE_SOCKET_URL;
}
return getBackendOrigin();
}
/** /uploads/... 경로를 브라우저에서 열 수 있는 URL로 변환 */
export function staticAssetUrl(path: string | null | undefined): string {
if (!path) return '';
if (/^https?:\/\//i.test(path) || path.startsWith('data:')) return path;
const normalized = path.startsWith('/') ? path : `/${path}`;
if (import.meta.env.DEV) {
return normalized;
}
return `${getBackendOrigin()}${normalized}`;
}

View File

@@ -1,13 +1,10 @@
import axios from 'axios';
import { getApiBaseUrl } from './apiBase';
// 개발: Vite 프록시 /api (localhost:4000)
// 배포: VITE_API_URL 미설정 시 Render 백엔드 기본값 사용
const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
const baseURL = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/api`
: import.meta.env.PROD
? `${RENDER_API}/api`
: '/api';
// 개발: Vite 프록시 /api localhost:4000
// 사설망 IP 접속: 자동으로 같은 IP:4000 백엔드
// Vercel 배포: Render API (VITE_API_URL로 오버라이드 가능)
const baseURL = getApiBaseUrl();
export const apiClient = axios.create({
baseURL,

View File

@@ -0,0 +1,25 @@
import type { TaskFormData } from '../components/common/TaskModal';
export function taskFormToApiPayload(data: TaskFormData): Record<string, unknown> {
return {
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,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
quarter: data.quarter,
pmMemberId: data.pmMemberId || null,
assigneeMemberIds: data.assigneeMemberIds,
};
}

View File

@@ -0,0 +1,121 @@
import type { Task, TeamMember } from '../types';
import { isRoutineTask } from './taskType';
/** 셀 컬럼 기본 순서 — 팀원 데이터에 다른 셀이 있으면 뒤에 추가 */
export const DEFAULT_CELL_ORDER = ['HR', '총무'] as const;
/** DB 저장값 → 화면 표시명 (저장값은 그대로, 라벨만 변경) */
const CELL_DISPLAY_LABELS: Record<string, string> = {
HR: '인사',
};
export function getCellLabel(cell: string | null | undefined): string {
if (!cell) return '';
return CELL_DISPLAY_LABELS[cell] ?? cell;
}
const LEADER_CELLS = new Set(['', '리더', '팀장']);
export function isLeaderCell(cell: string | null | undefined): boolean {
if (!cell || LEADER_CELLS.has(cell)) return true;
return false;
}
const RANK_ORDER: Record<string, number> = {
수석연구원: 1,
책임연구원: 2,
선임연구원: 3,
연구원: 4,
};
const CELL_ORDER: Record<string, number> = {
HR: 1,
총무: 2,
};
export function compareTeamMembers(a: TeamMember, b: TeamMember): number {
const aLeader = a.role?.includes('팀장') || isLeaderCell(a.cell);
const bLeader = b.role?.includes('팀장') || isLeaderCell(b.cell);
if (aLeader && !bLeader) return -1;
if (!aLeader && bLeader) return 1;
const aCell = CELL_ORDER[a.cell ?? ''] ?? 99;
const bCell = CELL_ORDER[b.cell ?? ''] ?? 99;
if (aCell !== bCell) return aCell - bCell;
const aCellLead = a.role?.includes('셀장');
const bCellLead = b.role?.includes('셀장');
if (aCellLead && !bCellLead) return -1;
if (!aCellLead && bCellLead) return 1;
const aRank = RANK_ORDER[a.rank ?? ''] ?? 99;
const bRank = RANK_ORDER[b.rank ?? ''] ?? 99;
if (aRank !== bRank) return aRank - bRank;
const aOrder = a.sortOrder ?? 0;
const bOrder = b.sortOrder ?? 0;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name, 'ko');
}
export function groupTeamMembers(members: TeamMember[]) {
const cellKeys: string[] = [...DEFAULT_CELL_ORDER];
members.forEach((m) => {
if (m.cell && !isLeaderCell(m.cell) && !cellKeys.includes(m.cell)) {
cellKeys.push(m.cell);
}
});
const groups: Record<string, TeamMember[]> = { : [] };
cellKeys.forEach((k) => { groups[k] = []; });
members.forEach((m) => {
if (isLeaderCell(m.cell) || m.role?.includes('팀장')) {
groups..push(m);
} else if (m.cell && groups[m.cell]) {
groups[m.cell].push(m);
} else {
groups..push(m);
}
});
Object.keys(groups).forEach((key) => {
groups[key].sort(compareTeamMembers);
});
return { groups, cellKeys };
}
export function getMemberTasks(memberId: string, tasks: Task[]): Task[] {
return tasks.filter((t) => {
if (t.status === 'DONE' || t.status === 'CANCELLED') return false;
if (t.pmMember?.id === memberId) return true;
return t.assigneeMembers?.some((a) => a.id === memberId) ?? false;
});
}
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
if (!task) return [];
const ids: string[] = [];
if (task.pmMember?.id) ids.push(task.pmMember.id);
task.assigneeMembers?.forEach((m) => {
if (m.id && !ids.includes(m.id)) ids.push(m.id);
});
return ids;
}
export function taskStatusBadge(task: Task): { label: string; variant: 'always' | 'hold' | 'wait' | 'progress' | 'done' } {
if (task.taskType && isRoutineTask(task.taskType)) {
return { label: '상시', variant: 'always' };
}
if (task.status === 'DONE') return { label: '완료', variant: 'done' };
if (task.status === 'REVIEW') return { label: '홀딩', variant: 'hold' };
if (task.status === 'TODO') return { label: '대기', variant: 'wait' };
return { label: `${task.progress}%`, variant: 'progress' };
}
export function taskSubtitle(task: Task): string {
const parts = [task.section, task.tag].filter(Boolean);
return parts.length ? `| ${parts.join(' · ')}` : '';
}

View File

@@ -1,4 +1,234 @@
// TODO: 관리자 페이지 UI 구현 예정
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { TeamMemberFormModal } from '../components/admin/TeamMemberFormModal';
import { TeamMemberAvatar } from '../components/dashboard/TeamMemberAvatar';
import {
EMPTY_MEMBER_FORM,
memberToForm,
useTeamMembersAdmin,
type TeamMemberForm,
} from '../hooks/useTeamMembersAdmin';
import { getCellLabel, groupTeamMembers, isLeaderCell } from '../lib/teamStatus';
import type { TeamMember } from '../types';
export default function AdminPage() {
return <div> - </div>;
const { query, create, update, remove, getApiErrorMessage } = useTeamMembersAdmin();
const members = query.data ?? [];
const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null);
const [editingMember, setEditingMember] = useState<TeamMember | null>(null);
const [defaultCell, setDefaultCell] = useState('HR');
const { groups, cellKeys } = useMemo(() => groupTeamMembers(members), [members]);
const sections = useMemo(() => {
const list: { key: string; label: string; members: TeamMember[] }[] = [];
if ((groups.?.length ?? 0) > 0) {
list.push({ key: '리더', label: '팀장', members: groups.리더 });
}
cellKeys.forEach((key) => {
const cellMembers = groups[key] ?? [];
if (cellMembers.length > 0) {
list.push({ key, label: getCellLabel(key), members: cellMembers });
}
});
return list;
}, [groups, cellKeys]);
const openAdd = (cell = 'HR') => {
setEditingMember(null);
setDefaultCell(cell);
setModalMode('add');
};
const openEdit = (member: TeamMember) => {
setEditingMember(member);
setModalMode('edit');
};
const closeModal = () => {
setModalMode(null);
setEditingMember(null);
};
const handleSave = async (form: TeamMemberForm) => {
try {
if (modalMode === 'add') {
await create.mutateAsync(form);
} else if (editingMember) {
await update.mutateAsync({ id: editingMember.id, form });
}
closeModal();
} catch (err) {
alert(getApiErrorMessage(err, '저장에 실패했습니다.'));
}
};
const handleDelete = async (member: TeamMember) => {
if (!window.confirm(`"${member.name}" 팀원을 삭제(비활성)하시겠습니까?`)) return;
try {
await remove.mutateAsync(member.id);
} catch (err) {
alert(getApiErrorMessage(err, '삭제에 실패했습니다.'));
}
};
const saving = create.isPending || update.isPending;
return (
<div className="admin-page">
<header className="admin-header">
<div className="admin-header-left">
<Link to="/" className="admin-back-link"> </Link>
<div className="admin-header-title">
<span className="admin-header-org"></span>
<span className="admin-header-sep">|</span>
<span> </span>
</div>
</div>
<div className="admin-header-actions">
<button type="button" className="admin-btn-primary" onClick={() => openAdd('리더')}>
+
</button>
</div>
</header>
<div className="admin-toolbar">
<div className="admin-stat-chips">
<span className="admin-stat-chip admin-stat-total">
<strong>{members.length}</strong>
</span>
<span className="admin-stat-chip">
<strong>{groups.?.length ?? 0}</strong>
</span>
<span className="admin-stat-chip">
<strong>{groups.HR?.length ?? 0}</strong>
</span>
<span className="admin-stat-chip">
<strong>{groups.?.length ?? 0}</strong>
</span>
</div>
<p className="admin-toolbar-hint">
<strong> </strong> . PM· .
</p>
</div>
<main className="admin-main">
{query.isLoading && (
<div className="admin-empty"> </div>
)}
{!query.isLoading && members.length === 0 && (
<div className="admin-empty-card">
<h3> </h3>
<p> 1, 2, 2 .</p>
<div className="admin-quick-add">
<button type="button" className="admin-btn-outline" onClick={() => openAdd('리더')}>
+
</button>
<button type="button" className="admin-btn-outline" onClick={() => openAdd('HR')}>
+
</button>
<button type="button" className="admin-btn-outline" onClick={() => openAdd('총무')}>
+
</button>
</div>
</div>
)}
{sections.map((section) => (
<section key={section.key} className="admin-section">
<div className="admin-section-head">
<div className="admin-section-title">
<span>{section.label}</span>
<span className="admin-section-badge">{section.members.length}</span>
</div>
<button
type="button"
className="admin-btn-outline admin-btn-sm"
onClick={() => openAdd(isLeaderCell(section.key) ? '리더' : section.key)}
>
+ {section.label}
</button>
</div>
<div className="admin-member-table-wrap">
<table className="admin-member-table">
<thead>
<tr>
<th className="w-avatar" />
<th></th>
<th> · </th>
<th></th>
<th></th>
<th className="w-actions" />
</tr>
</thead>
<tbody>
{section.members.map((member) => (
<tr key={member.id}>
<td>
<TeamMemberAvatar member={member} size="member" />
</td>
<td>
<span className="admin-member-name">{member.name}</span>
<span className="admin-member-cell-tag">{getCellLabel(member.cell) || '팀장'}</span>
</td>
<td>
{[member.rank, member.role].filter(Boolean).join(' · ') || '—'}
</td>
<td className="admin-contact">{member.contact ?? '—'}</td>
<td className="admin-sort">{member.sortOrder ?? 0}</td>
<td>
<div className="admin-row-actions">
<button
type="button"
className="admin-icon-btn"
title="수정"
onClick={() => openEdit(member)}
>
</button>
<button
type="button"
className="admin-icon-btn admin-icon-btn-danger"
title="삭제"
onClick={() => handleDelete(member)}
>
🗑
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
{members.length > 0 && (
<div className="admin-footer-note">
<Link to="/" className="admin-preview-link">
</Link>
</div>
)}
</main>
{modalMode && (
<TeamMemberFormModal
mode={modalMode}
initial={
modalMode === 'edit' && editingMember
? memberToForm(editingMember)
: { ...EMPTY_MEMBER_FORM, cell: defaultCell }
}
onSave={handleSave}
onClose={closeModal}
saving={saving}
/>
)}
</div>
);
}

View File

@@ -13,7 +13,9 @@ import {
import { arrayMove } from '@dnd-kit/sortable';
import { apiClient } from '../lib/apiClient';
import { useTasks } from '../hooks/useTasks';
import { useTeamMembers } from '../hooks/useTeamMembers';
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel';
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
import { TaskManager } from '../components/dashboard/TaskManager';
import { useSocket } from '../contexts/SocketContext';
@@ -42,6 +44,9 @@ export default function DashboardPage() {
const [filtersBeforeIssue, setFiltersBeforeIssue] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
const [issueFilterActive, setIssueFilterActive] = useState(false);
const [showTaskManager, setShowTaskManager] = useState(false);
const [teamPanelOpen, setTeamPanelOpen] = useState(false);
const [showAllTeamTasks, setShowAllTeamTasks] = useState(false);
const [activeTeamProjectId, setActiveTeamProjectId] = useState<string | null>(null);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
@@ -50,6 +55,7 @@ export default function DashboardPage() {
const socket = useSocket();
const { data: tasks = [], isLoading } = useTasks({ quarter: QUARTER });
const { data: teamMembers = [] } = useTeamMembers();
const { data: colConfigs } = useQuery({
queryKey: ['columns', 'all'],
@@ -248,9 +254,35 @@ export default function DashboardPage() {
onToggleIssue={handleToggleIssue}
onOpenDetailWindow={() => { openDetailWindow(); }}
onOpenTaskManager={() => setShowTaskManager(true)}
teamPanelOpen={teamPanelOpen}
onToggleTeamPanel={() => {
setTeamPanelOpen((open) => {
if (open) {
setActiveTeamProjectId(null);
setShowAllTeamTasks(false);
}
return !open;
});
}}
/>
<main className="relative flex min-h-0 flex-1 overflow-hidden px-5 py-5">
{teamPanelOpen && (
<TeamStatusPanel
members={teamMembers}
tasks={tasks}
showAllTasks={showAllTeamTasks}
activeProjectId={activeTeamProjectId}
onToggleShowAll={() => setShowAllTeamTasks((v) => !v)}
onProjectClick={setActiveTeamProjectId}
onClose={() => {
setTeamPanelOpen(false);
setActiveTeamProjectId(null);
setShowAllTeamTasks(false);
}}
/>
)}
<main className={`relative flex min-h-0 flex-1 overflow-hidden px-5 py-5 ${teamPanelOpen ? 'hidden' : ''}`}>
{/* ── 좌측 라벨 컬럼 ── */}
<div className="mr-4 flex w-16 shrink-0 flex-col overflow-hidden rounded-[2rem] bg-white shadow-[0_16px_40px_rgba(15,23,42,0.12)] ring-1 ring-slate-200/70">
<div className="h-10 shrink-0 border-b border-slate-100 bg-slate-50" />
@@ -283,6 +315,7 @@ export default function DashboardPage() {
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
/>
))}
</div>
@@ -307,6 +340,7 @@ export default function DashboardPage() {
tasks={tasks}
sectionOptions={sectionOptions}
quarter={QUARTER}
teamMembers={teamMembers}
onClose={() => setShowTaskManager(false)}
/>
)}

View File

@@ -12,6 +12,23 @@ export interface User {
createdAt: string;
}
export interface TeamMember {
id: string;
name: string;
rank: string | null;
role: string | null;
cell: string | null;
contact: string | null;
photoUrl: string | null;
sortOrder?: number;
isActive?: boolean;
}
export type TeamMemberBrief = Pick<
TeamMember,
'id' | 'name' | 'rank' | 'role' | 'cell' | 'contact' | 'photoUrl'
>;
export interface Task {
id: string;
title: string;
@@ -35,10 +52,13 @@ export interface Task {
keywords: string | null;
creatorId: string;
assigneeId: string | null;
pmMemberId?: string | null;
createdAt: string;
updatedAt: string;
assignee?: Pick<User, 'id' | 'name' | 'department'> | null;
creator?: Pick<User, 'id' | 'name'>;
pmMember?: TeamMemberBrief | null;
assigneeMembers?: TeamMemberBrief[];
_count?: { files: number; details: number };
}