diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ef6be1e..4f757ed 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -3,10 +3,14 @@
## Frontend Structure
- Route pages stay thin under `src/pages/*` and delegate UI to feature components.
+- App routes are loaded lazily from `src/App.tsx` so large team-space and presentation screens do not inflate the initial route bundle.
- Matching UI lives in `src/features/match/*`.
- Team workspace UI lives in `src/features/team/*`.
+- Team workspace orchestration lives in `src/features/team/components/team-space-view.tsx`; signed-in server-backed panels are split into focused `real-team-*` components, and the signed-out mock preview lives in `src/features/team/components/mock-team-space-view.tsx`.
+- Shared team workspace chrome, status helpers, member formatting, tabs, and GitHub permission notices live in focused files under `src/features/team/components/*`.
- Shared domain types live in `src/lib/types/*`.
- API request functions stay in `src/lib/api/*`.
+- MSW request handlers stay in `src/lib/api/mocks/handlers.ts`, with reusable team-space fixture builders in `src/lib/api/mocks/team-space-fixtures.ts`.
- `/team` uses project group, checklist, admin permission, and GitHub App installation request functions when a user is signed in.
- In mock API mode, signed-in `/team` calls the same request functions through MSW; signed-out `/team` keeps the richer local demo workspace as a preview.
diff --git a/DECISIONS.md b/DECISIONS.md
index 963ec0d..c12810d 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -43,3 +43,8 @@
## 2026-06-02
- UX writing approach: use concise Korean 해요체 for customer-facing guidance, lead with the user's next action, avoid internal planning terms in UI copy, and keep feature-critical product terms when they communicate real behavior.
- Repository UX writing guide: keep AI-facing workflow instructions in `.agents/skills/ux-writing/SKILL.md` and human-readable product copy standards in `docs/UX_WRITING.md`.
+
+## 2026-06-06
+- Route performance approach: lazy-load route pages from `src/App.tsx`, especially `/team` and `/deck/*`, to keep the initial app bundle smaller without changing route ownership.
+- Team workspace structure: keep `/team` orchestration in `team-space-view.tsx`, split signed-in server-backed panels into focused `real-team-*` components, and keep the signed-out mock preview separate while sharing tabs, status helpers, and GitHub permission notices.
+- MSW maintainability: keep route handlers in `src/lib/api/mocks/handlers.ts`, but move reusable team-space fixture builders into `src/lib/api/mocks/team-space-fixtures.ts` so handler code focuses on request behavior.
diff --git a/src/App.tsx b/src/App.tsx
index 74eb442..7f4d047 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { lazy, Suspense, useEffect } from "react";
import {
BrowserRouter,
Navigate,
@@ -7,106 +7,240 @@ import {
useLocation,
} from "react-router-dom";
-import { EmailVerificationPage } from "@/pages/email-verification-page";
-import { GithubOAuthCallbackPage } from "@/pages/github-oauth-callback-page";
-import { LandingPage } from "@/pages/landing-page";
-import { LoginPage } from "@/pages/login-page";
-import { MatchPage } from "@/pages/match-page";
-import { PasswordResetPage } from "@/pages/password-reset-page";
-import { TeamPoPresentationEleventhPage } from "@/pages/team-po-presentation-eleventh-page";
-import { TeamPoPresentationFourteenthPage } from "@/pages/team-po-presentation-fourteenth-page";
-import { TeamPoPresentationThirteenthPage } from "@/pages/team-po-presentation-thirteenth-page";
-import { TeamPoPresentationTwelfthPage } from "@/pages/team-po-presentation-twelfth-page";
-import { ProfilePage } from "@/pages/profile-page";
-import { TeamPoPresentationEighthPage } from "@/pages/team-po-presentation-eighth-page";
-import { TeamPoPresentationFourthPage } from "@/pages/team-po-presentation-fourth-page";
-import { TeamPoPresentationNinthPage } from "@/pages/team-po-presentation-ninth-page";
-import { SignupPage } from "@/pages/signup-page";
-import { TeamPoPresentationPage } from "@/pages/team-po-presentation-page";
-import { TeamPoPresentationSecondPage } from "@/pages/team-po-presentation-second-page";
-import { TeamPoPresentationThirdPage } from "@/pages/team-po-presentation-third-page";
-import { TeamPoPresentationFifthPage } from "@/pages/team-po-presentation-fifth-page";
-import { TeamPoPresentationSeventhPage } from "@/pages/team-po-presentation-seventh-page";
-import { TeamPoPresentationSixthPage } from "@/pages/team-po-presentation-sixth-page";
-import { TeamSpacePage } from "@/pages/team-space-page";
-import { TeamPoPresentationTenthPage } from "@/pages/team-po-presentation-tenth-page";
+const LandingPage = lazy(() =>
+ import("@/pages/landing-page").then(({ LandingPage }) => ({
+ default: LandingPage,
+ })),
+);
+const LoginPage = lazy(() =>
+ import("@/pages/login-page").then(({ LoginPage }) => ({
+ default: LoginPage,
+ })),
+);
+const SignupPage = lazy(() =>
+ import("@/pages/signup-page").then(({ SignupPage }) => ({
+ default: SignupPage,
+ })),
+);
+const PasswordResetPage = lazy(() =>
+ import("@/pages/password-reset-page").then(({ PasswordResetPage }) => ({
+ default: PasswordResetPage,
+ })),
+);
+const EmailVerificationPage = lazy(() =>
+ import("@/pages/email-verification-page").then(
+ ({ EmailVerificationPage }) => ({
+ default: EmailVerificationPage,
+ }),
+ ),
+);
+const GithubOAuthCallbackPage = lazy(() =>
+ import("@/pages/github-oauth-callback-page").then(
+ ({ GithubOAuthCallbackPage }) => ({
+ default: GithubOAuthCallbackPage,
+ }),
+ ),
+);
+const ProfilePage = lazy(() =>
+ import("@/pages/profile-page").then(({ ProfilePage }) => ({
+ default: ProfilePage,
+ })),
+);
+const MatchPage = lazy(() =>
+ import("@/pages/match-page").then(({ MatchPage }) => ({
+ default: MatchPage,
+ })),
+);
+const TeamSpacePage = lazy(() =>
+ import("@/pages/team-space-page").then(({ TeamSpacePage }) => ({
+ default: TeamSpacePage,
+ })),
+);
+const TeamPoPresentationPage = lazy(() =>
+ import("@/pages/team-po-presentation-page").then(
+ ({ TeamPoPresentationPage }) => ({
+ default: TeamPoPresentationPage,
+ }),
+ ),
+);
+const TeamPoPresentationSecondPage = lazy(() =>
+ import("@/pages/team-po-presentation-second-page").then(
+ ({ TeamPoPresentationSecondPage }) => ({
+ default: TeamPoPresentationSecondPage,
+ }),
+ ),
+);
+const TeamPoPresentationThirdPage = lazy(() =>
+ import("@/pages/team-po-presentation-third-page").then(
+ ({ TeamPoPresentationThirdPage }) => ({
+ default: TeamPoPresentationThirdPage,
+ }),
+ ),
+);
+const TeamPoPresentationFourthPage = lazy(() =>
+ import("@/pages/team-po-presentation-fourth-page").then(
+ ({ TeamPoPresentationFourthPage }) => ({
+ default: TeamPoPresentationFourthPage,
+ }),
+ ),
+);
+const TeamPoPresentationFifthPage = lazy(() =>
+ import("@/pages/team-po-presentation-fifth-page").then(
+ ({ TeamPoPresentationFifthPage }) => ({
+ default: TeamPoPresentationFifthPage,
+ }),
+ ),
+);
+const TeamPoPresentationSixthPage = lazy(() =>
+ import("@/pages/team-po-presentation-sixth-page").then(
+ ({ TeamPoPresentationSixthPage }) => ({
+ default: TeamPoPresentationSixthPage,
+ }),
+ ),
+);
+const TeamPoPresentationSeventhPage = lazy(() =>
+ import("@/pages/team-po-presentation-seventh-page").then(
+ ({ TeamPoPresentationSeventhPage }) => ({
+ default: TeamPoPresentationSeventhPage,
+ }),
+ ),
+);
+const TeamPoPresentationEighthPage = lazy(() =>
+ import("@/pages/team-po-presentation-eighth-page").then(
+ ({ TeamPoPresentationEighthPage }) => ({
+ default: TeamPoPresentationEighthPage,
+ }),
+ ),
+);
+const TeamPoPresentationNinthPage = lazy(() =>
+ import("@/pages/team-po-presentation-ninth-page").then(
+ ({ TeamPoPresentationNinthPage }) => ({
+ default: TeamPoPresentationNinthPage,
+ }),
+ ),
+);
+const TeamPoPresentationTenthPage = lazy(() =>
+ import("@/pages/team-po-presentation-tenth-page").then(
+ ({ TeamPoPresentationTenthPage }) => ({
+ default: TeamPoPresentationTenthPage,
+ }),
+ ),
+);
+const TeamPoPresentationEleventhPage = lazy(() =>
+ import("@/pages/team-po-presentation-eleventh-page").then(
+ ({ TeamPoPresentationEleventhPage }) => ({
+ default: TeamPoPresentationEleventhPage,
+ }),
+ ),
+);
+const TeamPoPresentationTwelfthPage = lazy(() =>
+ import("@/pages/team-po-presentation-twelfth-page").then(
+ ({ TeamPoPresentationTwelfthPage }) => ({
+ default: TeamPoPresentationTwelfthPage,
+ }),
+ ),
+);
+const TeamPoPresentationThirteenthPage = lazy(() =>
+ import("@/pages/team-po-presentation-thirteenth-page").then(
+ ({ TeamPoPresentationThirteenthPage }) => ({
+ default: TeamPoPresentationThirteenthPage,
+ }),
+ ),
+);
+const TeamPoPresentationFourteenthPage = lazy(() =>
+ import("@/pages/team-po-presentation-fourteenth-page").then(
+ ({ TeamPoPresentationFourteenthPage }) => ({
+ default: TeamPoPresentationFourteenthPage,
+ }),
+ ),
+);
export function App() {
return (
-
- } />
- } />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- } />
- } />
- } />
- } />
- }
- />
- } />
- } />
- } />
- } />
-
+ }>
+
+ } />
+ } />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+
+
);
}
+function RouteFallback() {
+ return (
+
+ 화면을 불러오고 있어요.
+
+ );
+}
+
function ScrollToTop() {
const { pathname } = useLocation();
diff --git a/src/features/auth/hooks/use-auth-queries.ts b/src/features/auth/hooks/use-auth-queries.ts
index 5289579..a335ba4 100644
--- a/src/features/auth/hooks/use-auth-queries.ts
+++ b/src/features/auth/hooks/use-auth-queries.ts
@@ -8,6 +8,8 @@ import {
import { matchQueryKeys } from "@/features/match/hooks/use-match-queries";
import { projectGroupQueryKeys } from "@/features/project-groups/hooks/use-project-group-queries";
+import { projectChecklistQueryKeys } from "@/features/team/hooks/use-project-checklist-queries";
+import { teamSpaceQueryKeys } from "@/features/team/hooks/use-team-space-queries";
import {
exchangeGithubOAuthCode,
login,
@@ -67,6 +69,8 @@ export function clearAuthScopedQueryData(queryClient: QueryClient) {
clearQueryData(queryClient, authQueryKeys.currentUser);
clearQueryData(queryClient, matchQueryKeys.all);
clearQueryData(queryClient, projectGroupQueryKeys.all);
+ clearQueryData(queryClient, projectChecklistQueryKeys.all);
+ clearQueryData(queryClient, teamSpaceQueryKeys.all);
}
export function useCurrentUserQuery() {
diff --git a/src/features/team/components/github-organization-policy-notice.tsx b/src/features/team/components/github-organization-policy-notice.tsx
new file mode 100644
index 0000000..f738cac
--- /dev/null
+++ b/src/features/team/components/github-organization-policy-notice.tsx
@@ -0,0 +1,54 @@
+import { GitPullRequest } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+
+const githubOAuthPolicySteps = [
+ "GitHub Organization",
+ "Settings",
+ "GitHub Apps",
+ "TeamPo 설치 권한 확인",
+] as const;
+
+export function GithubOrganizationPolicyNotice() {
+ return (
+
+
+
+
+
+
+
+
+
+ 저장소 연결 전 TeamPo 접근 권한을 확인해 주세요
+
+
permission check
+
+
+ GitHub App 설치나 선택 저장소 권한이 제한되어 있으면 TeamPo가
+ 저장소와 PR 정보를 가져오지 못할 수 있어요. Organization owner가
+ TeamPo GitHub App이 설치되어 있는지, 선택한 저장소와 Pull requests
+ 읽기 권한이 열려 있는지 확인해 주세요.
+
+
+
+
+
+ {githubOAuthPolicySteps.map((step, index) => (
+
+
+ {index + 1}
+
+
+ {step}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/features/team/components/mock-team-space-view.tsx b/src/features/team/components/mock-team-space-view.tsx
new file mode 100644
index 0000000..a092500
--- /dev/null
+++ b/src/features/team/components/mock-team-space-view.tsx
@@ -0,0 +1,1592 @@
+import {
+ ArrowRight,
+ Building2,
+ CheckCircle2,
+ ExternalLink,
+ GitBranch,
+ Github,
+ GitPullRequest,
+ MessageSquareText,
+ PencilLine,
+ Plus,
+ Save,
+ SendHorizontal,
+ Settings2,
+ ShieldCheck,
+ Trash2,
+} from "lucide-react";
+import {
+ type FormEvent,
+ type ReactNode,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import {
+ AppPanel,
+ AppPanelHeader,
+ AppShell,
+ MetricCard,
+} from "@/components/app-shell";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { GithubOrganizationPolicyNotice } from "@/features/team/components/github-organization-policy-notice";
+import {
+ TeamTabList,
+ type TeamTab,
+} from "@/features/team/components/team-tab-list";
+import { demoTeamSpace } from "@/features/team/lib/demo-team-space";
+import type {
+ GithubRepositorySummary,
+ TeamChecklistItem,
+ TeamMessage,
+} from "@/lib/types/team";
+import { cn } from "@/lib/utils";
+
+const checklistTone: Record = {
+ doing: "border-primary/25 bg-primary/10 text-primary",
+ done: "border-emerald-500/25 bg-emerald-50 text-emerald-700",
+ todo: "border-border bg-secondary/45 text-muted-foreground",
+};
+
+const checklistLabels: Record = {
+ doing: "진행 중",
+ done: "완료",
+ todo: "할 일",
+};
+
+const contributionLevelClass = [
+ "bg-secondary",
+ "bg-emerald-100",
+ "bg-emerald-300",
+ "bg-emerald-500",
+ "bg-emerald-700",
+] as const;
+
+export function MockTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) {
+ const [selectedTab, setSelectedTab] = useState("overview");
+ const [teamName, setTeamName] = useState(demoTeamSpace.name);
+ const [rulesMarkdown, setRulesMarkdown] = useState(
+ demoTeamSpace.rulesMarkdown,
+ );
+ const [checklist, setChecklist] = useState(demoTeamSpace.checklist);
+ const [messages, setMessages] = useState(demoTeamSpace.messages);
+ const [isGithubLinked, setIsGithubLinked] = useState(
+ demoTeamSpace.githubSummary.projectGroupGithubLinked,
+ );
+ const metrics = getTeamMetrics(checklist);
+
+ function handleChecklistStatusChange(
+ itemId: string,
+ status: TeamChecklistItem["status"],
+ ) {
+ setChecklist((current) =>
+ current.map((item) => (item.id === itemId ? { ...item, status } : item)),
+ );
+ }
+
+ function handleChecklistAdd(item: Omit) {
+ setChecklist((current) => [
+ {
+ ...item,
+ id: `task-${Date.now()}`,
+ status: "todo",
+ },
+ ...current,
+ ]);
+ }
+
+ function handleChecklistDelete(itemId: string) {
+ setChecklist((current) => current.filter((item) => item.id !== itemId));
+ }
+
+ function handleSendMessage(message: string) {
+ const trimmedMessage = message.trim();
+
+ if (!trimmedMessage) {
+ return;
+ }
+
+ setMessages((current) => [
+ ...current,
+ {
+ author: "나",
+ id: `message-${Date.now()}`,
+ message: trimmedMessage,
+ timeLabel: "방금",
+ },
+ ]);
+ }
+
+ return (
+
+ }
+ title={teamName}
+ >
+
+
+ {metrics.map((metric) => (
+
+ ))}
+
+
+ {!isSignedIn ? (
+
+ 지금은 샘플 팀 스페이스를 둘러보고 있어요. 로그인하면 내 팀 기준으로
+ 규칙, 체크리스트, 채팅을 이어서 관리할 수 있어요.
+
+ ) : null}
+
+
+
+
+ getTeamTabBadge(tabId, checklist, messages, isGithubLinked)
+ }
+ onSelectTab={setSelectedTab}
+ selectedTab={selectedTab}
+ />
+
+
+ {selectedTab === "overview" ? (
+
+ ) : null}
+ {selectedTab === "guide" ? : null}
+ {selectedTab === "rules" ? (
+
+ ) : null}
+ {selectedTab === "checklist" ? (
+
+ ) : null}
+
+
+
+ {selectedTab === "chat" ? (
+
+ ) : null}
+ {selectedTab === "manage" ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function TeamFocusPanel({
+ checklist,
+ onSelectTab,
+}: {
+ checklist: TeamChecklistItem[];
+ onSelectTab: (tab: TeamTab) => void;
+}) {
+ const openTasks = checklist.filter((item) => item.status !== "done");
+ const doneTasks = checklist.length - openTasks.length;
+ const primaryTask =
+ checklist.find((item) => item.status === "doing") ??
+ checklist.find((item) => item.status === "todo") ??
+ checklist[0];
+
+ return (
+
+
+
+
+ 오늘의 핵심
+ 팀 운영
+
+
+ {primaryTask
+ ? primaryTask.title
+ : "새 작업을 추가해 다음 할 일을 정해요"}
+
+
+ {primaryTask
+ ? `${primaryTask.assignee} 담당 · ${primaryTask.dueLabel} · ${checklistLabels[primaryTask.status]}`
+ : "체크리스트에서 첫 작업을 만들면 팀 홈 상단에 바로 보여요."}
+
+
+
+
+
+
+ 남은 작업
+
+
+ {openTasks.length}
+
+
+
+
+ 완료 작업
+
+
+ {doneTasks}
+
+
+
+
+ 다음 회의
+
+
+ {demoTeamSpace.nextMeetingLabel}
+
+
+
+
+
+
onSelectTab("checklist")} type="button">
+
+ 체크리스트 열기
+
+
onSelectTab("github")}
+ type="button"
+ variant="outline"
+ >
+
+ GitHub 연동
+
+
onSelectTab("chat")}
+ type="button"
+ variant="outline"
+ >
+
+ 채팅 열기
+
+
onSelectTab("manage")}
+ type="button"
+ variant="outline"
+ >
+
+ 관리
+
+
+
+
+
+ );
+}
+
+function getTeamTabBadge(
+ tabId: TeamTab,
+ checklist: TeamChecklistItem[],
+ messages: TeamMessage[],
+ isGithubLinked: boolean,
+) {
+ if (tabId === "checklist") {
+ const openTaskCount = checklist.filter(
+ (item) => item.status !== "done",
+ ).length;
+ return openTaskCount > 0 ? String(openTaskCount) : "완료";
+ }
+
+ if (tabId === "github") {
+ return isGithubLinked ? null : "설정";
+ }
+
+ if (tabId === "chat") {
+ return String(messages.length);
+ }
+
+ if (tabId === "manage") {
+ return "팀";
+ }
+
+ return null;
+}
+
+function TeamRail({
+ checklist,
+ isGithubLinked,
+ onSelectTab,
+ teamName,
+}: {
+ checklist: TeamChecklistItem[];
+ isGithubLinked: boolean;
+ onSelectTab: (tabId: TeamTab) => void;
+ teamName: string;
+}) {
+ const openTasks = checklist.filter((item) => item.status !== "done").length;
+ const doneTasks = checklist.length - openTasks;
+
+ return (
+
+
+
+
+
+
+
남은 작업
+
+ {openTasks}
+
+
+
+
완료 작업
+
+ {doneTasks}
+
+
+
+
+ GitHub 연동은{" "}
+
+ {isGithubLinked ? "완료" : "설정 필요"}
+
+ 상태예요. 팀 설정과 멤버 관리는 관리 탭에서 확인해요.
+
+
+ onSelectTab("checklist")} type="button">
+
+ 체크리스트
+
+ onSelectTab("manage")}
+ type="button"
+ variant="outline"
+ >
+
+ 관리
+
+
+
+
+
+ );
+}
+
+function MockManagePanel({
+ onTeamNameChange,
+ teamName,
+}: {
+ onTeamNameChange: (name: string) => void;
+ teamName: string;
+}) {
+ return (
+
+
+ local preview}
+ description="팀 설정 화면을 미리 확인해요. 아직 연결되지 않은 기능은 준비 중이에요."
+ eyebrow="Manage"
+ title="팀 관리"
+ />
+
+
+
+ 팀 이름
+ onTeamNameChange(event.target.value)}
+ value={teamName}
+ />
+
+
+ 팀 상태
+
+ 운영 중
+
+
+
+
+ 팀 운영 상태 변경은 서버 API가 연결되면 사용할 수 있어요.
+
+
+
+
+
+
+
+ {demoTeamSpace.members.map((member) => (
+
+
+
+
{member.name}
+
{member.role}
+
+
+ Lv.{member.level} · 온도 {member.temperature.toFixed(1)}℃
+
+
+
+
+ 권한 설정 준비 중
+
+
+ ))}
+
+
+
+ );
+}
+
+interface OverviewPanelProps {
+ checklist: TeamChecklistItem[];
+}
+
+function OverviewPanel({ checklist }: OverviewPanelProps) {
+ const totalTasks = checklist.length;
+ const doneTasks = checklist.filter((item) => item.status === "done").length;
+
+ return (
+
+
+
+
+
+
+ MVP
+
+
+ {demoTeamSpace.projectMvp}
+
+
+
+ {demoTeamSpace.members.map((member) => (
+
+
+
+ {member.name.slice(0, 1)}
+
+
+
+
+ {member.name}
+
+
{member.role}
+
+
+ Lv.{member.level} · 온도 {member.temperature.toFixed(1)}℃
+
+
+
+
+
+ {member.responsibility}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ 체크리스트 {doneTasks}/{totalTasks} 완료
+
+
+
+ {checklist.slice(0, 3).map((item) => (
+
+
+ {item.dueLabel}
+
+
+
{item.title}
+
+ 담당 {item.assignee}
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function GuidePanel() {
+ return (
+
+
+
+ {demoTeamSpace.guideline.sections.map((section) => (
+
+
{section.title}
+
+ {section.body}
+
+
+ ))}
+
+
+ );
+}
+
+interface RulesPanelProps {
+ onRulesChange: (rulesMarkdown: string) => void;
+ rulesMarkdown: string;
+}
+
+function RulesPanel({ onRulesChange, rulesMarkdown }: RulesPanelProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [draftRules, setDraftRules] = useState(rulesMarkdown);
+ const renderedRules = useMemo(
+ () => parseRulesMarkdown(rulesMarkdown),
+ [rulesMarkdown],
+ );
+
+ function handleStartEditing() {
+ setDraftRules(rulesMarkdown);
+ setIsEditing(true);
+ }
+
+ function handleSave() {
+ onRulesChange(draftRules);
+ setIsEditing(false);
+ }
+
+ return (
+
+
+
+
+ 저장
+
+ setIsEditing(false)}
+ type="button"
+ variant="outline"
+ >
+ 취소
+
+
+ ) : (
+
+
+ 규칙 수정
+
+ )
+ }
+ description="함께 지킬 협업 규칙을 정리하고 필요할 때 수정해요."
+ eyebrow="Rulebook"
+ title="팀 규칙"
+ />
+
+ {isEditing ? (
+
+ ) : (
+
+
+
+
+ Rulebook
+
+
+ {renderedRules.title}
+
+
+
{renderedRules.items.length} rules
+
+
+ {renderedRules.items.map((item, index) => (
+
+
+ {index + 1}
+
+
+ {renderInlineCode(item)}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+interface ChecklistPanelProps {
+ checklist: TeamChecklistItem[];
+ onAdd: (item: Omit) => void;
+ onDelete: (itemId: string) => void;
+ onStatusChange: (itemId: string, status: TeamChecklistItem["status"]) => void;
+}
+
+function ChecklistPanel({
+ checklist,
+ onAdd,
+ onDelete,
+ onStatusChange,
+}: ChecklistPanelProps) {
+ const [newItem, setNewItem] = useState({
+ assignee: "조하늘",
+ dueLabel: "D-7",
+ title: "",
+ });
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+
+ if (!newItem.title.trim()) {
+ return;
+ }
+
+ onAdd({
+ assignee: newItem.assignee,
+ dueLabel: newItem.dueLabel,
+ title: newItem.title.trim(),
+ });
+ setNewItem((current) => ({ ...current, title: "" }));
+ }
+
+ return (
+
+
+
+
+
+ {checklist.map((item) => (
+
+
+
+
+ {checklistLabels[item.status]}
+
+ {item.dueLabel}
+
+
+ {item.title}
+
+
+ 담당 {item.assignee}
+
+
+
+
+ {item.title} 상태
+
+
+ onStatusChange(
+ item.id,
+ event.target.value as TeamChecklistItem["status"],
+ )
+ }
+ value={item.status}
+ >
+ 할 일
+ 진행 중
+ 완료
+
+ onDelete(item.id)}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function GithubPanel({
+ isProjectGroupGithubLinked,
+ onProjectGroupGithubLinkedChange,
+}: {
+ isProjectGroupGithubLinked: boolean;
+ onProjectGroupGithubLinkedChange: (isLinked: boolean) => void;
+}) {
+ const initialSelectedRepoIds =
+ demoTeamSpace.githubSummary.connectedRepos.length > 0
+ ? demoTeamSpace.githubSummary.connectedRepos.map((repo) => repo.id)
+ : [];
+ const [organization, setOrganization] = useState(
+ demoTeamSpace.githubSummary.organization,
+ );
+ const [installationStatus, setInstallationStatus] = useState(
+ demoTeamSpace.githubSummary.appInstallation.status,
+ );
+ const [selectedRepoIds, setSelectedRepoIds] = useState(
+ initialSelectedRepoIds,
+ );
+ const [connectedRepos, setConnectedRepos] = useState(
+ demoTeamSpace.githubSummary.connectedRepos,
+ );
+ const selectedRepositories = useMemo(
+ () =>
+ demoTeamSpace.githubSummary.availableRepositories.filter((repo) =>
+ selectedRepoIds.includes(repo.id),
+ ),
+ [selectedRepoIds],
+ );
+ const isGitHubAppInstalled = installationStatus === "installed";
+ const canSelectRepositories = Boolean(organization && isGitHubAppInstalled);
+ const canSaveRepositoryConnection =
+ canSelectRepositories && selectedRepoIds.length > 0;
+ const hasConnectedRepositories = connectedRepos.length > 0;
+ const showGithubPolicyNotice = !hasConnectedRepositories;
+ const setupStatusItems = [
+ {
+ description: organization?.login ?? "Organization 필요",
+ icon: Building2,
+ label: "Organization",
+ ready: Boolean(organization),
+ },
+ {
+ description: isGitHubAppInstalled ? "TeamPo App 설치됨" : "설치 전",
+ icon: Github,
+ label: "GitHub App",
+ ready: isGitHubAppInstalled,
+ },
+ {
+ description: hasConnectedRepositories
+ ? `${connectedRepos.length}개 저장소`
+ : "저장소 선택 전",
+ icon: GitBranch,
+ label: "Repository",
+ ready: hasConnectedRepositories,
+ },
+ {
+ description: isProjectGroupGithubLinked ? "기여도 집계 가능" : "연동 전",
+ icon: ShieldCheck,
+ label: "팀 스페이스",
+ ready: isProjectGroupGithubLinked,
+ },
+ ];
+
+ function handleInstallationComplete() {
+ setOrganization({
+ login: "team-po-labs",
+ name: "TeamPo Labs",
+ url: "https://github.com/team-po-labs",
+ });
+ setInstallationStatus("installed");
+ setConnectedRepos([]);
+ onProjectGroupGithubLinkedChange(false);
+ setSelectedRepoIds((current) =>
+ current.length > 0
+ ? current
+ : [demoTeamSpace.githubSummary.availableRepositories[0]?.id].filter(
+ Boolean,
+ ),
+ );
+ }
+
+ function handleRepositoryToggle(repoId: string) {
+ setSelectedRepoIds((current) =>
+ current.includes(repoId)
+ ? current.filter((id) => id !== repoId)
+ : [...current, repoId],
+ );
+ }
+
+ function handleSaveRepositoryConnection() {
+ if (!canSaveRepositoryConnection) {
+ return;
+ }
+
+ setConnectedRepos(selectedRepositories);
+ onProjectGroupGithubLinkedChange(true);
+ }
+
+ return (
+
+
+
+
+ {setupStatusItems.map((item) => {
+ const Icon = item.icon;
+
+ return (
+
+
+
+
+ {item.ready ? "ready" : "pending"}
+
+
+
+ {item.label}
+
+
+ {item.description}
+
+
+ );
+ })}
+
+
+ {showGithubPolicyNotice ?
: null}
+
+
+
+
+
+
+ GitHub 조직 준비
+
+
+ 팀 스페이스에 연결할 GitHub 조직이 필요해요.
+
+
+
+ {organization ? "found" : "required"}
+
+
+ {organization ? (
+
+
+ {organization.login}
+
+
+ {organization.name}
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ TeamPo GitHub App
+
+
+ 읽기 전용 권한으로 설치하고 필요한 저장소만 선택해요.
+
+
+
+ {isGitHubAppInstalled ? "installed" : "not installed"}
+
+
+
+
+
+ Only select repositories
+ read-only
+ installation_id + state
+
+
+ {demoTeamSpace.githubSummary.appInstallation.permissions.map(
+ (permission) => (
+
+ {permission}
+
+ ),
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ 저장소 선택
+
+
+ 설치된 GitHub App이 접근할 수 있는 저장소 중 팀 활동을 집계할
+ 저장소를 선택해요.
+
+
+
+ {canSelectRepositories ? "selectable" : "install first"}
+
+
+
+ {demoTeamSpace.githubSummary.availableRepositories.map((repo) => (
+
+ ))}
+
+
+
+ {selectedRepoIds.length > 0
+ ? `${selectedRepoIds.length}개 저장소 선택됨`
+ : "최소 1개 저장소를 선택해 주세요."}
+
+
+
+ 선택 저장소 연결
+
+
+
+
+
+
+
+
+ 팀 스페이스 연결 상태
+
+
+ 프로젝트 그룹 기준으로 GitHub 조직 연결 여부를 확인해요.
+
+
+
+ {isProjectGroupGithubLinked ? "linked" : "not linked"}
+
+
+ {connectedRepos.length > 0 ? (
+
+ ) : (
+
+
+ GitHub App 설치와 저장소 선택이 끝나면 이 영역에서 연결된
+ 저장소를 확인할 수 있어요.
+
+
+ )}
+
+
+
+
+
+
+
+
+ GitHub 활동 히트맵
+
+
+ 기여량이 많을수록 색과 밀도가 진해져요.
+
+
+
+ open PR{" "}
+ {isProjectGroupGithubLinked
+ ? demoTeamSpace.githubSummary.openPrs
+ : "-"}
+
+
+
+ {demoTeamSpace.githubSummary.contributionDays.map((day) => (
+
+ ))}
+
+
+ {isProjectGroupGithubLinked
+ ? demoTeamSpace.githubSummary.weeklySummary
+ : "저장소를 연결하면 커밋, PR, 리뷰, 이슈 기준으로 팀원별 기여를 집계해요."}
+
+
+
+
팀원별 기여
+
+ {demoTeamSpace.githubSummary.memberContributions.map(
+ (contribution) => {
+ const member = demoTeamSpace.members.find(
+ (item) => item.id === contribution.memberId,
+ );
+
+ if (!member) {
+ return null;
+ }
+
+ return (
+
+
+
+ {isProjectGroupGithubLinked
+ ? `커밋 ${contribution.commits} · PR ${contribution.prs} · 리뷰 ${contribution.reviews} · 이슈 ${contribution.issues}`
+ : "저장소 연결 후 기여 수치가 표시돼요."}
+
+
+ );
+ },
+ )}
+
+
+
+
+
최근 활동
+ {isProjectGroupGithubLinked ? (
+
+ {demoTeamSpace.githubSummary.recentActivities.map((activity) => (
+
+
+ {activity.type.replace("_", " ")}
+
+
+ {activity.label}
+
+
+ {activity.memberName} · {activity.timeLabel}
+
+
+ ))}
+
+ ) : (
+
+ 연동된 저장소 활동이 아직 없어요.
+
+ )}
+
+
+
+ );
+}
+
+function RepositoryOption({
+ disabled,
+ onToggle,
+ repo,
+ selected,
+}: {
+ disabled: boolean;
+ onToggle: (repoId: string) => void;
+ repo: GithubRepositorySummary;
+ selected: boolean;
+}) {
+ return (
+
+
+
onToggle(repo.id)}
+ type="checkbox"
+ />
+
+
+ {repo.owner}/{repo.name}
+
+
+ {repo.visibility} · {repo.defaultBranch} · pushed{" "}
+ {repo.lastPushedLabel}
+
+
+
+
+
+ {repo.visibility}
+
+
+
+
+ );
+}
+
+interface ChatPanelProps {
+ messages: TeamMessage[];
+ onSend: (message: string) => void;
+}
+
+function ChatPanel({ messages, onSend }: ChatPanelProps) {
+ const [draftMessage, setDraftMessage] = useState("");
+ const messageListRef = useRef(null);
+ const latestMessageId = messages.at(-1)?.id;
+
+ useEffect(() => {
+ const messageList = messageListRef.current;
+
+ if (!messageList || !latestMessageId) {
+ return;
+ }
+
+ messageList.scrollTop = messageList.scrollHeight;
+ }, [latestMessageId]);
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+
+ if (!draftMessage.trim()) {
+ return;
+ }
+
+ onSend(draftMessage);
+ setDraftMessage("");
+ }
+
+ return (
+
+
+
+
+ {messages.map((message) => (
+
+
+
+
{message.author}
+
+ {message.timeLabel}
+
+
+
+ {message.message}
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function getTeamMetrics(checklist: TeamChecklistItem[]) {
+ const doneCount = checklist.filter((item) => item.status === "done").length;
+ const progress = checklist.length
+ ? Math.round((doneCount / checklist.length) * 100)
+ : 0;
+
+ return [
+ {
+ label: "스프린트 진행률",
+ tone: "primary" as const,
+ trend: "이번 주 +14%",
+ value: `${progress}%`,
+ },
+ {
+ label: "완료 체크리스트",
+ tone: "emerald" as const,
+ trend: `${doneCount} / ${checklist.length} 완료`,
+ value: `${doneCount}`,
+ },
+ {
+ label: "오픈 PR",
+ tone: "amber" as const,
+ trend: "리뷰 필요",
+ value: "3",
+ },
+ {
+ label: "팀 온도",
+ tone: "emerald" as const,
+ value: "41.2",
+ },
+ ];
+}
+
+function parseRulesMarkdown(markdown: string) {
+ const lines = markdown
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+ const title =
+ lines.find((line) => line.startsWith("#"))?.replace(/^#+\s*/, "") ??
+ "팀 규칙";
+ const items = lines
+ .filter((line) => line.startsWith("-"))
+ .map((line) => line.replace(/^-\s*/, ""));
+
+ return {
+ items: items.length ? items : ["아직 등록된 규칙이 없어요."],
+ title,
+ };
+}
+
+function renderInlineCode(text: string): ReactNode[] {
+ const parts = text.split(/(`[^`]+`)/g).filter(Boolean);
+
+ return parts.map((part, index) => {
+ const key = `${part}-${index}`;
+
+ if (part.startsWith("`") && part.endsWith("`")) {
+ return (
+
+ {part.slice(1, -1)}
+
+ );
+ }
+
+ return {part} ;
+ });
+}
diff --git a/src/features/team/components/real-github-installation-panel.tsx b/src/features/team/components/real-github-installation-panel.tsx
new file mode 100644
index 0000000..3bd7749
--- /dev/null
+++ b/src/features/team/components/real-github-installation-panel.tsx
@@ -0,0 +1,722 @@
+import {
+ ExternalLink,
+ Github,
+ LoaderCircle,
+ RefreshCw,
+ Save,
+} from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+
+import { AppPanel, AppPanelHeader } from "@/components/app-shell";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { GithubOrganizationPolicyNotice } from "@/features/team/components/github-organization-policy-notice";
+import {
+ type ActionFeedback,
+ RealActionFeedback,
+ RealInlineStatus,
+} from "@/features/team/components/real-team-shared";
+import {
+ useAvailableGithubRepositoriesQuery,
+ useCreateGithubAppInstallationUrlMutation,
+ useGithubInstallationStatusQuery,
+ useGithubRepositoriesQuery,
+ useGithubRepositoryContributionsQuery,
+ useSetGithubRepositoriesMutation,
+ useSyncGithubPullRequestContributionsMutation,
+} from "@/features/team/hooks/use-team-space-queries";
+import { getApiErrorMessage } from "@/lib/api/client";
+import type { MyProjectGroup } from "@/lib/types/project-group";
+import type {
+ GithubRepository,
+ GithubRepositoryContributor,
+} from "@/lib/types/team-space";
+import { cn } from "@/lib/utils";
+
+const contributionNumberFormatter = new Intl.NumberFormat("ko-KR");
+
+function areNumberSelectionsEqual(left: number[], right: number[]) {
+ if (left.length !== right.length) {
+ return false;
+ }
+
+ const normalizedLeft = [...left].sort(
+ (leftValue, rightValue) => leftValue - rightValue,
+ );
+ const normalizedRight = [...right].sort(
+ (leftValue, rightValue) => leftValue - rightValue,
+ );
+
+ return normalizedLeft.every(
+ (value, index) => value === normalizedRight[index],
+ );
+}
+
+export function RealGithubInstallationPanel({
+ canManageGithubInstallation,
+ completionFeedback,
+ isCompletingInstallation,
+ projectGroup,
+}: {
+ canManageGithubInstallation: boolean;
+ completionFeedback: ActionFeedback | null;
+ isCompletingInstallation: boolean;
+ projectGroup: MyProjectGroup;
+}) {
+ const githubStatusQuery = useGithubInstallationStatusQuery(
+ projectGroup.projectGroupId,
+ );
+ const createInstallUrlMutation = useCreateGithubAppInstallationUrlMutation();
+ const setGithubRepositoriesMutation = useSetGithubRepositoriesMutation();
+ const [feedback, setFeedback] = useState(null);
+ const githubStatus = githubStatusQuery.data;
+ const isGithubConnected = githubStatus?.connected === true;
+ const githubRepositoriesQuery = useGithubRepositoriesQuery(
+ projectGroup.projectGroupId,
+ isGithubConnected,
+ );
+ const availableGithubRepositoriesQuery = useAvailableGithubRepositoriesQuery(
+ projectGroup.projectGroupId,
+ isGithubConnected && canManageGithubInstallation,
+ );
+ const connectedRepositories =
+ githubRepositoriesQuery.data?.repositories ?? [];
+ const availableRepositories =
+ availableGithubRepositoriesQuery.data?.repositories ?? [];
+ const connectedRepositoryIds = useMemo(
+ () =>
+ connectedRepositories.map((repository) => repository.githubRepositoryId),
+ [connectedRepositories],
+ );
+ const availableGithubRepositoryIdSet = useMemo(
+ () =>
+ new Set(
+ availableRepositories.map(
+ (repository) => repository.githubRepositoryId,
+ ),
+ ),
+ [availableRepositories],
+ );
+ const configurableRepositories = useMemo(() => {
+ const repositoriesById = new Map();
+
+ for (const repository of availableRepositories) {
+ repositoriesById.set(repository.githubRepositoryId, repository);
+ }
+
+ for (const repository of connectedRepositories) {
+ if (!repositoriesById.has(repository.githubRepositoryId)) {
+ repositoriesById.set(repository.githubRepositoryId, repository);
+ }
+ }
+
+ return [...repositoriesById.values()];
+ }, [availableRepositories, connectedRepositories]);
+ const [selectedGithubRepositoryIds, setSelectedGithubRepositoryIds] =
+ useState([]);
+ const selectedGithubRepositoryIdSet = useMemo(
+ () => new Set(selectedGithubRepositoryIds),
+ [selectedGithubRepositoryIds],
+ );
+ const hasRepositorySelectionChanged = !areNumberSelectionsEqual(
+ selectedGithubRepositoryIds,
+ connectedRepositoryIds,
+ );
+ const hasGithubRepositorySelection = (githubStatus?.repositoryCount ?? 0) > 0;
+ const canCreateInstallUrl =
+ githubStatusQuery.isSuccess &&
+ canManageGithubInstallation &&
+ !githubStatus?.connected &&
+ !createInstallUrlMutation.isPending;
+ const canChangeGithubRepositories =
+ canManageGithubInstallation &&
+ githubRepositoriesQuery.isSuccess &&
+ availableGithubRepositoriesQuery.isSuccess &&
+ !setGithubRepositoriesMutation.isPending;
+ const canSaveGithubRepositories =
+ isGithubConnected &&
+ canChangeGithubRepositories &&
+ hasRepositorySelectionChanged;
+ const showGithubPolicyNotice =
+ githubStatusQuery.isSuccess &&
+ canManageGithubInstallation &&
+ !hasGithubRepositorySelection;
+
+ useEffect(() => {
+ if (!githubRepositoriesQuery.data) {
+ return;
+ }
+
+ setSelectedGithubRepositoryIds(connectedRepositoryIds);
+ }, [connectedRepositoryIds, githubRepositoriesQuery.data]);
+
+ function handleCreateInstallUrl() {
+ setFeedback(null);
+ createInstallUrlMutation.mutate(projectGroup.projectGroupId, {
+ onError: (error: unknown) => {
+ setFeedback({
+ message: getApiErrorMessage(error),
+ tone: "error",
+ });
+ },
+ onSuccess: ({ installUrl }) => {
+ window.location.assign(installUrl);
+ },
+ });
+ }
+
+ function handleGithubRepositoryToggle(githubRepositoryId: number) {
+ setFeedback(null);
+ setSelectedGithubRepositoryIds((current) =>
+ current.includes(githubRepositoryId)
+ ? current.filter((repositoryId) => repositoryId !== githubRepositoryId)
+ : [...current, githubRepositoryId],
+ );
+ }
+
+ function handleSaveGithubRepositories() {
+ setFeedback(null);
+ setGithubRepositoriesMutation.mutate(
+ {
+ githubRepositoryIds: selectedGithubRepositoryIds,
+ projectGroupId: projectGroup.projectGroupId,
+ },
+ {
+ onError: (error: unknown) => {
+ setFeedback({
+ message: getApiErrorMessage(error),
+ tone: "error",
+ });
+ },
+ onSuccess: () => {
+ setFeedback({
+ message: "GitHub 저장소 연결을 저장했어요.",
+ tone: "success",
+ });
+ },
+ },
+ );
+ }
+
+ return (
+
+
+ {githubStatus?.connected ? "connected" : "not connected"}
+
+ }
+ description="GitHub 조직과 저장소 연결 상태를 확인해요."
+ eyebrow="GitHub App"
+ title="GitHub 조직 연결"
+ />
+
+
+
+ {githubStatusQuery.isLoading || isCompletingInstallation ? (
+
}
+ message={
+ isCompletingInstallation
+ ? "GitHub App 설치를 마무리하고 있어요."
+ : "GitHub 연결 상태를 불러오고 있어요."
+ }
+ />
+ ) : null}
+
+ {githubStatusQuery.error ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ {showGithubPolicyNotice ?
: null}
+
+
+
+
+ TeamPo GitHub App
+
+
+ {githubStatus?.connected
+ ? "GitHub 조직이 팀 스페이스에 연결되어 있어요."
+ : "호스트가 GitHub App 설치를 시작할 수 있어요."}
+
+
+
+ {createInstallUrlMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 설치 URL 발급
+
+
+
+ {isGithubConnected ? (
+
+
+
+
+
+ 연결된 저장소
+
+
+ 팀 스페이스에 등록된 GitHub 저장소예요.
+
+
+
0 ? "brand" : "neutral"
+ }
+ >
+ {connectedRepositories.length}개
+
+
+
+
+ {githubRepositoriesQuery.isLoading ? (
+
}
+ message="등록된 저장소를 불러오고 있어요."
+ />
+ ) : null}
+
+ {githubRepositoriesQuery.error ? (
+
+ ) : null}
+
+ {githubRepositoriesQuery.isSuccess &&
+ connectedRepositories.length === 0 ? (
+
+
+ 아직 팀 스페이스에 등록된 GitHub 저장소가 없어요.
+
+
+ ) : null}
+
+ {connectedRepositories.map((repository) => (
+
+ ))}
+
+
+
+
+
+
+
+ 저장소 설정
+
+
+ GitHub App이 접근할 수 있는 저장소 중 집계할 대상을
+ 선택해요.
+
+
+
+ {canManageGithubInstallation ? "editable" : "read only"}
+
+
+
+ {canManageGithubInstallation ? (
+
+ {availableGithubRepositoriesQuery.isLoading ? (
+
}
+ message="선택 가능한 저장소를 불러오고 있어요."
+ />
+ ) : null}
+
+ {availableGithubRepositoriesQuery.error ? (
+
+ ) : null}
+
+ {availableGithubRepositoriesQuery.isSuccess &&
+ availableRepositories.length === 0 ? (
+
+
+ GitHub App이 접근할 수 있는 저장소가 없어요.
+
+
+ ) : null}
+
+ {configurableRepositories.map((repository) => {
+ const selected = selectedGithubRepositoryIdSet.has(
+ repository.githubRepositoryId,
+ );
+ const available = availableGithubRepositoryIdSet.has(
+ repository.githubRepositoryId,
+ );
+
+ return (
+
+ );
+ })}
+
+
+
+ {selectedGithubRepositoryIds.length}개 저장소 선택됨
+
+
+ {setGithubRepositoriesMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 저장소 설정 저장
+
+
+
+ ) : (
+
+
+ 호스트만 GitHub 저장소 설정을 변경할 수 있어요.
+
+
+ )}
+
+
+ ) : null}
+
+
+ );
+}
+
+function RealGithubStatusCard({
+ label,
+ ready,
+ value,
+}: {
+ label: string;
+ ready: boolean;
+ value: string;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {ready ? "ready" : "pending"}
+
+
+
+ {value}
+
+
+ );
+}
+
+function RealGithubRepositoryContributionCard({
+ canManageGithubInstallation,
+ projectGroupId,
+ repository,
+}: {
+ canManageGithubInstallation: boolean;
+ projectGroupId: number;
+ repository: GithubRepository;
+}) {
+ const contributionsQuery = useGithubRepositoryContributionsQuery(
+ projectGroupId,
+ repository.githubRepositoryId,
+ );
+ const syncContributionsMutation =
+ useSyncGithubPullRequestContributionsMutation();
+ const contributors = contributionsQuery.data?.contributors ?? [];
+ const sortedContributors = useMemo(
+ () =>
+ [...contributors].sort(
+ (left, right) => right.contributionScore - left.contributionScore,
+ ),
+ [contributors],
+ );
+ const totals = useMemo(
+ () => calculateGithubContributionTotals(contributors),
+ [contributors],
+ );
+ const isSyncingCurrentRepository =
+ syncContributionsMutation.isPending &&
+ syncContributionsMutation.variables?.githubRepositoryId ===
+ repository.githubRepositoryId;
+
+ function handleSyncContributions() {
+ syncContributionsMutation.mutate({
+ githubRepositoryId: repository.githubRepositoryId,
+ projectGroupId,
+ });
+ }
+
+ return (
+
+
+
+ {contributionsQuery.isLoading ? (
+
}
+ message="저장소 기여도를 불러오고 있어요."
+ />
+ ) : null}
+
+ {contributionsQuery.error ? (
+
+ ) : null}
+
+ {syncContributionsMutation.error &&
+ syncContributionsMutation.variables?.githubRepositoryId ===
+ repository.githubRepositoryId ? (
+
+ ) : null}
+
+ {contributionsQuery.isSuccess ? (
+
+
+
+
+
+
+
+
+ {sortedContributors.length > 0 ? (
+
+ {sortedContributors.map((contributor) => (
+
+ ))}
+
+ ) : (
+
+
+ 아직 동기화된 PR 기여도가 없어요.
+
+
+ )}
+
+ ) : null}
+
+ );
+}
+
+function calculateGithubContributionTotals(
+ contributors: GithubRepositoryContributor[],
+) {
+ return contributors.reduce(
+ (totals, contributor) => ({
+ changedFiles: totals.changedFiles + contributor.changedFiles,
+ contributionScore:
+ totals.contributionScore + contributor.contributionScore,
+ linkedIssueCount: totals.linkedIssueCount + contributor.linkedIssueCount,
+ mergedPrCount: totals.mergedPrCount + contributor.mergedPrCount,
+ }),
+ {
+ changedFiles: 0,
+ contributionScore: 0,
+ linkedIssueCount: 0,
+ mergedPrCount: 0,
+ },
+ );
+}
+
+function RealGithubContributionStat({
+ label,
+ value,
+}: {
+ label: string;
+ value: number;
+}) {
+ return (
+
+
+ {label}
+
+
+ {contributionNumberFormatter.format(value)}
+
+
+ );
+}
+
+function RealGithubContributorRow({
+ contributor,
+}: {
+ contributor: GithubRepositoryContributor;
+}) {
+ return (
+
+
+
+ @{contributor.githubUsername}
+
+
+ PR {contributionNumberFormatter.format(contributor.mergedPrCount)} ·
+ 이슈{" "}
+ {contributionNumberFormatter.format(contributor.linkedIssueCount)}
+
+
+
+
+ +{contributionNumberFormatter.format(contributor.additions)}
+
+
+ -{contributionNumberFormatter.format(contributor.deletions)}
+
+
+ {contributionNumberFormatter.format(contributor.changedFiles)} files
+
+
+ {contributionNumberFormatter.format(contributor.contributionScore)}
+
+
+
+ );
+}
+
+function RealGithubRepositoryOption({
+ disabled,
+ onToggle,
+ repository,
+ selected,
+ unavailable,
+}: {
+ disabled: boolean;
+ onToggle: (githubRepositoryId: number) => void;
+ repository: GithubRepository;
+ selected: boolean;
+ unavailable: boolean;
+}) {
+ return (
+
+ onToggle(repository.githubRepositoryId)}
+ type="checkbox"
+ />
+
+
+ {repository.fullName}
+
+
+ {unavailable ? "GitHub App 접근 권한 없음" : repository.repoName}
+
+
+
+ );
+}
diff --git a/src/features/team/components/real-project-checklists-panel.tsx b/src/features/team/components/real-project-checklists-panel.tsx
new file mode 100644
index 0000000..900a822
--- /dev/null
+++ b/src/features/team/components/real-project-checklists-panel.tsx
@@ -0,0 +1,672 @@
+import {
+ CheckCircle2,
+ LoaderCircle,
+ PencilLine,
+ Plus,
+ Save,
+ Sparkles,
+ Trash2,
+ X,
+} from "lucide-react";
+import { type FormEvent, useState } from "react";
+
+import { AppPanel, AppPanelHeader } from "@/components/app-shell";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ type ActionFeedback,
+ RealActionFeedback,
+ RealInlineStatus,
+ projectChecklistStatusLabels,
+ projectChecklistStatusTone,
+} from "@/features/team/components/real-team-shared";
+import {
+ useCreateProjectChecklistMutation,
+ useDeleteProjectChecklistMutation,
+ useGenerateChecklistAdviceMutation,
+ useProjectChecklistsQuery,
+ useUpdateProjectChecklistMutation,
+} from "@/features/team/hooks/use-project-checklist-queries";
+import { getApiErrorMessage } from "@/lib/api/client";
+import type {
+ ProjectChecklist,
+ ProjectChecklistStatus,
+} from "@/lib/types/project-checklist";
+import type { MyProjectGroup } from "@/lib/types/project-group";
+import { cn } from "@/lib/utils";
+
+const checklistControlClass =
+ "h-11 w-full min-w-0 rounded-lg border border-input bg-white px-3 text-sm font-normal outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
+
+export function RealProjectChecklistsPanel({
+ projectGroup,
+}: {
+ projectGroup: MyProjectGroup;
+}) {
+ const checklistQuery = useProjectChecklistsQuery(projectGroup.projectGroupId);
+ const createChecklistMutation = useCreateProjectChecklistMutation();
+ const updateChecklistMutation = useUpdateProjectChecklistMutation();
+ const deleteChecklistMutation = useDeleteProjectChecklistMutation();
+ const generateAdviceMutation = useGenerateChecklistAdviceMutation();
+ const [feedback, setFeedback] = useState(null);
+ const [draft, setDraft] = useState({
+ assigneeUserId: "",
+ description: "",
+ dueDate: "",
+ title: "",
+ });
+ const [editingChecklistId, setEditingChecklistId] = useState(
+ null,
+ );
+ const checklists = checklistQuery.data ?? [];
+ const pendingChecklistId = updateChecklistMutation.isPending
+ ? (updateChecklistMutation.variables?.checklistId ?? null)
+ : deleteChecklistMutation.isPending
+ ? (deleteChecklistMutation.variables?.checklistId ?? null)
+ : generateAdviceMutation.isPending
+ ? (generateAdviceMutation.variables?.checklistId ?? null)
+ : null;
+ const isChecklistActionPending =
+ updateChecklistMutation.isPending ||
+ deleteChecklistMutation.isPending ||
+ generateAdviceMutation.isPending;
+
+ async function handleCreateChecklist(event: FormEvent) {
+ event.preventDefault();
+
+ const title = draft.title.trim();
+
+ if (!title) {
+ setFeedback({
+ message: "체크리스트 제목을 입력해 주세요.",
+ tone: "error",
+ });
+ return;
+ }
+
+ setFeedback(null);
+ try {
+ await createChecklistMutation.mutateAsync({
+ assigneeUserId: draft.assigneeUserId
+ ? Number(draft.assigneeUserId)
+ : null,
+ description: draft.description.trim() || null,
+ dueDate: draft.dueDate || null,
+ projectGroupId: projectGroup.projectGroupId,
+ title,
+ });
+ setDraft({
+ assigneeUserId: "",
+ description: "",
+ dueDate: "",
+ title: "",
+ });
+ setFeedback({
+ message: "체크리스트를 추가했어요.",
+ tone: "success",
+ });
+ } catch (error: unknown) {
+ setFeedback({
+ message: getApiErrorMessage(error),
+ tone: "error",
+ });
+ }
+ }
+
+ function handleStartEdit(checklist: ProjectChecklist) {
+ setFeedback(null);
+ setEditingChecklistId(checklist.id);
+ }
+
+ function handleCancelEdit(checklistId?: number) {
+ setEditingChecklistId((currentChecklistId) => {
+ if (checklistId === undefined || currentChecklistId === checklistId) {
+ return null;
+ }
+
+ return currentChecklistId;
+ });
+ }
+
+ async function handleUpdateChecklist(
+ event: FormEvent,
+ checklist: ProjectChecklist,
+ ) {
+ event.preventDefault();
+
+ const formData = new FormData(event.currentTarget);
+ const title = getChecklistFormValue(formData, "title").trim();
+ const description = getChecklistFormValue(formData, "description").trim();
+ const dueDate = getChecklistFormValue(formData, "dueDate");
+ const assigneeUserId = getChecklistFormValue(formData, "assigneeUserId");
+ const status = getChecklistFormValue(formData, "status");
+
+ if (!title) {
+ setFeedback({
+ message: "체크리스트 제목을 입력해 주세요.",
+ tone: "error",
+ });
+ return;
+ }
+
+ if (!isProjectChecklistStatus(status)) {
+ setFeedback({
+ message: "체크리스트 상태를 다시 선택해 주세요.",
+ tone: "error",
+ });
+ return;
+ }
+
+ setFeedback(null);
+ try {
+ await updateChecklistMutation.mutateAsync({
+ assigneeUserId: assigneeUserId ? Number(assigneeUserId) : null,
+ checklistId: checklist.id,
+ description: description || null,
+ dueDate: dueDate || null,
+ projectGroupId: projectGroup.projectGroupId,
+ status,
+ title,
+ });
+ handleCancelEdit(checklist.id);
+ setFeedback({
+ message: "체크리스트를 수정했어요.",
+ tone: "success",
+ });
+ } catch (error: unknown) {
+ setFeedback({
+ message: getApiErrorMessage(error),
+ tone: "error",
+ });
+ }
+ }
+
+ async function handleStatusChange(
+ checklist: ProjectChecklist,
+ status: ProjectChecklistStatus,
+ ) {
+ setFeedback(null);
+ try {
+ await updateChecklistMutation.mutateAsync({
+ assigneeUserId: checklist.assigneeUserId,
+ checklistId: checklist.id,
+ description: checklist.description,
+ dueDate: checklist.dueDate,
+ projectGroupId: projectGroup.projectGroupId,
+ status,
+ title: checklist.title,
+ });
+ setFeedback({
+ message: "체크리스트 상태를 바꿨어요.",
+ tone: "success",
+ });
+ } catch (error: unknown) {
+ setFeedback({
+ message: getApiErrorMessage(error),
+ tone: "error",
+ });
+ }
+ }
+
+ async function handleDeleteChecklist(checklist: ProjectChecklist) {
+ setFeedback(null);
+ try {
+ await deleteChecklistMutation.mutateAsync({
+ checklistId: checklist.id,
+ projectGroupId: projectGroup.projectGroupId,
+ });
+ setFeedback({
+ message: "체크리스트를 삭제했어요.",
+ tone: "success",
+ });
+ } catch (error: unknown) {
+ setFeedback({
+ message: getApiErrorMessage(error),
+ tone: "error",
+ });
+ }
+ }
+
+ async function handleGenerateAdvice(checklist: ProjectChecklist) {
+ setFeedback(null);
+ try {
+ await generateAdviceMutation.mutateAsync({
+ checklistId: checklist.id,
+ projectGroupId: projectGroup.projectGroupId,
+ });
+ setFeedback({
+ message: "AI 조언을 만들었어요.",
+ tone: "success",
+ });
+ } catch (error: unknown) {
+ setFeedback({
+ message: getApiErrorMessage(error),
+ tone: "error",
+ });
+ }
+ }
+
+ return (
+
+ {checklists.length} tasks}
+ description="팀 작업, 담당자, 마감일, AI 조언을 함께 관리해요."
+ eyebrow="Checklist"
+ title="프로젝트 체크리스트"
+ />
+
+
+
+
+
+ {checklistQuery.isLoading ? (
+
}
+ message="체크리스트를 불러오고 있어요."
+ />
+ ) : null}
+
+ {checklistQuery.error ? (
+
+ ) : null}
+
+
+ {checklists.map((checklist) => {
+ const isPending = pendingChecklistId === checklist.id;
+ const isEditing = editingChecklistId === checklist.id;
+
+ return (
+
+ {isEditing ? (
+
+ ) : (
+ <>
+
+
+
+ {projectChecklistStatusLabels[checklist.status]}
+
+
+ {checklist.title}
+
+
+
+ {checklist.description ?? "설명이 없어요."}
+
+
+
+ 담당 {checklist.assigneeNickname ?? "미지정"}
+
+ 마감 {checklist.dueDate ?? "미정"}
+ 생성 {checklist.createdByNickname}
+
+
+
+
handleStartEdit(checklist)}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+
+ 수정
+
+
+ handleStatusChange(
+ checklist,
+ checklist.status === "DONE" ? "TODO" : "DONE",
+ )
+ }
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ {isPending &&
+ updateChecklistMutation.variables?.checklistId ===
+ checklist.id ? (
+
+ ) : (
+
+ )}
+ {checklist.status === "DONE" ? "다시 열기" : "완료"}
+
+
handleGenerateAdvice(checklist)}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ {isPending &&
+ generateAdviceMutation.variables?.checklistId ===
+ checklist.id ? (
+
+ ) : (
+
+ )}
+ AI 조언
+
+
handleDeleteChecklist(checklist)}
+ size="icon"
+ title="체크리스트 삭제"
+ type="button"
+ variant="ghost"
+ >
+ {isPending &&
+ deleteChecklistMutation.variables?.checklistId ===
+ checklist.id ? (
+
+ ) : (
+
+ )}
+
+
+ {checklist.aiAdvice ? (
+
+ ) : null}
+ >
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
+
+function RealChecklistAdvice({
+ checklist,
+ className,
+}: {
+ checklist: ProjectChecklist;
+ className?: string;
+}) {
+ if (!checklist.aiAdvice) {
+ return null;
+ }
+
+ return (
+
+
+ {checklist.aiAdvice.summary}
+
+
+
+
+
+
+
+ );
+}
+
+function RealAdviceList({ items, title }: { items: string[]; title: string }) {
+ return (
+
+
+ {title}
+
+
+ {items.map((item) => (
+ {item}
+ ))}
+
+
+ );
+}
+
+function getChecklistFormValue(formData: FormData, key: string) {
+ const value = formData.get(key);
+ return typeof value === "string" ? value : "";
+}
+
+function isProjectChecklistStatus(
+ value: string,
+): value is ProjectChecklistStatus {
+ return value === "TODO" || value === "DONE";
+}
diff --git a/src/features/team/components/real-team-guide-panel.tsx b/src/features/team/components/real-team-guide-panel.tsx
new file mode 100644
index 0000000..c5dc2ab
--- /dev/null
+++ b/src/features/team/components/real-team-guide-panel.tsx
@@ -0,0 +1,486 @@
+import { CheckCircle2, LoaderCircle, RefreshCw } from "lucide-react";
+
+import { AppPanel, AppPanelHeader } from "@/components/app-shell";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { RealInlineStatus } from "@/features/team/components/real-team-shared";
+import {
+ useDevGuideQuery,
+ useRegenerateDevGuideMutation,
+} from "@/features/team/hooks/use-team-space-queries";
+import { getApiErrorMessage } from "@/lib/api/client";
+import type { MyProjectGroup } from "@/lib/types/project-group";
+import type {
+ DevGuideContent,
+ DevGuideGenerationStatus,
+ DevGuideQueryResponse,
+} from "@/lib/types/team-space";
+
+export function RealGuidePanel({
+ projectGroup,
+}: {
+ projectGroup: MyProjectGroup;
+}) {
+ const devGuideQuery = useDevGuideQuery(projectGroup.projectGroupId);
+ const regenerateDevGuideMutation = useRegenerateDevGuideMutation();
+ const guideResponse = devGuideQuery.data;
+ const guide = getDevGuideContent(guideResponse);
+ const generationStatus = guideResponse?.generationStatus;
+ const remainingRegenerationCount =
+ guideResponse?.remainingRegenerationCount ?? null;
+
+ const handleRegenerateDevGuide = () => {
+ regenerateDevGuideMutation.mutate({
+ projectGroupId: projectGroup.projectGroupId,
+ });
+ };
+
+ if (devGuideQuery.isLoading) {
+ return (
+
+ 조회 중}
+ description="팀 방향, MVP 우선순위, 결정 포인트를 불러오고 있어요."
+ eyebrow="Guide"
+ title="AI 개발 가이드"
+ />
+
+
+ }
+ message="AI 개발 가이드를 불러오고 있어요."
+ />
+
+
+ );
+ }
+
+ if (devGuideQuery.error) {
+ if (isDevGuideNotFoundError(devGuideQuery.error)) {
+ return (
+
+ 대기}
+ description="아직 이 팀에 생성된 AI 개발 가이드가 없어요."
+ eyebrow="Guide"
+ title="AI 개발 가이드"
+ />
+
+
+
+
+ void devGuideQuery.refetch()}
+ type="button"
+ variant="outline"
+ >
+ {devGuideQuery.isFetching ? (
+
+ ) : (
+
+ )}
+ 다시 확인
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ 오류}
+ description="AI 개발 가이드를 불러오지 못했어요."
+ eyebrow="Guide"
+ title="AI 개발 가이드"
+ />
+
+
+
+
+ );
+ }
+
+ if (!guide) {
+ const isGenerating = generationStatus === "GENERATING";
+ const isFailed = generationStatus === "FAILED";
+
+ return (
+
+ void devGuideQuery.refetch()}
+ onRegenerate={handleRegenerateDevGuide}
+ remainingRegenerationCount={remainingRegenerationCount}
+ showRegenerate={isFailed}
+ />
+ }
+ description={
+ isFailed
+ ? "AI 개발 가이드 생성에 실패했어요."
+ : "아직 이 팀에 생성된 AI 개발 가이드가 없어요."
+ }
+ eyebrow="Guide"
+ title="AI 개발 가이드"
+ />
+
+
+
+ ) : undefined
+ }
+ message={
+ isFailed
+ ? "재생성을 누르면 AI가 팀 정보를 바탕으로 가이드를 다시 만들어요."
+ : "팀을 만든 직후라면 AI 개발 가이드 생성이 아직 진행 중일 수 있어요."
+ }
+ />
+ {regenerateDevGuideMutation.error ? (
+
+ ) : null}
+
+
+
+ );
+ }
+
+ return (
+
+
+ void devGuideQuery.refetch()}
+ onRegenerate={handleRegenerateDevGuide}
+ remainingRegenerationCount={remainingRegenerationCount}
+ showRegenerate={generationStatus !== "GENERATING"}
+ />
+ }
+ description={getDevGuidePanelDescription(generationStatus)}
+ eyebrow="Guide"
+ title="AI 개발 가이드"
+ />
+
+ {regenerateDevGuideMutation.error ? (
+
+ ) : null}
+
+
프로젝트 개요
+
+ {guide.overview}
+
+
+
+ {guide.mvpPriorities.map((priority) => (
+
+
+
+ {priority.feature}
+
+ P{priority.priority}
+
+
+ {priority.rationale}
+
+
+ {priority.subFeatures.map((subFeature) => (
+
+
+ {subFeature}
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function getDevGuideContent(
+ response: DevGuideQueryResponse | undefined,
+): DevGuideContent | null {
+ if (
+ !response ||
+ typeof response.overview !== "string" ||
+ !Array.isArray(response.techStack) ||
+ !Array.isArray(response.mvpPriorities) ||
+ !Array.isArray(response.decisionPoints) ||
+ !Array.isArray(response.milestones)
+ ) {
+ return null;
+ }
+
+ return {
+ decisionPoints: response.decisionPoints,
+ milestones: response.milestones,
+ mvpPriorities: response.mvpPriorities,
+ overview: response.overview,
+ techStack: response.techStack,
+ };
+}
+
+function getDevGuidePanelDescription(
+ generationStatus: DevGuideGenerationStatus | undefined,
+) {
+ if (generationStatus === "GENERATING") {
+ return "기존 AI 개발 가이드를 보여주는 동안 새 가이드를 만들고 있어요.";
+ }
+
+ if (generationStatus === "FAILED") {
+ return "최근 생성이 실패해서 기존 AI 개발 가이드를 보여주고 있어요.";
+ }
+
+ return "팀 방향, MVP 우선순위, 결정 포인트를 한곳에 모았어요.";
+}
+
+function DevGuideHeaderAction({
+ generationStatus,
+ isFetching,
+ isRegenerating,
+ onRefresh,
+ onRegenerate,
+ remainingRegenerationCount,
+ showRegenerate,
+}: {
+ generationStatus: DevGuideGenerationStatus;
+ isFetching: boolean;
+ isRegenerating: boolean;
+ onRefresh: () => void;
+ onRegenerate: () => void;
+ remainingRegenerationCount: number | null;
+ showRegenerate: boolean;
+}) {
+ const hasNoManualRegenerationCount =
+ generationStatus === "COMPLETED" && remainingRegenerationCount === 0;
+ const isGenerating = generationStatus === "GENERATING";
+
+ return (
+
+
+ {getDevGuideStatusLabel(generationStatus)}
+
+ {remainingRegenerationCount !== null ? (
+ 남은 {remainingRegenerationCount}
+ ) : null}
+ {showRegenerate ? (
+
+ {isRegenerating ? (
+
+ ) : (
+
+ )}
+ 재생성
+
+ ) : (
+
+ {isFetching || isGenerating ? (
+
+ ) : (
+
+ )}
+ 다시 확인
+
+ )}
+
+ );
+}
+
+function getDevGuideStatusLabel(status: DevGuideGenerationStatus) {
+ if (status === "GENERATING") {
+ return "생성 중";
+ }
+
+ if (status === "FAILED") {
+ return "실패";
+ }
+
+ return "생성됨";
+}
+
+function getDevGuideStatusBadgeVariant(status: DevGuideGenerationStatus) {
+ if (status === "COMPLETED") {
+ return "brand";
+ }
+
+ if (status === "FAILED") {
+ return "warm";
+ }
+
+ return "neutral";
+}
+
+function isDevGuideNotFoundError(error: unknown) {
+ if (typeof error !== "object" || error === null || !("code" in error)) {
+ return false;
+ }
+
+ return error.code === "DEV_GUIDE_NOT_FOUND";
+}
+
+function RealDevGuideTechStackPanel({ guide }: { guide: DevGuideContent }) {
+ return (
+
+
+
+ {guide.techStack.map((item) => (
+
+
+
{item.category}
+
{item.recommendation}
+
+
+ {item.reason}
+
+
+ ))}
+
+
+ );
+}
+
+function RealDevGuideDecisionPanel({ guide }: { guide: DevGuideContent }) {
+ return (
+
+
+
+ {guide.decisionPoints.map((decision) => (
+
+
{decision.topic}
+
+ {decision.options.map((option) => (
+
+ {option}
+
+ ))}
+
+
+ {decision.consideration}
+
+
+ ))}
+
+
+ );
+}
+
+function RealDevGuideMilestonePanel({ guide }: { guide: DevGuideContent }) {
+ return (
+
+
+
+ {guide.milestones.map((milestone) => (
+
+
+
+ {milestone.week}주차
+
+ W{milestone.week}
+
+
+ {milestone.goal}
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function RealDevGuideRoleTask({
+ label,
+ value,
+}: {
+ label: string;
+ value: string;
+}) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/src/features/team/components/real-team-manage-panel.tsx b/src/features/team/components/real-team-manage-panel.tsx
new file mode 100644
index 0000000..8b7e77f
--- /dev/null
+++ b/src/features/team/components/real-team-manage-panel.tsx
@@ -0,0 +1,297 @@
+import { CheckCircle2, LoaderCircle, ShieldCheck } from "lucide-react";
+
+import { AppPanel, AppPanelHeader } from "@/components/app-shell";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ type ActionFeedback,
+ RealActionFeedback,
+ formatMemberRole,
+ getProjectGroupMemberImageSrc,
+} from "@/features/team/components/real-team-shared";
+import type {
+ MyProjectGroup,
+ ProjectGroupMember,
+} from "@/lib/types/project-group";
+import { cn } from "@/lib/utils";
+
+export function RealManagePanel({
+ canManageAdminPermissions,
+ currentUserId,
+ finishFeedback,
+ feedback,
+ hasCurrentUserAgreedFinish,
+ isAdminPermissionPending,
+ isFinishPending,
+ onAdminPermissionChange,
+ onFinishProjectGroup,
+ pendingAdminPermissionTargetId,
+ projectGroup,
+}: {
+ canManageAdminPermissions: boolean;
+ currentUserId: number;
+ finishFeedback: ActionFeedback | null;
+ feedback: ActionFeedback | null;
+ hasCurrentUserAgreedFinish: boolean;
+ isAdminPermissionPending: boolean;
+ isFinishPending: boolean;
+ onAdminPermissionChange: (member: ProjectGroupMember) => void;
+ onFinishProjectGroup: () => void;
+ pendingAdminPermissionTargetId: number | null;
+ projectGroup: MyProjectGroup;
+}) {
+ return (
+
+
+ active}
+ description="멤버 관리자 권한과 팀 종료 동의를 관리해요."
+ eyebrow="Manage"
+ title="팀 관리"
+ />
+
+
+
+ 팀 이름
+
+
+
+ 팀 상태
+
+ 운영 중
+
+
+
+
+ 팀 이름 편집은 준비 중이에요. 팀 종료는 모든 팀원의 동의가 모이면
+ 완료돼요.
+
+
+
+
+
+
+ {hasCurrentUserAgreedFinish ? "agreed" : "pending"}
+
+ }
+ description="진행 중인 팀 스페이스를 종료하려면 팀원 전원의 동의가 필요해요."
+ eyebrow="Finish"
+ title="팀 종료 동의"
+ />
+
+
+
+
+ {projectGroup.projectName} 종료 동의
+
+
+ 동의가 기록되면 팀 스페이스 종료 조건에 반영돼요.
+
+
+
+ {isFinishPending ? (
+
+ ) : (
+
+ )}
+ {isFinishPending
+ ? "기록 중"
+ : hasCurrentUserAgreedFinish
+ ? "동의 완료"
+ : "종료 동의"}
+
+
+
+
+
+
+
+
+
+
+
+ {projectGroup.members.map((member) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function RealAdminPermissionStatus({
+ canManageAdminPermissions,
+ feedback,
+}: {
+ canManageAdminPermissions: boolean;
+ feedback: ActionFeedback | null;
+}) {
+ return (
+
+
+
+
+
+
+
관리자 권한 관리
+
+ {canManageAdminPermissions
+ ? "팀원 카드에서 권한을 조정할 수 있어요."
+ : "방장 계정에서만 권한을 변경할 수 있어요."}
+
+
+
+
+
+ {canManageAdminPermissions ? "HOST" : "READ ONLY"}
+
+ {feedback ? (
+
+ {feedback.message}
+
+ ) : null}
+
+
+ );
+}
+
+function RealProjectGroupMemberCard({
+ canManageAdminPermissions,
+ currentUserId,
+ isAdminPermissionPending,
+ member,
+ onAdminPermissionChange,
+ pendingAdminPermissionTargetId,
+}: {
+ canManageAdminPermissions: boolean;
+ currentUserId: number;
+ isAdminPermissionPending: boolean;
+ member: ProjectGroupMember;
+ onAdminPermissionChange: (member: ProjectGroupMember) => void;
+ pendingAdminPermissionTargetId: number | null;
+}) {
+ const profileImageSrc = getProjectGroupMemberImageSrc(member.profileImage);
+ const canChangeThisMember =
+ canManageAdminPermissions && member.groupRole === "MEMBER";
+ const isThisMemberPending = pendingAdminPermissionTargetId === member.userId;
+
+ return (
+
+
+
+ {profileImageSrc ? (
+
+ ) : (
+ member.nickname.slice(0, 2).toUpperCase()
+ )}
+
+
+
+
+ {member.nickname}
+
+ {member.userId === currentUserId ? (
+
ME
+ ) : null}
+
+
+ {formatMemberRole(member.memberRole)} · Lv.{member.level} · 온도{" "}
+ {member.temperature}
+
+
+
+
+
+
+ {member.groupRole}
+
+
+ {member.admin ? "ADMIN" : "MEMBER"}
+
+
+ {canChangeThisMember ? (
+
onAdminPermissionChange(member)}
+ size="sm"
+ variant={member.admin ? "outline" : "default"}
+ >
+ {isThisMemberPending ? (
+
+ ) : (
+
+ )}
+ {isThisMemberPending
+ ? "처리 중"
+ : member.admin
+ ? "권한 회수"
+ : "관리자 부여"}
+
+ ) : null}
+ {canManageAdminPermissions && member.groupRole === "HOST" ? (
+
+ 방장 권한 고정
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/features/team/components/real-team-overview-panel.tsx b/src/features/team/components/real-team-overview-panel.tsx
new file mode 100644
index 0000000..12984dc
--- /dev/null
+++ b/src/features/team/components/real-team-overview-panel.tsx
@@ -0,0 +1,179 @@
+import { AppPanel, AppPanelHeader } from "@/components/app-shell";
+import { Badge } from "@/components/ui/badge";
+import {
+ RealInlineStatus,
+ formatMemberRole,
+ getProjectChecklistSummary,
+ getProjectGroupMemberImageSrc,
+ projectChecklistStatusLabels,
+ projectChecklistStatusTone,
+} from "@/features/team/components/real-team-shared";
+import type { ProjectChecklist } from "@/lib/types/project-checklist";
+import type {
+ MyProjectGroup,
+ ProjectGroupMember,
+} from "@/lib/types/project-group";
+import { cn } from "@/lib/utils";
+
+export function RealOverviewPanel({
+ checklistErrorMessage,
+ checklists,
+ projectGroup,
+}: {
+ checklistErrorMessage: string | null;
+ checklists: ProjectChecklist[];
+ projectGroup: MyProjectGroup;
+}) {
+ const summary = getProjectChecklistSummary(checklists);
+
+ return (
+
+
+
+
+
+
+ MVP
+
+
+ {projectGroup.projectMvp ?? "MVP가 아직 등록되지 않았어요."}
+
+
+
+ {projectGroup.members.map((member) => (
+
+ ))}
+
+
+
+
+
+
+
+ {checklistErrorMessage ? (
+
+ ) : null}
+ {checklistErrorMessage ? null : (
+ <>
+
+
+ 체크리스트 {summary.doneCount}/{summary.totalCount} 완료
+
+
+
+ {checklists.slice(0, 3).map((checklist) => (
+
+
+ {projectChecklistStatusLabels[checklist.status]}
+
+
+
+ {checklist.title}
+
+
+ 담당 {checklist.assigneeNickname ?? "미지정"}
+
+
+
+ ))}
+ {checklists.length === 0 ? (
+
+ 아직 등록된 체크리스트가 없어요. 체크리스트 탭에서 첫 작업을
+ 만들어 주세요.
+
+ ) : null}
+ >
+ )}
+
+
+
+ );
+}
+
+function RealMemberSummaryCard({
+ currentUserId,
+ member,
+}: {
+ currentUserId: number;
+ member: ProjectGroupMember;
+}) {
+ const profileImageSrc = getProjectGroupMemberImageSrc(member.profileImage);
+
+ return (
+
+
+
+ {profileImageSrc ? (
+
+ ) : (
+ member.nickname.slice(0, 1).toUpperCase()
+ )}
+
+
+
+
+ {member.nickname}
+
+
+ {formatMemberRole(member.memberRole)}
+
+ {member.userId === currentUserId ? (
+
ME
+ ) : null}
+
+
+ Lv.{member.level} · 온도 {member.temperature}
+
+
+
+
+
+ {member.groupRole === "HOST"
+ ? "팀 운영과 관리 권한을 맡고 있어요."
+ : "팀 작업과 체크리스트를 함께 진행해요."}
+
+
+
+ {member.groupRole}
+
+
+ {member.admin ? "ADMIN" : "MEMBER"}
+
+
+
+
+ );
+}
diff --git a/src/features/team/components/real-team-shared.tsx b/src/features/team/components/real-team-shared.tsx
new file mode 100644
index 0000000..1e2cc85
--- /dev/null
+++ b/src/features/team/components/real-team-shared.tsx
@@ -0,0 +1,103 @@
+import type { ReactNode } from "react";
+
+import type {
+ ProjectChecklist,
+ ProjectChecklistStatus,
+} from "@/lib/types/project-checklist";
+import type { ProjectGroupMember } from "@/lib/types/project-group";
+import { cn } from "@/lib/utils";
+
+export type ActionFeedback = {
+ message: string;
+ tone: "error" | "success";
+};
+
+export const projectChecklistStatusLabels: Record<
+ ProjectChecklistStatus,
+ string
+> = {
+ DONE: "완료",
+ TODO: "할 일",
+};
+
+export const projectChecklistStatusTone: Record<
+ ProjectChecklistStatus,
+ string
+> = {
+ DONE: "border-emerald-500/25 bg-emerald-50 text-emerald-700",
+ TODO: "border-border bg-secondary/45 text-muted-foreground",
+};
+
+export function getProjectChecklistSummary(checklists: ProjectChecklist[]) {
+ const totalCount = checklists.length;
+ const doneCount = checklists.filter(
+ (checklist) => checklist.status === "DONE",
+ ).length;
+ const openCount = totalCount - doneCount;
+ const progress = totalCount ? Math.round((doneCount / totalCount) * 100) : 0;
+
+ return { doneCount, openCount, progress, totalCount };
+}
+
+export function RealActionFeedback({
+ feedback,
+}: {
+ feedback: ActionFeedback | null;
+}) {
+ if (!feedback) {
+ return null;
+ }
+
+ return (
+
+ {feedback.message}
+
+ );
+}
+
+export function RealInlineStatus({
+ className,
+ icon,
+ message,
+}: {
+ className?: string;
+ icon?: ReactNode;
+ message: string;
+}) {
+ return (
+
+ {icon}
+ {message}
+
+ );
+}
+
+export function getProjectGroupMemberImageSrc(profileImage: string | null) {
+ if (!profileImage?.startsWith("http")) {
+ return undefined;
+ }
+
+ return profileImage;
+}
+
+export function formatMemberRole(role: ProjectGroupMember["memberRole"]) {
+ const labels: Record = {
+ BACKEND: "BE",
+ DESIGN: "Design",
+ FRONTEND: "FE",
+ };
+
+ return labels[role];
+}
diff --git a/src/features/team/components/team-space-view.tsx b/src/features/team/components/team-space-view.tsx
index f6e424a..2fc064b 100644
--- a/src/features/team/components/team-space-view.tsx
+++ b/src/features/team/components/team-space-view.tsx
@@ -1,35 +1,14 @@
import {
ArrowRight,
- BookOpenText,
- Building2,
CheckCircle2,
- ExternalLink,
- GitBranch,
- Github,
GitPullRequest,
- Home,
LoaderCircle,
- MessageSquareText,
- PencilLine,
- Plus,
- RefreshCw,
Save,
SendHorizontal,
Settings2,
- ShieldCheck,
Sparkles,
- Trash2,
- X,
} from "lucide-react";
-import {
- type ComponentType,
- type FormEvent,
- type ReactNode,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import {
@@ -40,10 +19,6 @@ import {
} from "@/components/app-shell";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import {
- hasStoredProjectGroupFinishAgreement,
- storeProjectGroupFinishAgreement,
-} from "@/features/project-groups/lib/finish-agreement-storage";
import {
useFinishProjectGroupMutation,
useGrantProjectGroupAdminPermissionMutation,
@@ -51,141 +26,46 @@ import {
useRevokeProjectGroupAdminPermissionMutation,
} from "@/features/project-groups/hooks/use-project-group-queries";
import {
- useCreateProjectChecklistMutation,
- useDeleteProjectChecklistMutation,
- useGenerateChecklistAdviceMutation,
- useProjectChecklistsQuery,
- useUpdateProjectChecklistMutation,
-} from "@/features/team/hooks/use-project-checklist-queries";
+ hasStoredProjectGroupFinishAgreement,
+ storeProjectGroupFinishAgreement,
+} from "@/features/project-groups/lib/finish-agreement-storage";
+import { RealGithubInstallationPanel } from "@/features/team/components/real-github-installation-panel";
+import { RealGuidePanel } from "@/features/team/components/real-team-guide-panel";
+import { RealManagePanel } from "@/features/team/components/real-team-manage-panel";
+import { RealOverviewPanel } from "@/features/team/components/real-team-overview-panel";
+import { RealProjectChecklistsPanel } from "@/features/team/components/real-project-checklists-panel";
+import {
+ type ActionFeedback,
+ RealInlineStatus,
+ getProjectChecklistSummary,
+ projectChecklistStatusLabels,
+} from "@/features/team/components/real-team-shared";
+import {
+ TeamTabList,
+ type TeamTab,
+} from "@/features/team/components/team-tab-list";
+import { useProjectChecklistsQuery } from "@/features/team/hooks/use-project-checklist-queries";
import {
- useAvailableGithubRepositoriesQuery,
useCompleteGithubAppInstallationMutation,
- useCreateGithubAppInstallationUrlMutation,
- useDevGuideQuery,
useGithubInstallationStatusQuery,
- useGithubRepositoriesQuery,
- useGithubRepositoryContributionsQuery,
- useRegenerateDevGuideMutation,
- useSetGithubRepositoriesMutation,
- useSyncGithubPullRequestContributionsMutation,
} from "@/features/team/hooks/use-team-space-queries";
-import { demoTeamSpace } from "@/features/team/lib/demo-team-space";
+import { MockTeamSpaceView } from "@/features/team/components/mock-team-space-view";
import { getAuthSession } from "@/lib/api/auth-session";
import { getApiErrorMessage } from "@/lib/api/client";
import { apiConfig } from "@/lib/api/config";
-import type {
- ProjectChecklist,
- ProjectChecklistStatus,
-} from "@/lib/types/project-checklist";
+import type { ProjectChecklist } from "@/lib/types/project-checklist";
import type {
MyProjectGroup,
ProjectGroupMember,
} from "@/lib/types/project-group";
-import type {
- DevGuideContent,
- DevGuideGenerationStatus,
- DevGuideQueryResponse,
- GithubRepository,
- GithubRepositoryContributor,
-} from "@/lib/types/team-space";
-import type {
- GithubRepositorySummary,
- TeamChecklistItem,
- TeamMessage,
-} from "@/lib/types/team";
import { cn } from "@/lib/utils";
-type TeamTab =
- | "overview"
- | "guide"
- | "rules"
- | "checklist"
- | "github"
- | "chat"
- | "manage";
-type ActionFeedback = {
- message: string;
- tone: "error" | "success";
-};
-type AdminPermissionFeedback = ActionFeedback;
type ProjectGroupFinishState = {
agreedProjectGroupId: number | null;
feedback: ActionFeedback | null;
feedbackProjectGroupId: number | null;
};
-const tabs: Array<{
- icon: ComponentType<{ className?: string }>;
- id: TeamTab;
- label: string;
-}> = [
- { icon: Home, id: "overview", label: "홈" },
- { icon: Sparkles, id: "guide", label: "가이드" },
- { icon: BookOpenText, id: "rules", label: "규칙" },
- { icon: CheckCircle2, id: "checklist", label: "체크리스트" },
- { icon: GitPullRequest, id: "github", label: "GitHub" },
- { icon: MessageSquareText, id: "chat", label: "채팅" },
- { icon: Settings2, id: "manage", label: "관리" },
-];
-
-const checklistTone: Record = {
- doing: "border-primary/25 bg-primary/10 text-primary",
- done: "border-emerald-500/25 bg-emerald-50 text-emerald-700",
- todo: "border-border bg-secondary/45 text-muted-foreground",
-};
-
-const checklistLabels: Record = {
- doing: "진행 중",
- done: "완료",
- todo: "할 일",
-};
-
-const projectChecklistStatusLabels: Record = {
- DONE: "완료",
- TODO: "할 일",
-};
-
-const projectChecklistStatusTone: Record = {
- DONE: "border-emerald-500/25 bg-emerald-50 text-emerald-700",
- TODO: "border-border bg-secondary/45 text-muted-foreground",
-};
-
-const checklistControlClass =
- "h-11 w-full min-w-0 rounded-lg border border-input bg-white px-3 text-sm font-normal outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
-
-const githubOAuthPolicySteps = [
- "GitHub Organization",
- "Settings",
- "GitHub Apps",
- "TeamPo 설치 권한 확인",
-] as const;
-
-function areNumberSelectionsEqual(left: number[], right: number[]) {
- if (left.length !== right.length) {
- return false;
- }
-
- const normalizedLeft = [...left].sort(
- (leftValue, rightValue) => leftValue - rightValue,
- );
- const normalizedRight = [...right].sort(
- (leftValue, rightValue) => leftValue - rightValue,
- );
-
- return normalizedLeft.every(
- (value, index) => value === normalizedRight[index],
- );
-}
-
-const contributionLevelClass = [
- "bg-secondary",
- "bg-emerald-100",
- "bg-emerald-300",
- "bg-emerald-500",
- "bg-emerald-700",
-] as const;
-const contributionNumberFormatter = new Intl.NumberFormat("ko-KR");
-
export function TeamSpaceView() {
const [isSignedIn] = useState(() => Boolean(getAuthSession()));
@@ -196,69 +76,6 @@ export function TeamSpaceView() {
return ;
}
-function TeamTabList({
- getBadge,
- isDisabled,
- onSelectTab,
- selectedTab,
-}: {
- getBadge: (tabId: TeamTab) => string | null;
- isDisabled?: (tabId: TeamTab) => boolean;
- onSelectTab: (tabId: TeamTab) => void;
- selectedTab: TeamTab;
-}) {
- return (
-
-
- {tabs.map((tab) => {
- const Icon = tab.icon;
- const badge = getBadge(tab.id);
- const disabled = isDisabled?.(tab.id) ?? false;
- const isSelected = selectedTab === tab.id;
-
- return (
- onSelectTab(tab.id)}
- title={
- disabled ? `${tab.label} 기능은 준비 중이에요.` : undefined
- }
- type="button"
- >
-
- {tab.label}
- {badge ? (
-
- {badge}
-
- ) : null}
-
- );
- })}
-
-
- );
-}
-
function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedTab, setSelectedTab] = useState("overview");
@@ -273,7 +90,7 @@ function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) {
mutate: completeGithubAppInstallation,
} = useCompleteGithubAppInstallationMutation();
const [adminPermissionFeedback, setAdminPermissionFeedback] =
- useState(null);
+ useState(null);
const [finishState, setFinishState] = useState({
agreedProjectGroupId: null,
feedback: null,
@@ -940,634 +757,6 @@ function RealTeamFocusPanel({
);
}
-function RealOverviewPanel({
- checklistErrorMessage,
- checklists,
- projectGroup,
-}: {
- checklistErrorMessage: string | null;
- checklists: ProjectChecklist[];
- projectGroup: MyProjectGroup;
-}) {
- const summary = getProjectChecklistSummary(checklists);
-
- return (
-
-
-
-
-
-
- MVP
-
-
- {projectGroup.projectMvp ?? "MVP가 아직 등록되지 않았어요."}
-
-
-
- {projectGroup.members.map((member) => (
-
- ))}
-
-
-
-
-
-
-
- {checklistErrorMessage ? (
-
- ) : null}
- {checklistErrorMessage ? null : (
- <>
-
-
- 체크리스트 {summary.doneCount}/{summary.totalCount} 완료
-
-
-
- {checklists.slice(0, 3).map((checklist) => (
-
-
- {projectChecklistStatusLabels[checklist.status]}
-
-
-
- {checklist.title}
-
-
- 담당 {checklist.assigneeNickname ?? "미지정"}
-
-
-
- ))}
- {checklists.length === 0 ? (
-
- 아직 등록된 체크리스트가 없어요. 체크리스트 탭에서 첫 작업을
- 만들어 주세요.
-
- ) : null}
- >
- )}
-
-
-
- );
-}
-
-function RealMemberSummaryCard({
- currentUserId,
- member,
-}: {
- currentUserId: number;
- member: ProjectGroupMember;
-}) {
- const profileImageSrc = getProjectGroupMemberImageSrc(member.profileImage);
-
- return (
-
-
-
- {profileImageSrc ? (
-
- ) : (
- member.nickname.slice(0, 1).toUpperCase()
- )}
-
-
-
-
- {member.nickname}
-
-
- {formatMemberRole(member.memberRole)}
-
- {member.userId === currentUserId ? (
-
ME
- ) : null}
-
-
- Lv.{member.level} · 온도 {member.temperature}
-
-
-
-
-
- {member.groupRole === "HOST"
- ? "팀 운영과 관리 권한을 맡고 있어요."
- : "팀 작업과 체크리스트를 함께 진행해요."}
-
-
-
- {member.groupRole}
-
-
- {member.admin ? "ADMIN" : "MEMBER"}
-
-
-
-
- );
-}
-
-function RealGuidePanel({ projectGroup }: { projectGroup: MyProjectGroup }) {
- const devGuideQuery = useDevGuideQuery(projectGroup.projectGroupId);
- const regenerateDevGuideMutation = useRegenerateDevGuideMutation();
- const guideResponse = devGuideQuery.data;
- const guide = getDevGuideContent(guideResponse);
- const generationStatus = guideResponse?.generationStatus;
- const remainingRegenerationCount =
- guideResponse?.remainingRegenerationCount ?? null;
-
- const handleRegenerateDevGuide = () => {
- regenerateDevGuideMutation.mutate({
- projectGroupId: projectGroup.projectGroupId,
- });
- };
-
- if (devGuideQuery.isLoading) {
- return (
-
- 조회 중}
- description="팀 방향, MVP 우선순위, 결정 포인트를 불러오고 있어요."
- eyebrow="Guide"
- title="AI 개발 가이드"
- />
-
-
- }
- message="AI 개발 가이드를 불러오고 있어요."
- />
-
-
- );
- }
-
- if (devGuideQuery.error) {
- if (isDevGuideNotFoundError(devGuideQuery.error)) {
- return (
-
- 대기}
- description="아직 이 팀에 생성된 AI 개발 가이드가 없어요."
- eyebrow="Guide"
- title="AI 개발 가이드"
- />
-
-
-
-
- void devGuideQuery.refetch()}
- type="button"
- variant="outline"
- >
- {devGuideQuery.isFetching ? (
-
- ) : (
-
- )}
- 다시 확인
-
-
-
-
-
- );
- }
-
- return (
-
- 오류}
- description="AI 개발 가이드를 불러오지 못했어요."
- eyebrow="Guide"
- title="AI 개발 가이드"
- />
-
-
-
-
- );
- }
-
- if (!guide) {
- const isGenerating = generationStatus === "GENERATING";
- const isFailed = generationStatus === "FAILED";
-
- return (
-
- void devGuideQuery.refetch()}
- onRegenerate={handleRegenerateDevGuide}
- remainingRegenerationCount={remainingRegenerationCount}
- showRegenerate={isFailed}
- />
- }
- description={
- isFailed
- ? "AI 개발 가이드 생성에 실패했어요."
- : "아직 이 팀에 생성된 AI 개발 가이드가 없어요."
- }
- eyebrow="Guide"
- title="AI 개발 가이드"
- />
-
-
-
- ) : undefined
- }
- message={
- isFailed
- ? "재생성을 누르면 AI가 팀 정보를 바탕으로 가이드를 다시 만들어요."
- : "팀을 만든 직후라면 AI 개발 가이드 생성이 아직 진행 중일 수 있어요."
- }
- />
- {regenerateDevGuideMutation.error ? (
-
- ) : null}
-
-
-
- );
- }
-
- return (
-
-
- void devGuideQuery.refetch()}
- onRegenerate={handleRegenerateDevGuide}
- remainingRegenerationCount={remainingRegenerationCount}
- showRegenerate={generationStatus !== "GENERATING"}
- />
- }
- description={getDevGuidePanelDescription(generationStatus)}
- eyebrow="Guide"
- title="AI 개발 가이드"
- />
-
- {regenerateDevGuideMutation.error ? (
-
- ) : null}
-
-
프로젝트 개요
-
- {guide.overview}
-
-
-
- {guide.mvpPriorities.map((priority) => (
-
-
-
- {priority.feature}
-
- P{priority.priority}
-
-
- {priority.rationale}
-
-
- {priority.subFeatures.map((subFeature) => (
-
-
- {subFeature}
-
- ))}
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function getDevGuideContent(
- response: DevGuideQueryResponse | undefined,
-): DevGuideContent | null {
- if (
- !response ||
- typeof response.overview !== "string" ||
- !Array.isArray(response.techStack) ||
- !Array.isArray(response.mvpPriorities) ||
- !Array.isArray(response.decisionPoints) ||
- !Array.isArray(response.milestones)
- ) {
- return null;
- }
-
- return {
- decisionPoints: response.decisionPoints,
- milestones: response.milestones,
- mvpPriorities: response.mvpPriorities,
- overview: response.overview,
- techStack: response.techStack,
- };
-}
-
-function getDevGuidePanelDescription(
- generationStatus: DevGuideGenerationStatus | undefined,
-) {
- if (generationStatus === "GENERATING") {
- return "기존 AI 개발 가이드를 보여주는 동안 새 가이드를 만들고 있어요.";
- }
-
- if (generationStatus === "FAILED") {
- return "최근 생성이 실패해서 기존 AI 개발 가이드를 보여주고 있어요.";
- }
-
- return "팀 방향, MVP 우선순위, 결정 포인트를 한곳에 모았어요.";
-}
-
-function DevGuideHeaderAction({
- generationStatus,
- isFetching,
- isRegenerating,
- onRefresh,
- onRegenerate,
- remainingRegenerationCount,
- showRegenerate,
-}: {
- generationStatus: DevGuideGenerationStatus;
- isFetching: boolean;
- isRegenerating: boolean;
- onRefresh: () => void;
- onRegenerate: () => void;
- remainingRegenerationCount: number | null;
- showRegenerate: boolean;
-}) {
- const hasNoManualRegenerationCount =
- generationStatus === "COMPLETED" && remainingRegenerationCount === 0;
- const isGenerating = generationStatus === "GENERATING";
-
- return (
-
-
- {getDevGuideStatusLabel(generationStatus)}
-
- {remainingRegenerationCount !== null ? (
- 남은 {remainingRegenerationCount}
- ) : null}
- {showRegenerate ? (
-
- {isRegenerating ? (
-
- ) : (
-
- )}
- 재생성
-
- ) : (
-
- {isFetching || isGenerating ? (
-
- ) : (
-
- )}
- 다시 확인
-
- )}
-
- );
-}
-
-function getDevGuideStatusLabel(status: DevGuideGenerationStatus) {
- if (status === "GENERATING") {
- return "생성 중";
- }
-
- if (status === "FAILED") {
- return "실패";
- }
-
- return "생성됨";
-}
-
-function getDevGuideStatusBadgeVariant(status: DevGuideGenerationStatus) {
- if (status === "COMPLETED") {
- return "brand";
- }
-
- if (status === "FAILED") {
- return "warm";
- }
-
- return "neutral";
-}
-
-function isDevGuideNotFoundError(error: unknown) {
- if (typeof error !== "object" || error === null || !("code" in error)) {
- return false;
- }
-
- return error.code === "DEV_GUIDE_NOT_FOUND";
-}
-
-function RealDevGuideTechStackPanel({ guide }: { guide: DevGuideContent }) {
- return (
-
-
-
- {guide.techStack.map((item) => (
-
-
-
{item.category}
-
{item.recommendation}
-
-
- {item.reason}
-
-
- ))}
-
-
- );
-}
-
-function RealDevGuideDecisionPanel({ guide }: { guide: DevGuideContent }) {
- return (
-
-
-
- {guide.decisionPoints.map((decision) => (
-
-
{decision.topic}
-
- {decision.options.map((option) => (
-
- {option}
-
- ))}
-
-
- {decision.consideration}
-
-
- ))}
-
-
- );
-}
-
-function RealDevGuideMilestonePanel({ guide }: { guide: DevGuideContent }) {
- return (
-
-
-
- {guide.milestones.map((milestone) => (
-
-
-
- {milestone.week}주차
-
- W{milestone.week}
-
-
- {milestone.goal}
-
-
-
-
-
-
-
- ))}
-
-
- );
-}
-
-function RealDevGuideRoleTask({
- label,
- value,
-}: {
- label: string;
- value: string;
-}) {
- return (
-
-
{label}
- {value}
-
- );
-}
-
function RealRulesPanelDisabled() {
return (
@@ -1647,145 +836,6 @@ function RealChatPanelDisabled() {
);
}
-function RealManagePanel({
- canManageAdminPermissions,
- currentUserId,
- finishFeedback,
- feedback,
- hasCurrentUserAgreedFinish,
- isAdminPermissionPending,
- isFinishPending,
- onAdminPermissionChange,
- onFinishProjectGroup,
- pendingAdminPermissionTargetId,
- projectGroup,
-}: {
- canManageAdminPermissions: boolean;
- currentUserId: number;
- finishFeedback: ActionFeedback | null;
- feedback: AdminPermissionFeedback | null;
- hasCurrentUserAgreedFinish: boolean;
- isAdminPermissionPending: boolean;
- isFinishPending: boolean;
- onAdminPermissionChange: (member: ProjectGroupMember) => void;
- onFinishProjectGroup: () => void;
- pendingAdminPermissionTargetId: number | null;
- projectGroup: MyProjectGroup;
-}) {
- return (
-
-
- active}
- description="멤버 관리자 권한과 팀 종료 동의를 관리해요."
- eyebrow="Manage"
- title="팀 관리"
- />
-
-
-
- 팀 이름
-
-
-
- 팀 상태
-
- 운영 중
-
-
-
-
- 팀 이름 편집은 준비 중이에요. 팀 종료는 모든 팀원의 동의가 모이면
- 완료돼요.
-
-
-
-
-
-
- {hasCurrentUserAgreedFinish ? "agreed" : "pending"}
-
- }
- description="진행 중인 팀 스페이스를 종료하려면 팀원 전원의 동의가 필요해요."
- eyebrow="Finish"
- title="팀 종료 동의"
- />
-
-
-
-
- {projectGroup.projectName} 종료 동의
-
-
- 동의가 기록되면 팀 스페이스 종료 조건에 반영돼요.
-
-
-
- {isFinishPending ? (
-
- ) : (
-
- )}
- {isFinishPending
- ? "기록 중"
- : hasCurrentUserAgreedFinish
- ? "동의 완료"
- : "종료 동의"}
-
-
-
-
-
-
-
-
-
-
-
- {projectGroup.members.map((member) => (
-
- ))}
-
-
-
-
- );
-}
-
function getRealTeamTabBadge(
tabId: TeamTab,
checklists: ProjectChecklist[],
@@ -1827,3087 +877,3 @@ function getRealTeamTabBadge(
function isRealTeamTabDisabled(tabId: TeamTab) {
return tabId === "rules" || tabId === "chat";
}
-
-function getProjectChecklistSummary(checklists: ProjectChecklist[]) {
- const totalCount = checklists.length;
- const doneCount = checklists.filter(
- (checklist) => checklist.status === "DONE",
- ).length;
- const openCount = totalCount - doneCount;
- const progress = totalCount ? Math.round((doneCount / totalCount) * 100) : 0;
-
- return { doneCount, openCount, progress, totalCount };
-}
-
-function RealAdminPermissionStatus({
- canManageAdminPermissions,
- feedback,
-}: {
- canManageAdminPermissions: boolean;
- feedback: AdminPermissionFeedback | null;
-}) {
- return (
-
-
-
-
-
-
-
관리자 권한 관리
-
- {canManageAdminPermissions
- ? "팀원 카드에서 권한을 조정할 수 있어요."
- : "방장 계정에서만 권한을 변경할 수 있어요."}
-
-
-
-
-
- {canManageAdminPermissions ? "HOST" : "READ ONLY"}
-
- {feedback ? (
-
- {feedback.message}
-
- ) : null}
-
-
- );
-}
-
-function RealProjectGroupMemberCard({
- canManageAdminPermissions,
- currentUserId,
- isAdminPermissionPending,
- member,
- onAdminPermissionChange,
- pendingAdminPermissionTargetId,
-}: {
- canManageAdminPermissions: boolean;
- currentUserId: number;
- isAdminPermissionPending: boolean;
- member: ProjectGroupMember;
- onAdminPermissionChange: (member: ProjectGroupMember) => void;
- pendingAdminPermissionTargetId: number | null;
-}) {
- const profileImageSrc = getProjectGroupMemberImageSrc(member.profileImage);
- const canChangeThisMember =
- canManageAdminPermissions && member.groupRole === "MEMBER";
- const isThisMemberPending = pendingAdminPermissionTargetId === member.userId;
-
- return (
-
-
-
- {profileImageSrc ? (
-
- ) : (
- member.nickname.slice(0, 2).toUpperCase()
- )}
-
-
-
-
- {member.nickname}
-
- {member.userId === currentUserId ? (
-
ME
- ) : null}
-
-
- {formatMemberRole(member.memberRole)} · Lv.{member.level} · 온도{" "}
- {member.temperature}
-
-
-
-
-
-
- {member.groupRole}
-
-
- {member.admin ? "ADMIN" : "MEMBER"}
-
-
- {canChangeThisMember ? (
-
onAdminPermissionChange(member)}
- size="sm"
- variant={member.admin ? "outline" : "default"}
- >
- {isThisMemberPending ? (
-
- ) : (
-
- )}
- {isThisMemberPending
- ? "처리 중"
- : member.admin
- ? "권한 회수"
- : "관리자 부여"}
-
- ) : null}
- {canManageAdminPermissions && member.groupRole === "HOST" ? (
-
- 방장 권한 고정
-
- ) : null}
-
-
- );
-}
-
-function RealProjectChecklistsPanel({
- projectGroup,
-}: {
- projectGroup: MyProjectGroup;
-}) {
- const checklistQuery = useProjectChecklistsQuery(projectGroup.projectGroupId);
- const createChecklistMutation = useCreateProjectChecklistMutation();
- const updateChecklistMutation = useUpdateProjectChecklistMutation();
- const deleteChecklistMutation = useDeleteProjectChecklistMutation();
- const generateAdviceMutation = useGenerateChecklistAdviceMutation();
- const [feedback, setFeedback] = useState(null);
- const [draft, setDraft] = useState({
- assigneeUserId: "",
- description: "",
- dueDate: "",
- title: "",
- });
- const [editingChecklistId, setEditingChecklistId] = useState(
- null,
- );
- const checklists = checklistQuery.data ?? [];
- const pendingChecklistId = updateChecklistMutation.isPending
- ? (updateChecklistMutation.variables?.checklistId ?? null)
- : deleteChecklistMutation.isPending
- ? (deleteChecklistMutation.variables?.checklistId ?? null)
- : generateAdviceMutation.isPending
- ? (generateAdviceMutation.variables?.checklistId ?? null)
- : null;
- const isChecklistActionPending =
- updateChecklistMutation.isPending ||
- deleteChecklistMutation.isPending ||
- generateAdviceMutation.isPending;
-
- async function handleCreateChecklist(event: FormEvent) {
- event.preventDefault();
-
- const title = draft.title.trim();
-
- if (!title) {
- setFeedback({
- message: "체크리스트 제목을 입력해 주세요.",
- tone: "error",
- });
- return;
- }
-
- setFeedback(null);
- try {
- await createChecklistMutation.mutateAsync({
- assigneeUserId: draft.assigneeUserId
- ? Number(draft.assigneeUserId)
- : null,
- description: draft.description.trim() || null,
- dueDate: draft.dueDate || null,
- projectGroupId: projectGroup.projectGroupId,
- title,
- });
- setDraft({
- assigneeUserId: "",
- description: "",
- dueDate: "",
- title: "",
- });
- setFeedback({
- message: "체크리스트를 추가했어요.",
- tone: "success",
- });
- } catch (error: unknown) {
- setFeedback({
- message: getApiErrorMessage(error),
- tone: "error",
- });
- }
- }
-
- function handleStartEdit(checklist: ProjectChecklist) {
- setFeedback(null);
- setEditingChecklistId(checklist.id);
- }
-
- function handleCancelEdit(checklistId?: number) {
- setEditingChecklistId((currentChecklistId) => {
- if (checklistId === undefined || currentChecklistId === checklistId) {
- return null;
- }
-
- return currentChecklistId;
- });
- }
-
- async function handleUpdateChecklist(
- event: FormEvent,
- checklist: ProjectChecklist,
- ) {
- event.preventDefault();
-
- const formData = new FormData(event.currentTarget);
- const title = getChecklistFormValue(formData, "title").trim();
- const description = getChecklistFormValue(formData, "description").trim();
- const dueDate = getChecklistFormValue(formData, "dueDate");
- const assigneeUserId = getChecklistFormValue(formData, "assigneeUserId");
- const status = getChecklistFormValue(formData, "status");
-
- if (!title) {
- setFeedback({
- message: "체크리스트 제목을 입력해 주세요.",
- tone: "error",
- });
- return;
- }
-
- if (!isProjectChecklistStatus(status)) {
- setFeedback({
- message: "체크리스트 상태를 다시 선택해 주세요.",
- tone: "error",
- });
- return;
- }
-
- setFeedback(null);
- try {
- await updateChecklistMutation.mutateAsync({
- assigneeUserId: assigneeUserId ? Number(assigneeUserId) : null,
- checklistId: checklist.id,
- description: description || null,
- dueDate: dueDate || null,
- projectGroupId: projectGroup.projectGroupId,
- status,
- title,
- });
- handleCancelEdit(checklist.id);
- setFeedback({
- message: "체크리스트를 수정했어요.",
- tone: "success",
- });
- } catch (error: unknown) {
- setFeedback({
- message: getApiErrorMessage(error),
- tone: "error",
- });
- }
- }
-
- async function handleStatusChange(
- checklist: ProjectChecklist,
- status: ProjectChecklistStatus,
- ) {
- setFeedback(null);
- try {
- await updateChecklistMutation.mutateAsync({
- assigneeUserId: checklist.assigneeUserId,
- checklistId: checklist.id,
- description: checklist.description,
- dueDate: checklist.dueDate,
- projectGroupId: projectGroup.projectGroupId,
- status,
- title: checklist.title,
- });
- setFeedback({
- message: "체크리스트 상태를 바꿨어요.",
- tone: "success",
- });
- } catch (error: unknown) {
- setFeedback({
- message: getApiErrorMessage(error),
- tone: "error",
- });
- }
- }
-
- async function handleDeleteChecklist(checklist: ProjectChecklist) {
- setFeedback(null);
- try {
- await deleteChecklistMutation.mutateAsync({
- checklistId: checklist.id,
- projectGroupId: projectGroup.projectGroupId,
- });
- setFeedback({
- message: "체크리스트를 삭제했어요.",
- tone: "success",
- });
- } catch (error: unknown) {
- setFeedback({
- message: getApiErrorMessage(error),
- tone: "error",
- });
- }
- }
-
- async function handleGenerateAdvice(checklist: ProjectChecklist) {
- setFeedback(null);
- try {
- await generateAdviceMutation.mutateAsync({
- checklistId: checklist.id,
- projectGroupId: projectGroup.projectGroupId,
- });
- setFeedback({
- message: "AI 조언을 만들었어요.",
- tone: "success",
- });
- } catch (error: unknown) {
- setFeedback({
- message: getApiErrorMessage(error),
- tone: "error",
- });
- }
- }
-
- return (
-
- {checklists.length} tasks}
- description="팀 작업, 담당자, 마감일, AI 조언을 함께 관리해요."
- eyebrow="Checklist"
- title="프로젝트 체크리스트"
- />
-
-
-
-
-
- {checklistQuery.isLoading ? (
-
}
- message="체크리스트를 불러오고 있어요."
- />
- ) : null}
-
- {checklistQuery.error ? (
-
- ) : null}
-
-
- {checklists.map((checklist) => {
- const isPending = pendingChecklistId === checklist.id;
- const isEditing = editingChecklistId === checklist.id;
-
- return (
-
- {isEditing ? (
-
- ) : (
- <>
-
-
-
- {projectChecklistStatusLabels[checklist.status]}
-
-
- {checklist.title}
-
-
-
- {checklist.description ?? "설명이 없어요."}
-
-
-
- 담당 {checklist.assigneeNickname ?? "미지정"}
-
- 마감 {checklist.dueDate ?? "미정"}
- 생성 {checklist.createdByNickname}
-
-
-
-
handleStartEdit(checklist)}
- size="sm"
- type="button"
- variant="outline"
- >
-
- 수정
-
-
- handleStatusChange(
- checklist,
- checklist.status === "DONE" ? "TODO" : "DONE",
- )
- }
- size="sm"
- type="button"
- variant="outline"
- >
- {isPending &&
- updateChecklistMutation.variables?.checklistId ===
- checklist.id ? (
-
- ) : (
-
- )}
- {checklist.status === "DONE" ? "다시 열기" : "완료"}
-
-
handleGenerateAdvice(checklist)}
- size="sm"
- type="button"
- variant="outline"
- >
- {isPending &&
- generateAdviceMutation.variables?.checklistId ===
- checklist.id ? (
-
- ) : (
-
- )}
- AI 조언
-
-
handleDeleteChecklist(checklist)}
- size="icon"
- title="체크리스트 삭제"
- type="button"
- variant="ghost"
- >
- {isPending &&
- deleteChecklistMutation.variables?.checklistId ===
- checklist.id ? (
-
- ) : (
-
- )}
-
-
- {checklist.aiAdvice ? (
-
- ) : null}
- >
- )}
-
- );
- })}
-
-
-
- );
-}
-
-function RealChecklistAdvice({
- checklist,
- className,
-}: {
- checklist: ProjectChecklist;
- className?: string;
-}) {
- if (!checklist.aiAdvice) {
- return null;
- }
-
- return (
-
-
- {checklist.aiAdvice.summary}
-
-
-
-
-
-
-
- );
-}
-
-function RealAdviceList({ items, title }: { items: string[]; title: string }) {
- return (
-
-
- {title}
-
-
- {items.map((item) => (
- {item}
- ))}
-
-
- );
-}
-
-function getChecklistFormValue(formData: FormData, key: string) {
- const value = formData.get(key);
- return typeof value === "string" ? value : "";
-}
-
-function isProjectChecklistStatus(
- value: string,
-): value is ProjectChecklistStatus {
- return value === "TODO" || value === "DONE";
-}
-
-function RealGithubInstallationPanel({
- canManageGithubInstallation,
- completionFeedback,
- isCompletingInstallation,
- projectGroup,
-}: {
- canManageGithubInstallation: boolean;
- completionFeedback: ActionFeedback | null;
- isCompletingInstallation: boolean;
- projectGroup: MyProjectGroup;
-}) {
- const githubStatusQuery = useGithubInstallationStatusQuery(
- projectGroup.projectGroupId,
- );
- const createInstallUrlMutation = useCreateGithubAppInstallationUrlMutation();
- const setGithubRepositoriesMutation = useSetGithubRepositoriesMutation();
- const [feedback, setFeedback] = useState(null);
- const githubStatus = githubStatusQuery.data;
- const isGithubConnected = githubStatus?.connected === true;
- const githubRepositoriesQuery = useGithubRepositoriesQuery(
- projectGroup.projectGroupId,
- isGithubConnected,
- );
- const availableGithubRepositoriesQuery = useAvailableGithubRepositoriesQuery(
- projectGroup.projectGroupId,
- isGithubConnected && canManageGithubInstallation,
- );
- const connectedRepositories =
- githubRepositoriesQuery.data?.repositories ?? [];
- const availableRepositories =
- availableGithubRepositoriesQuery.data?.repositories ?? [];
- const connectedRepositoryIds = useMemo(
- () =>
- connectedRepositories.map((repository) => repository.githubRepositoryId),
- [connectedRepositories],
- );
- const availableGithubRepositoryIdSet = useMemo(
- () =>
- new Set(
- availableRepositories.map(
- (repository) => repository.githubRepositoryId,
- ),
- ),
- [availableRepositories],
- );
- const configurableRepositories = useMemo(() => {
- const repositoriesById = new Map();
-
- for (const repository of availableRepositories) {
- repositoriesById.set(repository.githubRepositoryId, repository);
- }
-
- for (const repository of connectedRepositories) {
- if (!repositoriesById.has(repository.githubRepositoryId)) {
- repositoriesById.set(repository.githubRepositoryId, repository);
- }
- }
-
- return [...repositoriesById.values()];
- }, [availableRepositories, connectedRepositories]);
- const [selectedGithubRepositoryIds, setSelectedGithubRepositoryIds] =
- useState([]);
- const selectedGithubRepositoryIdSet = useMemo(
- () => new Set(selectedGithubRepositoryIds),
- [selectedGithubRepositoryIds],
- );
- const hasRepositorySelectionChanged = !areNumberSelectionsEqual(
- selectedGithubRepositoryIds,
- connectedRepositoryIds,
- );
- const hasGithubRepositorySelection = (githubStatus?.repositoryCount ?? 0) > 0;
- const canCreateInstallUrl =
- githubStatusQuery.isSuccess &&
- canManageGithubInstallation &&
- !githubStatus?.connected &&
- !createInstallUrlMutation.isPending;
- const canChangeGithubRepositories =
- canManageGithubInstallation &&
- githubRepositoriesQuery.isSuccess &&
- availableGithubRepositoriesQuery.isSuccess &&
- !setGithubRepositoriesMutation.isPending;
- const canSaveGithubRepositories =
- isGithubConnected &&
- canChangeGithubRepositories &&
- hasRepositorySelectionChanged;
- const showGithubPolicyNotice =
- githubStatusQuery.isSuccess &&
- canManageGithubInstallation &&
- !hasGithubRepositorySelection;
-
- useEffect(() => {
- if (!githubRepositoriesQuery.data) {
- return;
- }
-
- setSelectedGithubRepositoryIds(connectedRepositoryIds);
- }, [connectedRepositoryIds, githubRepositoriesQuery.data]);
-
- function handleCreateInstallUrl() {
- setFeedback(null);
- createInstallUrlMutation.mutate(projectGroup.projectGroupId, {
- onError: (error: unknown) => {
- setFeedback({
- message: getApiErrorMessage(error),
- tone: "error",
- });
- },
- onSuccess: ({ installUrl }) => {
- window.location.assign(installUrl);
- },
- });
- }
-
- function handleGithubRepositoryToggle(githubRepositoryId: number) {
- setFeedback(null);
- setSelectedGithubRepositoryIds((current) =>
- current.includes(githubRepositoryId)
- ? current.filter((repositoryId) => repositoryId !== githubRepositoryId)
- : [...current, githubRepositoryId],
- );
- }
-
- function handleSaveGithubRepositories() {
- setFeedback(null);
- setGithubRepositoriesMutation.mutate(
- {
- githubRepositoryIds: selectedGithubRepositoryIds,
- projectGroupId: projectGroup.projectGroupId,
- },
- {
- onError: (error: unknown) => {
- setFeedback({
- message: getApiErrorMessage(error),
- tone: "error",
- });
- },
- onSuccess: () => {
- setFeedback({
- message: "GitHub 저장소 연결을 저장했어요.",
- tone: "success",
- });
- },
- },
- );
- }
-
- return (
-
-
- {githubStatus?.connected ? "connected" : "not connected"}
-
- }
- description="GitHub 조직과 저장소 연결 상태를 확인해요."
- eyebrow="GitHub App"
- title="GitHub 조직 연결"
- />
-
-
-
- {githubStatusQuery.isLoading || isCompletingInstallation ? (
-
}
- message={
- isCompletingInstallation
- ? "GitHub App 설치를 마무리하고 있어요."
- : "GitHub 연결 상태를 불러오고 있어요."
- }
- />
- ) : null}
-
- {githubStatusQuery.error ? (
-
- ) : null}
-
-
-
-
-
-
-
- {showGithubPolicyNotice ?
: null}
-
-
-
-
- TeamPo GitHub App
-
-
- {githubStatus?.connected
- ? "GitHub 조직이 팀 스페이스에 연결되어 있어요."
- : "호스트가 GitHub App 설치를 시작할 수 있어요."}
-
-
-
- {createInstallUrlMutation.isPending ? (
-
- ) : (
-
- )}
- 설치 URL 발급
-
-
-
- {isGithubConnected ? (
-
-
-
-
-
- 연결된 저장소
-
-
- 팀 스페이스에 등록된 GitHub 저장소예요.
-
-
-
0 ? "brand" : "neutral"
- }
- >
- {connectedRepositories.length}개
-
-
-
-
- {githubRepositoriesQuery.isLoading ? (
-
}
- message="등록된 저장소를 불러오고 있어요."
- />
- ) : null}
-
- {githubRepositoriesQuery.error ? (
-
- ) : null}
-
- {githubRepositoriesQuery.isSuccess &&
- connectedRepositories.length === 0 ? (
-
-
- 아직 팀 스페이스에 등록된 GitHub 저장소가 없어요.
-
-
- ) : null}
-
- {connectedRepositories.map((repository) => (
-
- ))}
-
-
-
-
-
-
-
- 저장소 설정
-
-
- GitHub App이 접근할 수 있는 저장소 중 집계할 대상을
- 선택해요.
-
-
-
- {canManageGithubInstallation ? "editable" : "read only"}
-
-
-
- {canManageGithubInstallation ? (
-
- {availableGithubRepositoriesQuery.isLoading ? (
-
}
- message="선택 가능한 저장소를 불러오고 있어요."
- />
- ) : null}
-
- {availableGithubRepositoriesQuery.error ? (
-
- ) : null}
-
- {availableGithubRepositoriesQuery.isSuccess &&
- availableRepositories.length === 0 ? (
-
-
- GitHub App이 접근할 수 있는 저장소가 없어요.
-
-
- ) : null}
-
- {configurableRepositories.map((repository) => {
- const selected = selectedGithubRepositoryIdSet.has(
- repository.githubRepositoryId,
- );
- const available = availableGithubRepositoryIdSet.has(
- repository.githubRepositoryId,
- );
-
- return (
-
- );
- })}
-
-
-
- {selectedGithubRepositoryIds.length}개 저장소 선택됨
-
-
- {setGithubRepositoriesMutation.isPending ? (
-
- ) : (
-
- )}
- 저장소 설정 저장
-
-
-
- ) : (
-
-
- 호스트만 GitHub 저장소 설정을 변경할 수 있어요.
-
-
- )}
-
-
- ) : null}
-
-
- );
-}
-
-function GithubOrganizationPolicyNotice() {
- return (
-
-
-
-
-
-
-
-
-
- 저장소 연결 전 TeamPo 접근 권한을 확인해 주세요
-
-
permission check
-
-
- GitHub App 설치나 선택 저장소 권한이 제한되어 있으면 TeamPo가
- 저장소와 PR 정보를 가져오지 못할 수 있어요. Organization owner가
- TeamPo GitHub App이 설치되어 있는지, 선택한 저장소와 Pull requests
- 읽기 권한이 열려 있는지 확인해 주세요.
-
-
-
-
-
- {githubOAuthPolicySteps.map((step, index) => (
-
-
- {index + 1}
-
-
- {step}
-
-
- ))}
-
-
-
- );
-}
-
-function RealGithubStatusCard({
- label,
- ready,
- value,
-}: {
- label: string;
- ready: boolean;
- value: string;
-}) {
- return (
-
-
-
- {label}
-
-
- {ready ? "ready" : "pending"}
-
-
-
- {value}
-
-
- );
-}
-
-function RealGithubRepositoryContributionCard({
- canManageGithubInstallation,
- projectGroupId,
- repository,
-}: {
- canManageGithubInstallation: boolean;
- projectGroupId: number;
- repository: GithubRepository;
-}) {
- const contributionsQuery = useGithubRepositoryContributionsQuery(
- projectGroupId,
- repository.githubRepositoryId,
- );
- const syncContributionsMutation =
- useSyncGithubPullRequestContributionsMutation();
- const contributors = contributionsQuery.data?.contributors ?? [];
- const sortedContributors = useMemo(
- () =>
- [...contributors].sort(
- (left, right) => right.contributionScore - left.contributionScore,
- ),
- [contributors],
- );
- const totals = useMemo(
- () => calculateGithubContributionTotals(contributors),
- [contributors],
- );
- const isSyncingCurrentRepository =
- syncContributionsMutation.isPending &&
- syncContributionsMutation.variables?.githubRepositoryId ===
- repository.githubRepositoryId;
-
- function handleSyncContributions() {
- syncContributionsMutation.mutate({
- githubRepositoryId: repository.githubRepositoryId,
- projectGroupId,
- });
- }
-
- return (
-
-
-
- {contributionsQuery.isLoading ? (
-
}
- message="저장소 기여도를 불러오고 있어요."
- />
- ) : null}
-
- {contributionsQuery.error ? (
-
- ) : null}
-
- {syncContributionsMutation.error &&
- syncContributionsMutation.variables?.githubRepositoryId ===
- repository.githubRepositoryId ? (
-
- ) : null}
-
- {contributionsQuery.isSuccess ? (
-
-
-
-
-
-
-
-
- {sortedContributors.length > 0 ? (
-
- {sortedContributors.map((contributor) => (
-
- ))}
-
- ) : (
-
-
- 아직 동기화된 PR 기여도가 없어요.
-
-
- )}
-
- ) : null}
-
- );
-}
-
-function calculateGithubContributionTotals(
- contributors: GithubRepositoryContributor[],
-) {
- return contributors.reduce(
- (totals, contributor) => ({
- changedFiles: totals.changedFiles + contributor.changedFiles,
- contributionScore:
- totals.contributionScore + contributor.contributionScore,
- linkedIssueCount: totals.linkedIssueCount + contributor.linkedIssueCount,
- mergedPrCount: totals.mergedPrCount + contributor.mergedPrCount,
- }),
- {
- changedFiles: 0,
- contributionScore: 0,
- linkedIssueCount: 0,
- mergedPrCount: 0,
- },
- );
-}
-
-function RealGithubContributionStat({
- label,
- value,
-}: {
- label: string;
- value: number;
-}) {
- return (
-
-
- {label}
-
-
- {contributionNumberFormatter.format(value)}
-
-
- );
-}
-
-function RealGithubContributorRow({
- contributor,
-}: {
- contributor: GithubRepositoryContributor;
-}) {
- return (
-
-
-
- @{contributor.githubUsername}
-
-
- PR {contributionNumberFormatter.format(contributor.mergedPrCount)} ·
- 이슈{" "}
- {contributionNumberFormatter.format(contributor.linkedIssueCount)}
-
-
-
-
- +{contributionNumberFormatter.format(contributor.additions)}
-
-
- -{contributionNumberFormatter.format(contributor.deletions)}
-
-
- {contributionNumberFormatter.format(contributor.changedFiles)} files
-
-
- {contributionNumberFormatter.format(contributor.contributionScore)}
-
-
-
- );
-}
-
-function RealGithubRepositoryOption({
- disabled,
- onToggle,
- repository,
- selected,
- unavailable,
-}: {
- disabled: boolean;
- onToggle: (githubRepositoryId: number) => void;
- repository: GithubRepository;
- selected: boolean;
- unavailable: boolean;
-}) {
- return (
-
- onToggle(repository.githubRepositoryId)}
- type="checkbox"
- />
-
-
- {repository.fullName}
-
-
- {unavailable ? "GitHub App 접근 권한 없음" : repository.repoName}
-
-
-
- );
-}
-
-function RealActionFeedback({ feedback }: { feedback: ActionFeedback | null }) {
- if (!feedback) {
- return null;
- }
-
- return (
-
- {feedback.message}
-
- );
-}
-
-function RealInlineStatus({
- className,
- icon,
- message,
-}: {
- className?: string;
- icon?: ReactNode;
- message: string;
-}) {
- return (
-
- {icon}
- {message}
-
- );
-}
-
-function getProjectGroupMemberImageSrc(profileImage: string | null) {
- if (!profileImage?.startsWith("http")) {
- return undefined;
- }
-
- return profileImage;
-}
-
-function formatMemberRole(role: ProjectGroupMember["memberRole"]) {
- const labels: Record = {
- BACKEND: "BE",
- DESIGN: "Design",
- FRONTEND: "FE",
- };
-
- return labels[role];
-}
-
-function MockTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) {
- const [selectedTab, setSelectedTab] = useState("overview");
- const [teamName, setTeamName] = useState(demoTeamSpace.name);
- const [rulesMarkdown, setRulesMarkdown] = useState(
- demoTeamSpace.rulesMarkdown,
- );
- const [checklist, setChecklist] = useState(demoTeamSpace.checklist);
- const [messages, setMessages] = useState(demoTeamSpace.messages);
- const [isGithubLinked, setIsGithubLinked] = useState(
- demoTeamSpace.githubSummary.projectGroupGithubLinked,
- );
- const metrics = getTeamMetrics(checklist);
-
- function handleChecklistStatusChange(
- itemId: string,
- status: TeamChecklistItem["status"],
- ) {
- setChecklist((current) =>
- current.map((item) => (item.id === itemId ? { ...item, status } : item)),
- );
- }
-
- function handleChecklistAdd(item: Omit) {
- setChecklist((current) => [
- {
- ...item,
- id: `task-${Date.now()}`,
- status: "todo",
- },
- ...current,
- ]);
- }
-
- function handleChecklistDelete(itemId: string) {
- setChecklist((current) => current.filter((item) => item.id !== itemId));
- }
-
- function handleSendMessage(message: string) {
- const trimmedMessage = message.trim();
-
- if (!trimmedMessage) {
- return;
- }
-
- setMessages((current) => [
- ...current,
- {
- author: "나",
- id: `message-${Date.now()}`,
- message: trimmedMessage,
- timeLabel: "방금",
- },
- ]);
- }
-
- return (
-
- }
- title={teamName}
- >
-
-
- {metrics.map((metric) => (
-
- ))}
-
-
- {!isSignedIn ? (
-
- 지금은 샘플 팀 스페이스를 둘러보고 있어요. 로그인하면 내 팀 기준으로
- 규칙, 체크리스트, 채팅을 이어서 관리할 수 있어요.
-
- ) : null}
-
-
-
-
- getTeamTabBadge(tabId, checklist, messages, isGithubLinked)
- }
- onSelectTab={setSelectedTab}
- selectedTab={selectedTab}
- />
-
-
- {selectedTab === "overview" ? (
-
- ) : null}
- {selectedTab === "guide" ? : null}
- {selectedTab === "rules" ? (
-
- ) : null}
- {selectedTab === "checklist" ? (
-
- ) : null}
-
-
-
- {selectedTab === "chat" ? (
-
- ) : null}
- {selectedTab === "manage" ? (
-
- ) : null}
-
-
-
- );
-}
-
-function TeamFocusPanel({
- checklist,
- onSelectTab,
-}: {
- checklist: TeamChecklistItem[];
- onSelectTab: (tab: TeamTab) => void;
-}) {
- const openTasks = checklist.filter((item) => item.status !== "done");
- const doneTasks = checklist.length - openTasks.length;
- const primaryTask =
- checklist.find((item) => item.status === "doing") ??
- checklist.find((item) => item.status === "todo") ??
- checklist[0];
-
- return (
-
-
-
-
- 오늘의 핵심
- 팀 운영
-
-
- {primaryTask
- ? primaryTask.title
- : "새 작업을 추가해 다음 할 일을 정해요"}
-
-
- {primaryTask
- ? `${primaryTask.assignee} 담당 · ${primaryTask.dueLabel} · ${checklistLabels[primaryTask.status]}`
- : "체크리스트에서 첫 작업을 만들면 팀 홈 상단에 바로 보여요."}
-
-
-
-
-
-
- 남은 작업
-
-
- {openTasks.length}
-
-
-
-
- 완료 작업
-
-
- {doneTasks}
-
-
-
-
- 다음 회의
-
-
- {demoTeamSpace.nextMeetingLabel}
-
-
-
-
-
-
onSelectTab("checklist")} type="button">
-
- 체크리스트 열기
-
-
onSelectTab("github")}
- type="button"
- variant="outline"
- >
-
- GitHub 연동
-
-
onSelectTab("chat")}
- type="button"
- variant="outline"
- >
-
- 채팅 열기
-
-
onSelectTab("manage")}
- type="button"
- variant="outline"
- >
-
- 관리
-
-
-
-
-
- );
-}
-
-function getTeamTabBadge(
- tabId: TeamTab,
- checklist: TeamChecklistItem[],
- messages: TeamMessage[],
- isGithubLinked: boolean,
-) {
- if (tabId === "checklist") {
- const openTaskCount = checklist.filter(
- (item) => item.status !== "done",
- ).length;
- return openTaskCount > 0 ? String(openTaskCount) : "완료";
- }
-
- if (tabId === "github") {
- return isGithubLinked ? null : "설정";
- }
-
- if (tabId === "chat") {
- return String(messages.length);
- }
-
- if (tabId === "manage") {
- return "팀";
- }
-
- return null;
-}
-
-function TeamRail({
- checklist,
- isGithubLinked,
- onSelectTab,
- teamName,
-}: {
- checklist: TeamChecklistItem[];
- isGithubLinked: boolean;
- onSelectTab: (tabId: TeamTab) => void;
- teamName: string;
-}) {
- const openTasks = checklist.filter((item) => item.status !== "done").length;
- const doneTasks = checklist.length - openTasks;
-
- return (
-
-
-
-
-
-
-
남은 작업
-
- {openTasks}
-
-
-
-
완료 작업
-
- {doneTasks}
-
-
-
-
- GitHub 연동은{" "}
-
- {isGithubLinked ? "완료" : "설정 필요"}
-
- 상태예요. 팀 설정과 멤버 관리는 관리 탭에서 확인해요.
-
-
- onSelectTab("checklist")} type="button">
-
- 체크리스트
-
- onSelectTab("manage")}
- type="button"
- variant="outline"
- >
-
- 관리
-
-
-
-
-
- );
-}
-
-function MockManagePanel({
- onTeamNameChange,
- teamName,
-}: {
- onTeamNameChange: (name: string) => void;
- teamName: string;
-}) {
- return (
-
-
- local preview}
- description="팀 설정 화면을 미리 확인해요. 아직 연결되지 않은 기능은 준비 중이에요."
- eyebrow="Manage"
- title="팀 관리"
- />
-
-
-
- 팀 이름
- onTeamNameChange(event.target.value)}
- value={teamName}
- />
-
-
- 팀 상태
-
- 운영 중
-
-
-
-
- 팀 운영 상태 변경은 서버 API가 연결되면 사용할 수 있어요.
-
-
-
-
-
-
-
- {demoTeamSpace.members.map((member) => (
-
-
-
-
{member.name}
-
{member.role}
-
-
- Lv.{member.level} · 온도 {member.temperature.toFixed(1)}℃
-
-
-
-
- 권한 설정 준비 중
-
-
- ))}
-
-
-
- );
-}
-
-interface OverviewPanelProps {
- checklist: TeamChecklistItem[];
-}
-
-function OverviewPanel({ checklist }: OverviewPanelProps) {
- const totalTasks = checklist.length;
- const doneTasks = checklist.filter((item) => item.status === "done").length;
-
- return (
-
-
-
-
-
-
- MVP
-
-
- {demoTeamSpace.projectMvp}
-
-
-
- {demoTeamSpace.members.map((member) => (
-
-
-
- {member.name.slice(0, 1)}
-
-
-
-
- {member.name}
-
-
{member.role}
-
-
- Lv.{member.level} · 온도 {member.temperature.toFixed(1)}℃
-
-
-
-
-
- {member.responsibility}
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
- 체크리스트 {doneTasks}/{totalTasks} 완료
-
-
-
- {checklist.slice(0, 3).map((item) => (
-
-
- {item.dueLabel}
-
-
-
{item.title}
-
- 담당 {item.assignee}
-
-
-
- ))}
-
-
-
- );
-}
-
-function GuidePanel() {
- return (
-
-
-
- {demoTeamSpace.guideline.sections.map((section) => (
-
-
{section.title}
-
- {section.body}
-
-
- ))}
-
-
- );
-}
-
-interface RulesPanelProps {
- onRulesChange: (rulesMarkdown: string) => void;
- rulesMarkdown: string;
-}
-
-function RulesPanel({ onRulesChange, rulesMarkdown }: RulesPanelProps) {
- const [isEditing, setIsEditing] = useState(false);
- const [draftRules, setDraftRules] = useState(rulesMarkdown);
- const renderedRules = useMemo(
- () => parseRulesMarkdown(rulesMarkdown),
- [rulesMarkdown],
- );
-
- function handleStartEditing() {
- setDraftRules(rulesMarkdown);
- setIsEditing(true);
- }
-
- function handleSave() {
- onRulesChange(draftRules);
- setIsEditing(false);
- }
-
- return (
-
-
-
-
- 저장
-
- setIsEditing(false)}
- type="button"
- variant="outline"
- >
- 취소
-
-
- ) : (
-
-
- 규칙 수정
-
- )
- }
- description="함께 지킬 협업 규칙을 정리하고 필요할 때 수정해요."
- eyebrow="Rulebook"
- title="팀 규칙"
- />
-
- {isEditing ? (
-
- ) : (
-
-
-
-
- Rulebook
-
-
- {renderedRules.title}
-
-
-
{renderedRules.items.length} rules
-
-
- {renderedRules.items.map((item, index) => (
-
-
- {index + 1}
-
-
- {renderInlineCode(item)}
-
-
- ))}
-
-
- )}
-
-
- );
-}
-
-interface ChecklistPanelProps {
- checklist: TeamChecklistItem[];
- onAdd: (item: Omit) => void;
- onDelete: (itemId: string) => void;
- onStatusChange: (itemId: string, status: TeamChecklistItem["status"]) => void;
-}
-
-function ChecklistPanel({
- checklist,
- onAdd,
- onDelete,
- onStatusChange,
-}: ChecklistPanelProps) {
- const [newItem, setNewItem] = useState({
- assignee: "조하늘",
- dueLabel: "D-7",
- title: "",
- });
-
- function handleSubmit(event: FormEvent) {
- event.preventDefault();
-
- if (!newItem.title.trim()) {
- return;
- }
-
- onAdd({
- assignee: newItem.assignee,
- dueLabel: newItem.dueLabel,
- title: newItem.title.trim(),
- });
- setNewItem((current) => ({ ...current, title: "" }));
- }
-
- return (
-
-
-
-
-
- {checklist.map((item) => (
-
-
-
-
- {checklistLabels[item.status]}
-
- {item.dueLabel}
-
-
- {item.title}
-
-
- 담당 {item.assignee}
-
-
-
-
- {item.title} 상태
-
-
- onStatusChange(
- item.id,
- event.target.value as TeamChecklistItem["status"],
- )
- }
- value={item.status}
- >
- 할 일
- 진행 중
- 완료
-
- onDelete(item.id)}
- size="icon"
- type="button"
- variant="ghost"
- >
-
-
-
-
- ))}
-
-
-
- );
-}
-
-function GithubPanel({
- isProjectGroupGithubLinked,
- onProjectGroupGithubLinkedChange,
-}: {
- isProjectGroupGithubLinked: boolean;
- onProjectGroupGithubLinkedChange: (isLinked: boolean) => void;
-}) {
- const initialSelectedRepoIds =
- demoTeamSpace.githubSummary.connectedRepos.length > 0
- ? demoTeamSpace.githubSummary.connectedRepos.map((repo) => repo.id)
- : [];
- const [organization, setOrganization] = useState(
- demoTeamSpace.githubSummary.organization,
- );
- const [installationStatus, setInstallationStatus] = useState(
- demoTeamSpace.githubSummary.appInstallation.status,
- );
- const [selectedRepoIds, setSelectedRepoIds] = useState(
- initialSelectedRepoIds,
- );
- const [connectedRepos, setConnectedRepos] = useState(
- demoTeamSpace.githubSummary.connectedRepos,
- );
- const selectedRepositories = useMemo(
- () =>
- demoTeamSpace.githubSummary.availableRepositories.filter((repo) =>
- selectedRepoIds.includes(repo.id),
- ),
- [selectedRepoIds],
- );
- const isGitHubAppInstalled = installationStatus === "installed";
- const canSelectRepositories = Boolean(organization && isGitHubAppInstalled);
- const canSaveRepositoryConnection =
- canSelectRepositories && selectedRepoIds.length > 0;
- const hasConnectedRepositories = connectedRepos.length > 0;
- const showGithubPolicyNotice = !hasConnectedRepositories;
- const setupStatusItems = [
- {
- description: organization?.login ?? "Organization 필요",
- icon: Building2,
- label: "Organization",
- ready: Boolean(organization),
- },
- {
- description: isGitHubAppInstalled ? "TeamPo App 설치됨" : "설치 전",
- icon: Github,
- label: "GitHub App",
- ready: isGitHubAppInstalled,
- },
- {
- description: hasConnectedRepositories
- ? `${connectedRepos.length}개 저장소`
- : "저장소 선택 전",
- icon: GitBranch,
- label: "Repository",
- ready: hasConnectedRepositories,
- },
- {
- description: isProjectGroupGithubLinked ? "기여도 집계 가능" : "연동 전",
- icon: ShieldCheck,
- label: "팀 스페이스",
- ready: isProjectGroupGithubLinked,
- },
- ];
-
- function handleInstallationComplete() {
- setOrganization({
- login: "team-po-labs",
- name: "TeamPo Labs",
- url: "https://github.com/team-po-labs",
- });
- setInstallationStatus("installed");
- setConnectedRepos([]);
- onProjectGroupGithubLinkedChange(false);
- setSelectedRepoIds((current) =>
- current.length > 0
- ? current
- : [demoTeamSpace.githubSummary.availableRepositories[0]?.id].filter(
- Boolean,
- ),
- );
- }
-
- function handleRepositoryToggle(repoId: string) {
- setSelectedRepoIds((current) =>
- current.includes(repoId)
- ? current.filter((id) => id !== repoId)
- : [...current, repoId],
- );
- }
-
- function handleSaveRepositoryConnection() {
- if (!canSaveRepositoryConnection) {
- return;
- }
-
- setConnectedRepos(selectedRepositories);
- onProjectGroupGithubLinkedChange(true);
- }
-
- return (
-
-
-
-
- {setupStatusItems.map((item) => {
- const Icon = item.icon;
-
- return (
-
-
-
-
- {item.ready ? "ready" : "pending"}
-
-
-
- {item.label}
-
-
- {item.description}
-
-
- );
- })}
-
-
- {showGithubPolicyNotice ?
: null}
-
-
-
-
-
-
- GitHub 조직 준비
-
-
- 팀 스페이스에 연결할 GitHub 조직이 필요해요.
-
-
-
- {organization ? "found" : "required"}
-
-
- {organization ? (
-
-
- {organization.login}
-
-
- {organization.name}
-
-
- ) : (
-
- )}
-
-
-
-
-
-
- TeamPo GitHub App
-
-
- 읽기 전용 권한으로 설치하고 필요한 저장소만 선택해요.
-
-
-
- {isGitHubAppInstalled ? "installed" : "not installed"}
-
-
-
-
-
- Only select repositories
- read-only
- installation_id + state
-
-
- {demoTeamSpace.githubSummary.appInstallation.permissions.map(
- (permission) => (
-
- {permission}
-
- ),
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- 저장소 선택
-
-
- 설치된 GitHub App이 접근할 수 있는 저장소 중 팀 활동을 집계할
- 저장소를 선택해요.
-
-
-
- {canSelectRepositories ? "selectable" : "install first"}
-
-
-
- {demoTeamSpace.githubSummary.availableRepositories.map((repo) => (
-
- ))}
-
-
-
- {selectedRepoIds.length > 0
- ? `${selectedRepoIds.length}개 저장소 선택됨`
- : "최소 1개 저장소를 선택해 주세요."}
-
-
-
- 선택 저장소 연결
-
-
-
-
-
-
-
-
- 팀 스페이스 연결 상태
-
-
- 프로젝트 그룹 기준으로 GitHub 조직 연결 여부를 확인해요.
-
-
-
- {isProjectGroupGithubLinked ? "linked" : "not linked"}
-
-
- {connectedRepos.length > 0 ? (
-
- ) : (
-
-
- GitHub App 설치와 저장소 선택이 끝나면 이 영역에서 연결된
- 저장소를 확인할 수 있어요.
-
-
- )}
-
-
-
-
-
-
-
-
- GitHub 활동 히트맵
-
-
- 기여량이 많을수록 색과 밀도가 진해져요.
-
-
-
- open PR{" "}
- {isProjectGroupGithubLinked
- ? demoTeamSpace.githubSummary.openPrs
- : "-"}
-
-
-
- {demoTeamSpace.githubSummary.contributionDays.map((day) => (
-
- ))}
-
-
- {isProjectGroupGithubLinked
- ? demoTeamSpace.githubSummary.weeklySummary
- : "저장소를 연결하면 커밋, PR, 리뷰, 이슈 기준으로 팀원별 기여를 집계해요."}
-
-
-
-
팀원별 기여
-
- {demoTeamSpace.githubSummary.memberContributions.map(
- (contribution) => {
- const member = demoTeamSpace.members.find(
- (item) => item.id === contribution.memberId,
- );
-
- if (!member) {
- return null;
- }
-
- return (
-
-
-
- {isProjectGroupGithubLinked
- ? `커밋 ${contribution.commits} · PR ${contribution.prs} · 리뷰 ${contribution.reviews} · 이슈 ${contribution.issues}`
- : "저장소 연결 후 기여 수치가 표시돼요."}
-
-
- );
- },
- )}
-
-
-
-
-
최근 활동
- {isProjectGroupGithubLinked ? (
-
- {demoTeamSpace.githubSummary.recentActivities.map((activity) => (
-
-
- {activity.type.replace("_", " ")}
-
-
- {activity.label}
-
-
- {activity.memberName} · {activity.timeLabel}
-
-
- ))}
-
- ) : (
-
- 연동된 저장소 활동이 아직 없어요.
-
- )}
-
-
-
- );
-}
-
-function RepositoryOption({
- disabled,
- onToggle,
- repo,
- selected,
-}: {
- disabled: boolean;
- onToggle: (repoId: string) => void;
- repo: GithubRepositorySummary;
- selected: boolean;
-}) {
- return (
-
-
-
onToggle(repo.id)}
- type="checkbox"
- />
-
-
- {repo.owner}/{repo.name}
-
-
- {repo.visibility} · {repo.defaultBranch} · pushed{" "}
- {repo.lastPushedLabel}
-
-
-
-
-
- {repo.visibility}
-
-
-
-
- );
-}
-
-interface ChatPanelProps {
- messages: TeamMessage[];
- onSend: (message: string) => void;
-}
-
-function ChatPanel({ messages, onSend }: ChatPanelProps) {
- const [draftMessage, setDraftMessage] = useState("");
- const messageListRef = useRef(null);
- const latestMessageId = messages.at(-1)?.id;
-
- useEffect(() => {
- const messageList = messageListRef.current;
-
- if (!messageList || !latestMessageId) {
- return;
- }
-
- messageList.scrollTop = messageList.scrollHeight;
- }, [latestMessageId]);
-
- function handleSubmit(event: FormEvent) {
- event.preventDefault();
-
- if (!draftMessage.trim()) {
- return;
- }
-
- onSend(draftMessage);
- setDraftMessage("");
- }
-
- return (
-
-
-
-
- {messages.map((message) => (
-
-
-
-
{message.author}
-
- {message.timeLabel}
-
-
-
- {message.message}
-
-
-
- ))}
-
-
-
-
- );
-}
-
-function getTeamMetrics(checklist: TeamChecklistItem[]) {
- const doneCount = checklist.filter((item) => item.status === "done").length;
- const progress = checklist.length
- ? Math.round((doneCount / checklist.length) * 100)
- : 0;
-
- return [
- {
- label: "스프린트 진행률",
- tone: "primary" as const,
- trend: "이번 주 +14%",
- value: `${progress}%`,
- },
- {
- label: "완료 체크리스트",
- tone: "emerald" as const,
- trend: `${doneCount} / ${checklist.length} 완료`,
- value: `${doneCount}`,
- },
- {
- label: "오픈 PR",
- tone: "amber" as const,
- trend: "리뷰 필요",
- value: "3",
- },
- {
- label: "팀 온도",
- tone: "emerald" as const,
- value: "41.2",
- },
- ];
-}
-
-function parseRulesMarkdown(markdown: string) {
- const lines = markdown
- .split("\n")
- .map((line) => line.trim())
- .filter(Boolean);
- const title =
- lines.find((line) => line.startsWith("#"))?.replace(/^#+\s*/, "") ??
- "팀 규칙";
- const items = lines
- .filter((line) => line.startsWith("-"))
- .map((line) => line.replace(/^-\s*/, ""));
-
- return {
- items: items.length ? items : ["아직 등록된 규칙이 없어요."],
- title,
- };
-}
-
-function renderInlineCode(text: string): ReactNode[] {
- const parts = text.split(/(`[^`]+`)/g).filter(Boolean);
-
- return parts.map((part, index) => {
- const key = `${part}-${index}`;
-
- if (part.startsWith("`") && part.endsWith("`")) {
- return (
-
- {part.slice(1, -1)}
-
- );
- }
-
- return {part} ;
- });
-}
diff --git a/src/features/team/components/team-tab-list.tsx b/src/features/team/components/team-tab-list.tsx
new file mode 100644
index 0000000..3540134
--- /dev/null
+++ b/src/features/team/components/team-tab-list.tsx
@@ -0,0 +1,99 @@
+import {
+ BookOpenText,
+ CheckCircle2,
+ GitPullRequest,
+ Home,
+ MessageSquareText,
+ Settings2,
+ Sparkles,
+} from "lucide-react";
+import type { ComponentType } from "react";
+
+import { AppPanel } from "@/components/app-shell";
+import { cn } from "@/lib/utils";
+
+export type TeamTab =
+ | "overview"
+ | "guide"
+ | "rules"
+ | "checklist"
+ | "github"
+ | "chat"
+ | "manage";
+
+const tabs: Array<{
+ icon: ComponentType<{ className?: string }>;
+ id: TeamTab;
+ label: string;
+}> = [
+ { icon: Home, id: "overview", label: "홈" },
+ { icon: Sparkles, id: "guide", label: "가이드" },
+ { icon: BookOpenText, id: "rules", label: "규칙" },
+ { icon: CheckCircle2, id: "checklist", label: "체크리스트" },
+ { icon: GitPullRequest, id: "github", label: "GitHub" },
+ { icon: MessageSquareText, id: "chat", label: "채팅" },
+ { icon: Settings2, id: "manage", label: "관리" },
+];
+
+export function TeamTabList({
+ getBadge,
+ isDisabled,
+ onSelectTab,
+ selectedTab,
+}: {
+ getBadge: (tabId: TeamTab) => string | null;
+ isDisabled?: (tabId: TeamTab) => boolean;
+ onSelectTab: (tabId: TeamTab) => void;
+ selectedTab: TeamTab;
+}) {
+ return (
+
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ const badge = getBadge(tab.id);
+ const disabled = isDisabled?.(tab.id) ?? false;
+ const isSelected = selectedTab === tab.id;
+
+ return (
+ onSelectTab(tab.id)}
+ title={
+ disabled ? `${tab.label} 기능은 준비 중이에요.` : undefined
+ }
+ type="button"
+ >
+
+ {tab.label}
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/features/team/hooks/use-project-checklist-queries.ts b/src/features/team/hooks/use-project-checklist-queries.ts
index 425d6d6..a90c8f5 100644
--- a/src/features/team/hooks/use-project-checklist-queries.ts
+++ b/src/features/team/hooks/use-project-checklist-queries.ts
@@ -18,6 +18,7 @@ export const projectChecklistQueryKeys = {
all: ["project-checklists"] as const,
byProjectGroup: (projectGroupId: number) =>
["project-checklists", projectGroupId] as const,
+ disabled: ["project-checklists", "disabled"] as const,
};
const checklistStaleTimeMs = 15_000;
@@ -40,7 +41,7 @@ export function useProjectChecklistsQuery(
queryKey:
typeof projectGroupId === "number"
? projectChecklistQueryKeys.byProjectGroup(projectGroupId)
- : projectChecklistQueryKeys.all,
+ : projectChecklistQueryKeys.disabled,
refetchOnWindowFocus: false,
retry: false,
staleTime: checklistStaleTimeMs,
diff --git a/src/features/team/hooks/use-team-space-queries.ts b/src/features/team/hooks/use-team-space-queries.ts
index a77fac6..1dc28a2 100644
--- a/src/features/team/hooks/use-team-space-queries.ts
+++ b/src/features/team/hooks/use-team-space-queries.ts
@@ -25,6 +25,7 @@ export const teamSpaceQueryKeys = {
all: ["team-space"] as const,
devGuide: (projectGroupId: number) =>
["team-space", projectGroupId, "dev-guide"] as const,
+ disabled: ["team-space", "disabled"] as const,
githubAvailableRepositories: (projectGroupId: number) =>
["team-space", projectGroupId, "github", "available-repositories"] as const,
githubRepositories: (projectGroupId: number) =>
@@ -70,7 +71,7 @@ export function useGithubInstallationStatusQuery(
queryKey:
typeof projectGroupId === "number"
? teamSpaceQueryKeys.githubStatus(projectGroupId)
- : teamSpaceQueryKeys.all,
+ : teamSpaceQueryKeys.disabled,
refetchOnWindowFocus: false,
retry: false,
staleTime: githubStatusStaleTimeMs,
@@ -87,7 +88,7 @@ export function useDevGuideQuery(
queryKey:
typeof projectGroupId === "number"
? teamSpaceQueryKeys.devGuide(projectGroupId)
- : teamSpaceQueryKeys.all,
+ : teamSpaceQueryKeys.disabled,
refetchInterval: (query) =>
isDevGuideGenerating(query.state.data) ||
isDevGuidePendingError(query.state.error)
@@ -145,7 +146,7 @@ export function useAvailableGithubRepositoriesQuery(
queryKey:
typeof projectGroupId === "number"
? teamSpaceQueryKeys.githubAvailableRepositories(projectGroupId)
- : teamSpaceQueryKeys.all,
+ : teamSpaceQueryKeys.disabled,
refetchOnWindowFocus: false,
retry: false,
staleTime: githubRepositoryStaleTimeMs,
@@ -162,7 +163,7 @@ export function useGithubRepositoriesQuery(
queryKey:
typeof projectGroupId === "number"
? teamSpaceQueryKeys.githubRepositories(projectGroupId)
- : teamSpaceQueryKeys.all,
+ : teamSpaceQueryKeys.disabled,
refetchOnWindowFocus: false,
retry: false,
staleTime: githubRepositoryStaleTimeMs,
@@ -190,7 +191,7 @@ export function useGithubRepositoryContributionsQuery(
projectGroupId,
githubRepositoryId,
)
- : teamSpaceQueryKeys.all,
+ : teamSpaceQueryKeys.disabled,
refetchOnWindowFocus: false,
retry: false,
staleTime: githubContributionStaleTimeMs,
diff --git a/src/lib/api/mocks/handlers.ts b/src/lib/api/mocks/handlers.ts
index 3a8c991..db4f2a5 100644
--- a/src/lib/api/mocks/handlers.ts
+++ b/src/lib/api/mocks/handlers.ts
@@ -5,6 +5,13 @@ import {
createPreviewUser,
previewAuthSeed,
} from "@/lib/api/mocks/auth-preview";
+import {
+ createDisconnectedGithubStatus,
+ createEmptyGithubRepositoryContribution,
+ createMockAvailableGithubRepositories,
+ createMockDevGuide,
+ createSyncedGithubRepositoryContribution,
+} from "@/lib/api/mocks/team-space-fixtures";
import type { ApiErrorResponse } from "@/lib/types/api";
import type {
CreateUserRequest,
@@ -272,134 +279,8 @@ function resetTeamSpaceApiState() {
githubRepositoryContributions = new Map();
}
-function createDisconnectedGithubStatus(): GithubInstallationStatus {
- return {
- connected: false,
- organizationLogin: null,
- repositoryCount: 0,
- };
-}
-
-function createMockAvailableGithubRepositories(): GithubRepository[] {
- return [
- {
- fullName: "team-po-labs/client",
- githubRepositoryId: 100,
- repoName: "client",
- },
- {
- fullName: "team-po-labs/server",
- githubRepositoryId: 200,
- repoName: "server",
- },
- {
- fullName: "team-po-labs/product-notes",
- githubRepositoryId: 300,
- repoName: "product-notes",
- },
- ];
-}
-
const availableGithubRepositories = createMockAvailableGithubRepositories();
-function createMockDevGuide(
- projectGroup: MyProjectGroup | null,
-): DevGuideContent | null {
- if (!projectGroup) {
- return null;
- }
-
- return {
- decisionPoints: [
- {
- consideration: "초기 구현 비용과 인증 유지보수 범위를 함께 비교합니다.",
- options: ["JWT", "Session"],
- topic: "인증 방식",
- },
- {
- consideration:
- "팀 생성 직후 바로 사용할 수 있는 관리 흐름을 우선합니다.",
- options: ["자동 생성", "방장 승인"],
- topic: "팀 스페이스 생성 정책",
- },
- {
- consideration: "API 비용과 사용자가 기대하는 최신성을 함께 맞춥니다.",
- options: ["생성 시 1회", "수동 재생성", "주기 재생성"],
- topic: "AI 가이드 갱신 방식",
- },
- ],
- milestones: Array.from({ length: 12 }, (_, index) => {
- const week = index + 1;
-
- return {
- goal:
- week <= 4
- ? "핵심 API와 화면 흐름을 연결합니다."
- : week <= 8
- ? "팀 운영 데이터와 GitHub 연동 품질을 다듬습니다."
- : "릴리스 안정성과 회고 지표를 정리합니다.",
- roleTasks: {
- backend: "계약에 맞는 API 응답과 예외 처리를 점검합니다.",
- design: "반복 사용 화면의 정보 밀도와 상태 표현을 정리합니다.",
- frontend: "TanStack Query 상태와 사용자 피드백을 연결합니다.",
- },
- week,
- };
- }),
- mvpPriorities: [
- {
- feature: "매칭 요청과 팀 생성",
- priority: 1,
- rationale: "서비스의 첫 가치가 팀 구성 완료 여부에서 결정됩니다.",
- subFeatures: ["프로필 기반 요청", "수락/거절", "팀 스페이스 생성"],
- },
- {
- feature: "팀 운영 체크리스트",
- priority: 2,
- rationale: "팀이 바로 실행할 작업을 공유해야 이탈을 줄일 수 있습니다.",
- subFeatures: ["담당자 지정", "상태 변경", "AI 조언 조회"],
- },
- {
- feature: "GitHub 저장소 연결",
- priority: 3,
- rationale: "개발 활동을 팀 스페이스 지표로 확장하는 기반입니다.",
- subFeatures: ["App 설치", "저장소 선택", "연결 상태 확인"],
- },
- ],
- overview: `${projectGroup.projectTitle} 팀은 MVP 범위를 작게 유지하고 매칭 이후 바로 실행 가능한 협업 루틴을 만드는 데 집중합니다. ${
- projectGroup.projectDescription ??
- "팀 설명이 구체화되면 가이드의 우선순위도 함께 조정합니다."
- }`,
- techStack: [
- {
- category: "Backend",
- reason: "인증, 매칭, 팀 스페이스 API를 빠르게 구성하기 좋습니다.",
- recommendation: "Spring Boot",
- },
- {
- category: "Frontend",
- reason: "상태 기반 화면 전환과 컴포넌트 재사용에 적합합니다.",
- recommendation: "React + TypeScript",
- },
- {
- category: "Database",
- reason: "팀, 멤버, 체크리스트 관계를 명확하게 저장할 수 있습니다.",
- recommendation: "MySQL",
- },
- {
- category: "Infra",
- reason: "팀원이 같은 실행 환경에서 API와 UI를 검증할 수 있습니다.",
- recommendation: "Docker",
- },
- {
- category: "CI/CD",
- reason: "PR마다 타입, 린트, 빌드 상태를 자동 확인합니다.",
- recommendation: "GitHub Actions",
- },
- ],
- };
-}
-
function createMockDevGuideQueryResponse(): DevGuideQueryResponse | null {
if (!activeDevGuide && !activeDevGuideGenerationStatus) {
return null;
@@ -514,71 +395,6 @@ function syncGithubRepositoryContributionState() {
githubRepositoryContributions = nextContributions;
}
-function createEmptyGithubRepositoryContribution(
- repository: GithubRepository,
-): GithubRepositoryContributionResponse {
- return {
- contributors: [],
- fullName: repository.fullName,
- githubRepositoryId: repository.githubRepositoryId,
- repoName: repository.repoName,
- };
-}
-
-function createSyncedGithubRepositoryContribution(
- repository: GithubRepository,
-): GithubRepositoryContributionResponse {
- const baseContributors = [
- {
- additions: 860,
- changedFiles: 34,
- contributionScore: 40,
- deletions: 190,
- githubUserId: 501,
- githubUsername: "dev-a",
- linkedIssueCount: 2,
- mergedPrCount: 3,
- userId: 1,
- },
- {
- additions: 420,
- changedFiles: 18,
- contributionScore: 25,
- deletions: 80,
- githubUserId: 502,
- githubUsername: "dev-b",
- linkedIssueCount: 1,
- mergedPrCount: 2,
- userId: 2,
- },
- ];
-
- if (repository.githubRepositoryId === 200) {
- return {
- ...createEmptyGithubRepositoryContribution(repository),
- contributors: [
- {
- additions: 1240,
- changedFiles: 42,
- contributionScore: 55,
- deletions: 310,
- githubUserId: 503,
- githubUsername: "server-runner",
- linkedIssueCount: 3,
- mergedPrCount: 4,
- userId: 1,
- },
- ...baseContributors.slice(1),
- ],
- };
- }
-
- return {
- ...createEmptyGithubRepositoryContribution(repository),
- contributors: baseContributors,
- };
-}
-
function findSelectedGithubRepository(githubRepositoryId: number) {
return selectedGithubRepositories.find(
(repository) => repository.githubRepositoryId === githubRepositoryId,
diff --git a/src/lib/api/mocks/team-space-fixtures.ts b/src/lib/api/mocks/team-space-fixtures.ts
new file mode 100644
index 0000000..0a1b454
--- /dev/null
+++ b/src/lib/api/mocks/team-space-fixtures.ts
@@ -0,0 +1,198 @@
+import type { MyProjectGroup } from "@/lib/types/project-group";
+import type {
+ DevGuideContent,
+ GithubInstallationStatus,
+ GithubRepository,
+ GithubRepositoryContributionResponse,
+} from "@/lib/types/team-space";
+
+export function createDisconnectedGithubStatus(): GithubInstallationStatus {
+ return {
+ connected: false,
+ organizationLogin: null,
+ repositoryCount: 0,
+ };
+}
+
+export function createMockAvailableGithubRepositories(): GithubRepository[] {
+ return [
+ {
+ fullName: "team-po-labs/client",
+ githubRepositoryId: 100,
+ repoName: "client",
+ },
+ {
+ fullName: "team-po-labs/server",
+ githubRepositoryId: 200,
+ repoName: "server",
+ },
+ {
+ fullName: "team-po-labs/product-notes",
+ githubRepositoryId: 300,
+ repoName: "product-notes",
+ },
+ ];
+}
+
+export function createMockDevGuide(
+ projectGroup: MyProjectGroup | null,
+): DevGuideContent | null {
+ if (!projectGroup) {
+ return null;
+ }
+
+ return {
+ decisionPoints: [
+ {
+ consideration: "초기 구현 비용과 인증 유지보수 범위를 함께 비교합니다.",
+ options: ["JWT", "Session"],
+ topic: "인증 방식",
+ },
+ {
+ consideration:
+ "팀 생성 직후 바로 사용할 수 있는 관리 흐름을 우선합니다.",
+ options: ["자동 생성", "방장 승인"],
+ topic: "팀 스페이스 생성 정책",
+ },
+ {
+ consideration: "API 비용과 사용자가 기대하는 최신성을 함께 맞춥니다.",
+ options: ["생성 시 1회", "수동 재생성", "주기 재생성"],
+ topic: "AI 가이드 갱신 방식",
+ },
+ ],
+ milestones: Array.from({ length: 12 }, (_, index) => {
+ const week = index + 1;
+
+ return {
+ goal:
+ week <= 4
+ ? "핵심 API와 화면 흐름을 연결합니다."
+ : week <= 8
+ ? "팀 운영 데이터와 GitHub 연동 품질을 다듬습니다."
+ : "릴리스 안정성과 회고 지표를 정리합니다.",
+ roleTasks: {
+ backend: "계약에 맞는 API 응답과 예외 처리를 점검합니다.",
+ design: "반복 사용 화면의 정보 밀도와 상태 표현을 정리합니다.",
+ frontend: "TanStack Query 상태와 사용자 피드백을 연결합니다.",
+ },
+ week,
+ };
+ }),
+ mvpPriorities: [
+ {
+ feature: "매칭 요청과 팀 생성",
+ priority: 1,
+ rationale: "서비스의 첫 가치가 팀 구성 완료 여부에서 결정됩니다.",
+ subFeatures: ["프로필 기반 요청", "수락/거절", "팀 스페이스 생성"],
+ },
+ {
+ feature: "팀 운영 체크리스트",
+ priority: 2,
+ rationale: "팀이 바로 실행할 작업을 공유해야 이탈을 줄일 수 있습니다.",
+ subFeatures: ["담당자 지정", "상태 변경", "AI 조언 조회"],
+ },
+ {
+ feature: "GitHub 저장소 연결",
+ priority: 3,
+ rationale: "개발 활동을 팀 스페이스 지표로 확장하는 기반입니다.",
+ subFeatures: ["App 설치", "저장소 선택", "연결 상태 확인"],
+ },
+ ],
+ overview: `${projectGroup.projectTitle} 팀은 MVP 범위를 작게 유지하고 매칭 이후 바로 실행 가능한 협업 루틴을 만드는 데 집중합니다. ${
+ projectGroup.projectDescription ??
+ "팀 설명이 구체화되면 가이드의 우선순위도 함께 조정합니다."
+ }`,
+ techStack: [
+ {
+ category: "Backend",
+ reason: "인증, 매칭, 팀 스페이스 API를 빠르게 구성하기 좋습니다.",
+ recommendation: "Spring Boot",
+ },
+ {
+ category: "Frontend",
+ reason: "상태 기반 화면 전환과 컴포넌트 재사용에 적합합니다.",
+ recommendation: "React + TypeScript",
+ },
+ {
+ category: "Database",
+ reason: "팀, 멤버, 체크리스트 관계를 명확하게 저장할 수 있습니다.",
+ recommendation: "MySQL",
+ },
+ {
+ category: "Infra",
+ reason: "팀원이 같은 실행 환경에서 API와 UI를 검증할 수 있습니다.",
+ recommendation: "Docker",
+ },
+ {
+ category: "CI/CD",
+ reason: "PR마다 타입, 린트, 빌드 상태를 자동 확인합니다.",
+ recommendation: "GitHub Actions",
+ },
+ ],
+ };
+}
+
+export function createEmptyGithubRepositoryContribution(
+ repository: GithubRepository,
+): GithubRepositoryContributionResponse {
+ return {
+ contributors: [],
+ fullName: repository.fullName,
+ githubRepositoryId: repository.githubRepositoryId,
+ repoName: repository.repoName,
+ };
+}
+
+export function createSyncedGithubRepositoryContribution(
+ repository: GithubRepository,
+): GithubRepositoryContributionResponse {
+ const baseContributors = [
+ {
+ additions: 860,
+ changedFiles: 34,
+ contributionScore: 40,
+ deletions: 190,
+ githubUserId: 501,
+ githubUsername: "dev-a",
+ linkedIssueCount: 2,
+ mergedPrCount: 3,
+ userId: 1,
+ },
+ {
+ additions: 420,
+ changedFiles: 18,
+ contributionScore: 25,
+ deletions: 80,
+ githubUserId: 502,
+ githubUsername: "dev-b",
+ linkedIssueCount: 1,
+ mergedPrCount: 2,
+ userId: 2,
+ },
+ ];
+
+ if (repository.githubRepositoryId === 200) {
+ return {
+ ...createEmptyGithubRepositoryContribution(repository),
+ contributors: [
+ {
+ additions: 1240,
+ changedFiles: 42,
+ contributionScore: 55,
+ deletions: 310,
+ githubUserId: 503,
+ githubUsername: "server-runner",
+ linkedIssueCount: 3,
+ mergedPrCount: 4,
+ userId: 1,
+ },
+ ...baseContributors.slice(1),
+ ],
+ };
+ }
+
+ return {
+ ...createEmptyGithubRepositoryContribution(repository),
+ contributors: baseContributors,
+ };
+}