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>
196 lines
6.8 KiB
TypeScript
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,
|
|
);
|
|
}
|