Skip to content

Commit 733c132

Browse files
authored
feat: 팀 생성 플로우에 팀원 초대 추가
1 parent 0579038 commit 733c132

1 file changed

Lines changed: 290 additions & 25 deletions

File tree

src/app/pages/WorkspacePage.tsx

Lines changed: 290 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ type Invite = {
3333
expiresTime: string;
3434
};
3535

36+
type TeamInviteDraft = {
37+
id: number;
38+
name: string;
39+
email: string;
40+
role: string;
41+
};
42+
3643
const MOCK_GITHUB_REPOS = [
3744
{ id: 1, name: "secure-flow-api", owner: "my_github", relation: "owner", isPrivate: true, language: "TypeScript" },
3845
{ id: 2, name: "auth-middleware", owner: "my_github", relation: "owner", isPrivate: true, language: "TypeScript" },
@@ -46,6 +53,24 @@ const MOCK_GITHUB_REPOS = [
4653
{ id: 10, name: "data-pipeline", owner: "some-org", relation: "collaborator", isPrivate: true, language: "Python" },
4754
];
4855

56+
const SUGGESTED_TEAM_MEMBERS: TeamInviteDraft[] = [
57+
{ id: 1, name: "김재준", email: "jaejun@codedock.dev", role: "Tech Lead" },
58+
{ id: 2, name: "김진아", email: "jinah@codedock.dev", role: "Backend Developer" },
59+
{ id: 3, name: "김진현", email: "jinhyun@codedock.dev", role: "DevOps Engineer" },
60+
{ id: 4, name: "안현", email: "hyun@codedock.dev", role: "QA Engineer" },
61+
];
62+
63+
const TEAM_ROLE_OPTIONS = [
64+
"Tech Lead",
65+
"Backend Developer",
66+
"Frontend Developer",
67+
"DevOps Engineer",
68+
"QA Engineer",
69+
"Product Manager",
70+
"Designer",
71+
"Viewer",
72+
];
73+
4974
function AutoScrollContainer({ children, itemCount }: { children: React.ReactNode; itemCount: number }) {
5075
const ref = useRef<HTMLDivElement>(null);
5176
const [lockedHeight, setLockedHeight] = useState<number | null>(null);
@@ -267,12 +292,15 @@ function CreateTeamModal({
267292
onCreate,
268293
}: {
269294
onClose: () => void;
270-
onCreate: (name: string, repoIds: number[]) => void;
295+
onCreate: (name: string, repoIds: number[], invitedMembers: TeamInviteDraft[]) => void;
271296
}) {
272-
const [step, setStep] = useState<1 | 2>(1);
297+
const [step, setStep] = useState<1 | 2 | 3>(1);
273298
const [name, setName] = useState("");
274299
const [selectedRepos, setSelectedRepos] = useState<number[]>([]);
275300
const [repoSearch, setRepoSearch] = useState("");
301+
const [selectedMembers, setSelectedMembers] = useState<TeamInviteDraft[]>([]);
302+
const [memberEmail, setMemberEmail] = useState("");
303+
const [memberRole, setMemberRole] = useState(TEAM_ROLE_OPTIONS[0]);
276304

277305
const canProceed = name.trim().length > 0;
278306

@@ -286,8 +314,42 @@ function CreateTeamModal({
286314
);
287315
};
288316

317+
const toggleSuggestedMember = (member: TeamInviteDraft) => {
318+
setSelectedMembers((prev) =>
319+
prev.some((item) => item.email === member.email)
320+
? prev.filter((item) => item.email !== member.email)
321+
: [...prev, member]
322+
);
323+
};
324+
325+
const handleAddMemberByEmail = () => {
326+
const email = memberEmail.trim();
327+
if (!email || !email.includes("@") || selectedMembers.some((member) => member.email === email)) return;
328+
329+
setSelectedMembers((prev) => [
330+
...prev,
331+
{
332+
id: Date.now(),
333+
name: email.split("@")[0],
334+
email,
335+
role: memberRole,
336+
},
337+
]);
338+
setMemberEmail("");
339+
};
340+
341+
const handleRemoveMember = (email: string) => {
342+
setSelectedMembers((prev) => prev.filter((member) => member.email !== email));
343+
};
344+
345+
const handleChangeMemberRole = (email: string, role: string) => {
346+
setSelectedMembers((prev) =>
347+
prev.map((member) => (member.email === email ? { ...member, role } : member))
348+
);
349+
};
350+
289351
const handleFinish = () => {
290-
onCreate(name.trim(), selectedRepos);
352+
onCreate(name.trim(), selectedRepos, selectedMembers);
291353
onClose();
292354
};
293355

@@ -315,7 +377,7 @@ function CreateTeamModal({
315377
팀 생성하기
316378
</h2>
317379
<div className="flex items-center gap-2 mt-2">
318-
{([1, 2] as const).map((s) => (
380+
{([1, 2, 3] as const).map((s) => (
319381
<div key={s} className="flex items-center gap-1.5">
320382
<div
321383
style={{
@@ -335,9 +397,9 @@ function CreateTeamModal({
335397
{s}
336398
</div>
337399
<span style={{ fontSize: "12px", fontWeight: 800, color: step === s ? "var(--white)" : "var(--muted)" }}>
338-
{s === 1 ? "팀 이름" : "리포지토리"}
400+
{s === 1 ? "팀 이름" : s === 2 ? "리포지토리" : "팀원 추가"}
339401
</span>
340-
{s < 2 && (
402+
{s < 3 && (
341403
<div
342404
style={{
343405
width: "20px",
@@ -432,22 +494,40 @@ function CreateTeamModal({
432494
<span style={{ fontWeight: 700 }}>(선택 사항 · {selectedRepos.length}개 선택됨)</span>
433495
</p>
434496

435-
<input
436-
autoFocus
437-
value={repoSearch}
438-
onChange={(e) => setRepoSearch(e.target.value)}
439-
placeholder="리포지토리 검색..."
440-
className="w-full rounded-xl px-4 py-2.5 outline-none tracking-tight mb-3"
441-
style={{
442-
background: "rgba(255,255,255,0.05)",
443-
border: "1.5px solid rgba(32, 227, 255, 0.18)",
444-
color: "var(--white)",
445-
fontSize: "13px",
446-
fontWeight: 700,
447-
}}
448-
onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(32, 227, 255, 0.5)")}
449-
onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(32, 227, 255, 0.18)")}
450-
/>
497+
<div className="relative mb-3">
498+
<input
499+
autoFocus
500+
value={repoSearch}
501+
onChange={(e) => setRepoSearch(e.target.value)}
502+
placeholder="리포지토리 검색..."
503+
className="w-full rounded-xl py-2.5 pl-4 pr-11 outline-none tracking-tight"
504+
style={{
505+
background: "rgba(255,255,255,0.05)",
506+
border: "1.5px solid rgba(32, 227, 255, 0.18)",
507+
color: "var(--white)",
508+
fontSize: "13px",
509+
fontWeight: 700,
510+
}}
511+
onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(32, 227, 255, 0.5)")}
512+
onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(32, 227, 255, 0.18)")}
513+
/>
514+
{repoSearch.length > 0 && (
515+
<button
516+
type="button"
517+
onMouseDown={(e) => e.preventDefault()}
518+
onClick={() => setRepoSearch("")}
519+
className="absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full border-0"
520+
style={{
521+
background: "rgba(255,255,255,0.08)",
522+
color: "var(--muted)",
523+
cursor: "pointer",
524+
}}
525+
aria-label="검색어 지우기"
526+
>
527+
<X size={14} />
528+
</button>
529+
)}
530+
</div>
451531

452532
<div className="grid gap-1.5 overflow-y-auto" style={{ maxHeight: "260px" }}>
453533
{filteredRepos.map((repo) => {
@@ -536,6 +616,191 @@ function CreateTeamModal({
536616
>
537617
이전
538618
</button>
619+
<button
620+
onClick={() => setStep(3)}
621+
className="flex-1 rounded-xl border-0 py-3 tracking-tight transition-all"
622+
style={{
623+
background: "linear-gradient(135deg, var(--neon-cyan), var(--deep-teal))",
624+
color: "#021014",
625+
fontSize: "14px",
626+
fontWeight: 900,
627+
cursor: "pointer",
628+
boxShadow: "0 4px 14px rgba(32, 227, 255, 0.28)",
629+
}}
630+
>
631+
{selectedRepos.length > 0 ? `다음 (${selectedRepos.length}개 연결)` : "다음"}
632+
</button>
633+
</div>
634+
</div>
635+
)}
636+
637+
{step === 3 && (
638+
<div className="px-7 pb-7">
639+
<div className="mb-5 rounded-2xl px-4 py-3" style={{ background: "rgba(32, 227, 255, 0.08)", border: "1px solid rgba(32, 227, 255, 0.18)" }}>
640+
<p className="m-0 tracking-tight" style={{ color: "var(--white)", fontSize: "14px", fontWeight: 950 }}>
641+
팀원을 초대하세요
642+
</p>
643+
<p className="m-0 mt-1 tracking-tight" style={{ color: "var(--muted)", fontSize: "12px", fontWeight: 750, lineHeight: 1.5 }}>
644+
팀 생성 후 초대 메일을 발송합니다. 지금 건너뛰고 나중에 팀 관리에서 추가할 수도 있습니다.
645+
</p>
646+
</div>
647+
648+
<div className="mb-4 grid grid-cols-[1fr_150px_auto] gap-2">
649+
<input
650+
value={memberEmail}
651+
onChange={(e) => setMemberEmail(e.target.value)}
652+
onKeyDown={(e) => {
653+
if (e.key === "Enter") {
654+
e.preventDefault();
655+
handleAddMemberByEmail();
656+
}
657+
}}
658+
autoFocus
659+
placeholder="teammate@company.com"
660+
className="min-w-0 rounded-xl px-4 py-3 outline-none tracking-tight"
661+
style={{
662+
background: "rgba(255,255,255,0.05)",
663+
border: "1.5px solid rgba(32, 227, 255, 0.20)",
664+
color: "var(--white)",
665+
fontSize: "13px",
666+
fontWeight: 800,
667+
}}
668+
/>
669+
<select
670+
value={memberRole}
671+
onChange={(e) => setMemberRole(e.target.value)}
672+
className="rounded-xl px-3 py-3 outline-none tracking-tight"
673+
style={{
674+
background: "rgba(255,255,255,0.05)",
675+
border: "1.5px solid rgba(32, 227, 255, 0.20)",
676+
color: "var(--white)",
677+
fontSize: "12px",
678+
fontWeight: 850,
679+
}}
680+
>
681+
{TEAM_ROLE_OPTIONS.map((role) => (
682+
<option key={role} value={role} style={{ background: "#121827", color: "#EAF7FF" }}>
683+
{role}
684+
</option>
685+
))}
686+
</select>
687+
<button
688+
type="button"
689+
onClick={handleAddMemberByEmail}
690+
className="rounded-xl border-0 px-4 py-3 tracking-tight"
691+
style={{
692+
background: "rgba(32, 227, 255, 0.12)",
693+
border: "1px solid rgba(32, 227, 255, 0.24)",
694+
color: "var(--neon-cyan)",
695+
cursor: "pointer",
696+
fontSize: "13px",
697+
fontWeight: 950,
698+
}}
699+
>
700+
추가
701+
</button>
702+
</div>
703+
704+
<p className="m-0 mb-2 tracking-tight" style={{ color: "var(--muted)", fontSize: "12px", fontWeight: 900 }}>
705+
추천 팀원
706+
</p>
707+
<div className="mb-4 grid gap-2 overflow-y-auto pr-1" style={{ maxHeight: "248px" }}>
708+
{SUGGESTED_TEAM_MEMBERS.map((member) => {
709+
const selected = selectedMembers.some((item) => item.email === member.email);
710+
return (
711+
<button
712+
key={member.email}
713+
type="button"
714+
onClick={() => toggleSuggestedMember(member)}
715+
className="flex w-full items-center gap-3 rounded-xl border-0 px-4 py-3 text-left transition-all"
716+
style={{
717+
background: selected ? "rgba(57, 255, 136, 0.10)" : "rgba(255,255,255,0.03)",
718+
border: selected ? "1px solid rgba(57, 255, 136, 0.30)" : "1px solid rgba(255,255,255,0.07)",
719+
cursor: "pointer",
720+
}}
721+
>
722+
<span className="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full" style={{ background: "linear-gradient(135deg, var(--neon-cyan), var(--matrix-green))", color: "#021014", fontSize: "12px", fontWeight: 950 }}>
723+
{member.name.slice(0, 1)}
724+
</span>
725+
<span className="min-w-0 flex-1">
726+
<span className="block truncate" style={{ color: "var(--white)", fontSize: "13px", fontWeight: 950 }}>{member.name}</span>
727+
<span className="block truncate" style={{ color: "var(--muted)", fontSize: "11px", fontWeight: 750 }}>{member.email} · {member.role}</span>
728+
</span>
729+
<span style={{ color: selected ? "var(--matrix-green)" : "var(--muted)", fontSize: "12px", fontWeight: 950 }}>
730+
{selected ? "선택됨" : "초대"}
731+
</span>
732+
</button>
733+
);
734+
})}
735+
</div>
736+
737+
{selectedMembers.length > 0 && (
738+
<div className="mb-5 grid gap-2 overflow-y-auto pr-1" style={{ maxHeight: "228px" }}>
739+
{selectedMembers.map((member) => (
740+
<div
741+
key={member.email}
742+
className="grid items-center gap-3 rounded-xl px-4 py-3 tracking-tight"
743+
style={{
744+
background: "rgba(234, 247, 255, 0.08)",
745+
border: "1px solid rgba(32, 227, 255, 0.16)",
746+
gridTemplateColumns: "minmax(0, 1fr) 190px auto",
747+
}}
748+
>
749+
<div className="min-w-0">
750+
<p className="m-0 truncate" style={{ color: "var(--white)", fontSize: "13px", fontWeight: 950 }}>
751+
{member.name}
752+
</p>
753+
<p className="m-0 truncate" style={{ color: "var(--muted)", fontSize: "11px", fontWeight: 750 }}>
754+
{member.email}
755+
</p>
756+
</div>
757+
<select
758+
value={member.role}
759+
onChange={(e) => handleChangeMemberRole(member.email, e.target.value)}
760+
className="rounded-xl px-3 py-2 outline-none tracking-tight"
761+
style={{
762+
background: "rgba(255,255,255,0.06)",
763+
border: "1px solid rgba(32, 227, 255, 0.20)",
764+
color: "var(--white)",
765+
fontSize: "12px",
766+
fontWeight: 850,
767+
}}
768+
>
769+
{TEAM_ROLE_OPTIONS.map((role) => (
770+
<option key={role} value={role} style={{ background: "#121827", color: "#EAF7FF" }}>
771+
{role}
772+
</option>
773+
))}
774+
</select>
775+
<button
776+
type="button"
777+
onClick={() => handleRemoveMember(member.email)}
778+
className="h-9 w-9 rounded-xl border-0"
779+
style={{
780+
background: "rgba(255, 107, 107, 0.12)",
781+
border: "1px solid rgba(255, 107, 107, 0.24)",
782+
color: "#FF6B6B",
783+
cursor: "pointer",
784+
fontSize: "14px",
785+
fontWeight: 950,
786+
}}
787+
aria-label={`${member.name} 제거`}
788+
>
789+
x
790+
</button>
791+
</div>
792+
))}
793+
</div>
794+
)}
795+
796+
<div className="flex gap-3 mt-5">
797+
<button
798+
onClick={() => setStep(2)}
799+
className="flex-1 rounded-xl border-0 py-3 tracking-tight"
800+
style={{ background: "rgba(255,255,255,0.06)", color: "var(--muted)", fontSize: "14px", fontWeight: 900, cursor: "pointer" }}
801+
>
802+
이전
803+
</button>
539804
<button
540805
onClick={handleFinish}
541806
className="flex-1 rounded-xl border-0 py-3 tracking-tight transition-all"
@@ -548,7 +813,7 @@ function CreateTeamModal({
548813
boxShadow: "0 4px 14px rgba(32, 227, 255, 0.28)",
549814
}}
550815
>
551-
{selectedRepos.length > 0 ? `팀 만들기 (${selectedRepos.length}개 연결)` : "팀 만들기"}
816+
{selectedMembers.length > 0 ? `팀 만들기 (${selectedMembers.length}명 초대)` : "팀 만들기"}
552817
</button>
553818
</div>
554819
</div>
@@ -726,10 +991,10 @@ export function WorkspacePage() {
726991
});
727992
}, []);
728993

729-
const handleCreateTeam = (name: string, repoIds: number[]) => {
994+
const handleCreateTeam = (name: string, repoIds: number[], invitedMembers: TeamInviteDraft[]) => {
730995
setOrgs((prev) => [
731996
...prev,
732-
{ id: Date.now(), name, openPRs: 0, highRisk: 0, activeIssues: 0, memberCount: 1, repoCount: repoIds.length, myRole: "소유자" },
997+
{ id: Date.now(), name, openPRs: 0, highRisk: 0, activeIssues: 0, memberCount: invitedMembers.length + 1, repoCount: repoIds.length, myRole: "소유자" },
733998
]);
734999
};
7351000

0 commit comments

Comments
 (0)