Files
eene_dashboard/frontend/src/components/admin/TeamMemberFormModal.tsx
EENE Dashboard fb2956b0ac 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>
2026-06-06 01:41:00 +09:00

196 lines
6.8 KiB
TypeScript

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,
);
}