@@ -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+
3643const 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+
4974function 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