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) => ( +
  1. + + {index + 1} + + + {step} + +
  2. + ))} +
+
+
+ ); +} 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} +

+
+
+ +
+ + + + +
+
+
+ ); +} + +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 ? "완료" : "설정 필요"} + + 상태예요. 팀 설정과 멤버 관리는 관리 탭에서 확인해요. +
+
+ + +
+
+
+
+ ); +} + +function MockManagePanel({ + onTeamNameChange, + teamName, +}: { + onTeamNameChange: (name: string) => void; + teamName: string; +}) { + return ( +
+ + local preview} + description="팀 설정 화면을 미리 확인해요. 아직 연결되지 않은 기능은 준비 중이에요." + eyebrow="Manage" + title="팀 관리" + /> +
+
+ + +
+
+ 팀 운영 상태 변경은 서버 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 ( + + + + + + ) : ( + + ) + } + description="함께 지킬 협업 규칙을 정리하고 필요할 때 수정해요." + eyebrow="Rulebook" + title="팀 규칙" + /> +
+ {isEditing ? ( +
+ +