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:
7
frontend/.env.local.example
Normal file
7
frontend/.env.local.example
Normal 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
|
||||
195
frontend/src/components/admin/TeamMemberFormModal.tsx
Normal file
195
frontend/src/components/admin/TeamMemberFormModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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); }}
|
||||
/>
|
||||
|
||||
84
frontend/src/components/dashboard/MemberTaskTooltip.tsx
Normal file
84
frontend/src/components/dashboard/MemberTaskTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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); }}
|
||||
/>
|
||||
|
||||
34
frontend/src/components/dashboard/TeamMemberAvatar.tsx
Normal file
34
frontend/src/components/dashboard/TeamMemberAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
frontend/src/components/dashboard/TeamStatusPanel.tsx
Normal file
215
frontend/src/components/dashboard/TeamStatusPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
14
frontend/src/hooks/useTeamMembers.ts
Normal file
14
frontend/src/hooks/useTeamMembers.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
85
frontend/src/hooks/useTeamMembersAdmin.ts
Normal file
85
frontend/src/hooks/useTeamMembersAdmin.ts
Normal 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
66
frontend/src/lib/apiBase.ts
Normal file
66
frontend/src/lib/apiBase.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
25
frontend/src/lib/taskFormPayload.ts
Normal file
25
frontend/src/lib/taskFormPayload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
121
frontend/src/lib/teamStatus.ts
Normal file
121
frontend/src/lib/teamStatus.ts
Normal 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(' · ')}` : '';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user