From 911f3181a6fa6e154ac9e03d8c1bf678339a2956 Mon Sep 17 00:00:00 2001 From: jeonbinggu Date: Thu, 8 Oct 2026 16:03:39 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=99=9C=EB=8F=99=20=ED=83=AD=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=C2=B7=20=EB=B3=B5=EC=82=AC=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=C2=B7=20=EB=B0=B0=EC=A0=95=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EB=AC=B8=EA=B5=AC=20=C2=B7=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=82=AC=EC=A0=84=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 활동·비용 탭: 글자 12px, 줄바꿈 금지, 탭 묶음이 설명에 밀려 줄어들지 않게 - CopyField: 복사됨 ✓ / 복사 실패를 1.5초 표시 (연결 키·초대 링크 공통) - 프로젝트 목록: 내 에이전트가 연결돼 있으면 연결 안내 대신 배정 대기 문구 - 프로젝트 시작: 태스크가 있는 역할에 미배정이거나 태스크가 없으면 버튼 비활성화 + 이동 링크. 판정은 여전히 서버 Closes #48 Co-Authored-By: Claude Opus 5.5 --- src/shared/ui/index.tsx | 24 ++++++++-- src/widgets/activity/ActivityView.tsx | 4 +- src/widgets/project-list/ProjectListView.tsx | 18 +++++--- src/widgets/settings/SettingsView.tsx | 48 ++++++++++++++++---- 4 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/shared/ui/index.tsx b/src/shared/ui/index.tsx index a7fdd11..80c75cf 100644 --- a/src/shared/ui/index.tsx +++ b/src/shared/ui/index.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { forwardRef, + useState, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode, @@ -270,6 +271,19 @@ export function EmptyState({ title, desc, action }: { title: string; desc?: stri } export function CopyField({ value, label }: { value: string; label?: string }) { + const [copied, setCopied] = useState<"ok" | "fail" | null>(null); + const copy = () => { + const flash = (r: "ok" | "fail") => { + setCopied(r); + setTimeout(() => setCopied(null), 1500); + }; + // clipboard는 https·localhost에서만 있다 + if (!navigator.clipboard) return flash("fail"); + navigator.clipboard.writeText(value).then( + () => flash("ok"), + () => flash("fail"), + ); + }; return (
@@ -278,10 +292,14 @@ export function CopyField({ value, label }: { value: string; label?: string }) {
); diff --git a/src/widgets/activity/ActivityView.tsx b/src/widgets/activity/ActivityView.tsx index 9c99a3c..f7dd34e 100644 --- a/src/widgets/activity/ActivityView.tsx +++ b/src/widgets/activity/ActivityView.tsx @@ -34,13 +34,13 @@ export function ActivityView({ projectId }: { projectId: string }) { 상세에서 확인합니다.

-
+
{(Object.keys(TABS) as Tab[]).map((t) => (
) : ( - + <> + {(unassigned.length > 0 || noTasks) && ( +

+ {noTasks ? ( + <> + 태스크가 없습니다.{" "} + + Room 3에서 PM 계획 받기 + + + ) : ( + <> + {unassigned.map((r) => TEAM_ROLE_META[r].label).join(", ")} 역할에 배정된 에이전트가 없습니다.{" "} + + 역할 배정으로 + + + )} +

+ )} + + )} )} From 0ddc14edea746e4476b0dcb7fd3e0fcf8913b031 Mon Sep 17 00:00:00 2001 From: jeonbinggu Date: Sun, 11 Oct 2026 14:36:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20BE=2010.08=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=E2=80=94=20GitHub=20=EB=A0=88=ED=8F=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=C2=B7=ED=98=91=EC=97=85=EC=9E=90=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20=EC=97=AD=ED=95=A0=20=EB=A3=B8=20=C2=B7=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=9E=AC=EA=B0=9C=20=C2=B7=20G3?= =?UTF-8?q?=20=C2=B7=20=EC=A7=88=EB=AC=B8=20=EC=A4=91=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema.d.ts 재생성 (BE dev openapi) - 새 레포 만들기(대표): entities/repo CreateGithubRepo, 프로젝트 생성·조직 화면 - 역할 배정에 레포별 GitHub 초대 결과 + 다시 초대 - 역할 룸 피드(/rooms/frontend · /rooms/backend), 사이드바·대시보드 Room 목록을 API로 - BLOCKED 사유 구분(QUESTION · AGENT_STOPPED), 수령 후 경과, 대표 재개 - G3 승인 카드(taskId null), 계획 카드에 통합 확인 항목 - entities/question: 답할 질문 목록 · 답하기 카드, 사이드바 배지를 실제 승인·질문 수로 - 질문 중계 토글(프로젝트 생성 · 설정) - 새 이벤트·에러 문구, 실시간 토픽 room · questions 매핑 Closes #50 Co-Authored-By: Claude Opus 5.5 --- docs/conventions.md | 2 +- src/app/p/[projectId]/rooms/[roomId]/page.tsx | 6 +- src/entities/approval/ui/ApprovalCard.tsx | 88 +- src/entities/event/model/describe.ts | 41 +- src/entities/project/api/projectMutations.ts | 36 +- src/entities/project/index.ts | 9 +- src/entities/question/api/questionApi.ts | 51 + src/entities/question/index.ts | 2 + src/entities/question/ui/QuestionCard.tsx | 214 ++ src/entities/repo/api/repoApi.ts | 23 +- src/entities/repo/index.ts | 3 + src/entities/repo/ui/CreateGithubRepo.tsx | 134 + src/entities/room/api/roomApi.ts | 45 + src/entities/room/index.ts | 3 +- src/entities/room/model/types.ts | 11 + src/entities/task/api/taskApi.ts | 15 +- src/entities/task/index.ts | 3 +- src/entities/task/model/api.ts | 11 + src/shared/api/errors.ts | 12 +- src/shared/api/schema.d.ts | 3117 ++++++++++++----- .../account-settings/AccountSettingsView.tsx | 3 +- src/widgets/app-shell/AppShell.tsx | 82 +- src/widgets/auth/useRealtimeSync.ts | 8 +- src/widgets/dashboard/DashboardView.tsx | 69 +- src/widgets/inbox/InboxView.tsx | 47 +- src/widgets/org/OrgView.tsx | 16 +- src/widgets/org/RepoSection.tsx | 22 +- src/widgets/pm-room/PlanCard.tsx | 17 + src/widgets/project-new/ProjectNewView.tsx | 69 +- src/widgets/room/RoleRoomView.tsx | 184 + src/widgets/room/index.ts | 1 + src/widgets/settings/SettingsView.tsx | 124 +- src/widgets/task-detail/TaskDetailView.tsx | 77 +- 33 files changed, 3606 insertions(+), 939 deletions(-) create mode 100644 src/entities/question/api/questionApi.ts create mode 100644 src/entities/question/index.ts create mode 100644 src/entities/question/ui/QuestionCard.tsx create mode 100644 src/entities/repo/ui/CreateGithubRepo.tsx create mode 100644 src/entities/room/api/roomApi.ts create mode 100644 src/widgets/room/RoleRoomView.tsx diff --git a/docs/conventions.md b/docs/conventions.md index c03abcf..a0034e4 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -40,7 +40,7 @@ src/ **`src/lib/`은 레이어가 아니다.** `store.tsx`와 `mock/seed.ts`만 남아 있고, 해체 예정이라(`architecture.md` §8) 이번 이행에서 일부러 옮기지 않았다. 여기에 새 파일을 추가하지 말 것. -엔티티 슬라이스 17개: `task` `agent` `user` `org` `invite` `project` `repo` `plan` `artifact` `note` `room` `message` `document` `event` `policy` `approval` `spec`. +엔티티 슬라이스 18개: `task` `agent` `user` `org` `invite` `project` `repo` `plan` `artifact` `note` `room` `message` `document` `event` `policy` `approval` `spec` `question`. 위젯 슬라이스 20개: `room` `pm-room` `dashboard` `inbox` `activity` `docs` `settings` `account-settings` `landing` `invite` `org` `repo-paths` `app-shell` `auth` `connect-agent` `connect-device` `onboarding` `project-list` `project-new` `task-detail`. **import는 `app → widgets → entities → shared` 단방향만.** 같은 레이어끼리 import 금지. diff --git a/src/app/p/[projectId]/rooms/[roomId]/page.tsx b/src/app/p/[projectId]/rooms/[roomId]/page.tsx index 9d5e927..8adbda2 100644 --- a/src/app/p/[projectId]/rooms/[roomId]/page.tsx +++ b/src/app/p/[projectId]/rooms/[roomId]/page.tsx @@ -1,6 +1,10 @@ -import { RoomView } from "@/widgets/room"; +import { RoleRoomView, RoomView } from "@/widgets/room"; +import { roleOfRoomSlug } from "@/entities/room"; +// frontend·backend는 역할 룸(API). 그 밖의 id는 목업 Room이다 export default async function RoomPage({ params }: { params: Promise<{ projectId: string; roomId: string }> }) { const { projectId, roomId } = await params; + const role = roleOfRoomSlug(roomId); + if (role) return ; return ; } diff --git a/src/entities/approval/ui/ApprovalCard.tsx b/src/entities/approval/ui/ApprovalCard.tsx index 47f5a5b..ea9e853 100644 --- a/src/entities/approval/ui/ApprovalCard.tsx +++ b/src/entities/approval/ui/ApprovalCard.tsx @@ -14,9 +14,25 @@ const MAX_REASON = 2000; const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); +interface ReleaseTask { + taskId: string; + title: string; + teamRole: string | null; + repo: string | null; + branchName: string | null; + commitSha: string | null; +} + +/** G3 payload.tasks: 대표가 머지하고 실행해 볼 브랜치와 마지막 커밋. 형식이 다른 항목은 버린다 */ +function releaseTasks(v: unknown): ReleaseTask[] { + if (!Array.isArray(v)) return []; + return v.filter((t): t is ReleaseTask => typeof t === "object" && t !== null && typeof t.title === "string"); +} + /** - * ACTION 승인 카드. 검증은 통과했지만 정책 판정이 HUMAN·PM_REVIEW라 멈춘 산출물이다. - * 결정은 대표만(canDecide). 승인하면 태스크 DONE, 반려하면 READY로 돌아가 재시도 +1 (3회째 ESCALATED) + * 승인 카드. 결정은 대표만(canDecide). + * - ACTION: 검증은 통과했지만 정책 판정이 HUMAN·PM_REVIEW라 멈춘 산출물. 승인하면 태스크 DONE, 반려하면 READY로 돌아가 재시도 +1 (3회째 ESCALATED) + * - G3: 통합 확인(프로젝트 전체, taskId 없음). 모든 태스크가 DONE이 되면 생긴다. 승인하면 프로젝트 completed, 반려하면 진행 중 그대로 */ export function ApprovalCard({ approval: a, @@ -26,7 +42,8 @@ export function ApprovalCard({ }: { approval: Approval; canDecide: boolean; - taskHref: string; + /** G3는 태스크가 없다 */ + taskHref?: string; onDecided?: () => void; }) { const decide = useDecideApproval(); @@ -38,6 +55,9 @@ export function ApprovalCard({ const paths = strings(p.changedPaths); const commit = typeof p.commitSha === "string" ? p.commitSha : null; const attempt = typeof p.attempt === "number" ? p.attempt : null; + const release = a.gate === "G3"; + const checks = strings(p.integrationChecks); + const tasks = releaseTasks(p.tasks); const run = (decision: "approve" | "reject") => decide.mutate( @@ -48,14 +68,22 @@ export function ApprovalCard({ return (
- {a.gateMode === "PM_REVIEW" ? ( + {release ? ( + G3 · 통합 확인 + ) : a.gateMode === "PM_REVIEW" ? ( PM 검토 · 대표 대행 ) : ( 사람 승인 )} - - {a.taskTitle ?? "태스크"} - + {taskHref ? ( + + {a.taskTitle ?? "태스크"} + + ) : ( + + {release ? "모든 태스크 완료 · 프로젝트 완료 승인" : (a.taskTitle ?? "태스크")} + + )} {relTime(a.requestedAt)} @@ -99,6 +127,40 @@ export function ApprovalCard({ )} + {release && ( +
+

+ 에이전트는 통합을 확인하지 않습니다. 아래 브랜치를 머지해 실행해 보고 확인 항목을 직접 점검하세요. +

+ {checks.length > 0 && ( +
+
확인 항목 {checks.length}개
+
    + {checks.map((c) => ( +
  • {c}
  • + ))} +
+
+ )} + {tasks.length > 0 && ( +
+ 브랜치 · 마지막 커밋 {tasks.length}개 +
    + {tasks.map((t) => ( +
  • + {t.title} + + {t.repo} + {t.branchName && ` · ${t.branchName}`} + {t.commitSha && ` @ ${t.commitSha.slice(0, 7)}`} + +
  • + ))} +
+
+ )} +
+ )} {p.backfilled === true && (

승인 기능 이전부터 멈춰 있던 태스크라 정보 일부가 없습니다.

)} @@ -121,7 +183,11 @@ export function ApprovalCard({ value={reason} maxLength={MAX_REASON} onChange={(e) => setReason(e.target.value)} - placeholder="반려 사유 (필수) — 에이전트가 다시 고칠 때 이 사유를 받습니다" + placeholder={ + release + ? "반려 사유 (필수) — 무엇이 통합되지 않았는지 남깁니다" + : "반려 사유 (필수) — 에이전트가 다시 고칠 때 이 사유를 받습니다" + } aria-label="반려 사유" autoFocus /> @@ -155,7 +221,11 @@ export function ApprovalCard({ > 반려 - 반려하면 태스크가 다시 대기로 돌아가 에이전트가 고칩니다. + + {release + ? "승인하면 프로젝트가 완료됩니다. 반려하면 진행 중 그대로이고, 고칠 태스크를 만들어 전부 끝나면 새 카드가 생깁니다." + : "반려하면 태스크가 다시 대기로 돌아가 에이전트가 고칩니다."} +
)} {decide.isError &&

{errorMessage(decide.error)}

} diff --git a/src/entities/event/model/describe.ts b/src/entities/event/model/describe.ts index 1340871..cc252f7 100644 --- a/src/entities/event/model/describe.ts +++ b/src/entities/event/model/describe.ts @@ -31,7 +31,8 @@ const ROLE: Record = { FRONTEND: "FE", BACKEND: "BE" }; /** 누가 했는가. 에이전트가 했으면 에이전트, 아니면 책임 주체(사람 또는 system:*) */ export function eventActor(e: ProjectEvent, l: EventLookup) { // 배정·해제는 actorAgentId에 배정된 에이전트가 들어 있다 — 한 사람은 대표다 - const byPerson = e.type === "MEMBER_ASSIGNED" || e.type === "MEMBER_UNASSIGNED"; + const byPerson = + e.type === "MEMBER_ASSIGNED" || e.type === "MEMBER_UNASSIGNED" || e.type === "GITHUB_COLLABORATORS_INVITED"; if (e.actorAgentId && !byPerson) return l.agent(e.actorAgentId) ?? "에이전트"; if (e.onBehalfOf === "system:pm") return "PM"; if (e.onBehalfOf.startsWith("system:")) return "시스템"; @@ -107,6 +108,44 @@ export function describeEvent(e: ProjectEvent, l: EventLookup): { text: string; } case "PLAN_REJECTED": return { text: `계획 반려${str("reason") && ` — ${str("reason")}`}`, tone: "default" }; + case "TASK_DISPATCHED": + return { text: `실행 지시 · ${str("title") || task()}`, tone: "default" }; + case "AGENT_RUN_STARTED": + return { text: `에이전트 실행 시작 · ${task()}`, tone: "default" }; + case "AGENT_RUN_ENDED": + return { text: `에이전트 실행 종료 · ${task()}`, tone: "default" }; + case "TASK_BLOCKED": + return { text: `제출 없이 멈춤 · 대표 확인 필요 · ${task()}`, tone: "warn" }; + case "TASK_RESUMED": + return { text: `재개 · ${task()}${str("note") && ` — ${str("note")}`}`, tone: "default" }; + case "RELEASE_REQUESTED": + return { text: `모든 태스크 완료 · 통합 확인 요청 (확인 항목 ${num("checkCount")}개)`, tone: "warn" }; + case "RELEASE_DECIDED": + return str("decision") === "APPROVE" + ? { text: "통합 확인 승인 · 프로젝트 완료", tone: "success" } + : { text: `통합 확인 반려${str("reason") && ` — ${str("reason")}`}`, tone: "danger" }; + case "ACTION_DETECTED": + return { text: `행동 감지 · ${str("actionKey")} · ${task()}`, tone: "warn" }; + case "GITHUB_COLLABORATORS_INVITED": { + const results = Array.isArray(p.results) ? (p.results as { status?: unknown }[]) : []; + const bad = results.filter((r) => r.status === "skipped" || r.status === "failed").length; + return bad > 0 + ? { text: `GitHub 협업자 초대 · ${results.length}개 레포 중 ${bad}개 보내지 못함 · ${agent()}`, tone: "warn" } + : { text: `GitHub 협업자 초대 · 레포 ${results.length}개 · ${agent()}`, tone: "default" }; + } + case "PROJECT_SETTINGS_UPDATED": { + const after = p.after as { questionRelay?: unknown } | undefined; + return { text: `질문 중계 ${after?.questionRelay === false ? "끔" : "켬"}`, tone: "default" }; + } + case "QUESTION_ASKED": + return { + text: `${ROLE[str("askerRole")] ?? "에이전트"} → ${ROLE[str("targetRole")] ?? "자기 소관"} 질문 · ${task()}`, + tone: "default", + }; + case "QUESTION_ANSWERED": + return { text: `질문에 답함 · ${task()}`, tone: "default" }; + case "QUESTION_EXPIRED": + return { text: `질문 만료 · 답이 없음 · ${task()}`, tone: "danger" }; default: return { text: e.type, tone: "default" }; } diff --git a/src/entities/project/api/projectMutations.ts b/src/entities/project/api/projectMutations.ts index a3f7add..84c1e2b 100644 --- a/src/entities/project/api/projectMutations.ts +++ b/src/entities/project/api/projectMutations.ts @@ -42,14 +42,17 @@ function useRefetchOnStale(projectId: string) { }; } -/** 대표 전용. 응답 notice: 배정된 에이전트는 토큰을 재발급해야 태스크·노트 API를 쓸 수 있다 */ +/** + * 대표 전용. 응답 notice: 배정된 에이전트는 토큰을 재발급해야 태스크·노트 API를 쓸 수 있다. + * 서버가 에이전트 주인을 프로젝트의 GitHub 레포에 협업자로 초대한다 — 결과는 members[].githubInvites에도 남는다 + */ export function useAssignMember(projectId: string) { return useMutation({ mutationFn: (body: RequestBody<"/projects/{projectId}/members", "post">) => - apiFetch<{ members: Schemas["ProjectMember"][]; notice: string }>(`/projects/${projectId}/members`, { - method: "POST", - body, - }), + apiFetch<{ members: Schemas["ProjectMember"][]; githubInvites: Schemas["GithubInviteResult"][]; notice: string }>( + `/projects/${projectId}/members`, + { method: "POST", body }, + ), onSuccess: useInvalidateProject(projectId), onError: useRefetchOnStale(projectId), }); @@ -67,6 +70,29 @@ export function useUnassignMember(projectId: string) { }); } +/** 대표 전용. GitHub 협업자 초대 다시 보내기. 시작 뒤에도 된다. 초대가 실패해도 200이고 결과는 githubInvites에 적힌다 */ +export function useResendGithubInvite(projectId: string) { + return useMutation({ + mutationFn: (agentId: string) => + apiFetch<{ githubInvites: Schemas["GithubInviteResult"][] }>( + `/projects/${projectId}/members/${agentId}/github-invite`, + { method: "POST" }, + ), + onSuccess: useInvalidateProject(projectId), + onError: useRefetchOnStale(projectId), + }); +} + +/** 대표 전용, 시작 전에만 (시작 뒤 403 PROJECT_STARTED). 지금은 질문 중계 스위치뿐이다 */ +export function useUpdateProjectSettings(projectId: string) { + return useMutation({ + mutationFn: (body: RequestBody<"/projects/{projectId}/settings", "patch">) => + apiFetch(`/projects/${projectId}/settings`, { method: "PATCH", body }), + onSuccess: useInvalidateProject(projectId), + onError: useRefetchOnStale(projectId), + }); +} + /** * G1. 대표 전용. 이 순간부터 에이전트가 태스크를 가져간다. 시작 후에는 멤버를 바꿀 수 없다. * 422 PROJECT_START_INVALID는 details에 이유가 전부 온다 (errorDetails) diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index d46b7f8..327411d 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -1,6 +1,13 @@ export type { Membership, Project, Repo } from "./model/types"; export { PROJECT_STATUS, fmtUsd } from "./model/api"; export { projectKeys, useProject, useProjects } from "./api/projectApi"; -export { useAssignMember, useCreateProject, useStartProject, useUnassignMember } from "./api/projectMutations"; +export { + useAssignMember, + useCreateProject, + useResendGithubInvite, + useStartProject, + useUnassignMember, + useUpdateProjectSettings, +} from "./api/projectMutations"; export { ProjectStatusBadge } from "./ui/ProjectStatusBadge"; export { ProjectErrorView } from "./ui/ProjectErrorView"; diff --git a/src/entities/question/api/questionApi.ts b/src/entities/question/api/questionApi.ts new file mode 100644 index 0000000..166f902 --- /dev/null +++ b/src/entities/question/api/questionApi.ts @@ -0,0 +1,51 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiFetch, livePoll, type Schemas } from "@/shared/api"; + +type AgentQuestion = Schemas["AgentQuestion"]; +/** pending = 사람이 답해야 함 · agent_answered = 에이전트가 답함(사람 확인 권장) */ +type Status = "pending" | "agent_answered"; + +export const questionKeys = { + all: ["question"] as const, + mine: (status: Status) => [...questionKeys.all, "mine", status] as const, + project: (projectId: string, status: Status) => [...questionKeys.all, "project", projectId, status] as const, +}; + +const POLL = { refetchInterval: livePoll(5000), refetchIntervalInBackground: false } as const; + +/** 내가 답할 수 있는 질문 (최신순). 대표는 조직 전체, 팀원은 자기 역할이 답할 질문만. 알림 배지용 */ +export function useMyQuestions(status: Status = "pending") { + return useQuery({ + queryKey: questionKeys.mine(status), + queryFn: () => + apiFetch<{ questions: { question: AgentQuestion; projectName: string; taskTitle: string }[] }>( + `/me/questions?status=${status}`, + ), + select: (data) => data.questions, + ...POLL, + }); +} + +/** 프로젝트의 질문 (최신순). 볼 수 있는 범위는 태스크와 같다 — 답할 수 있는지는 targetRole로 따로 본다 */ +export function useProjectQuestions(projectId: string, status: Status = "pending") { + return useQuery({ + queryKey: questionKeys.project(projectId, status), + queryFn: () => apiFetch<{ questions: AgentQuestion[] }>(`/projects/${projectId}/questions?status=${status}`), + select: (data) => data.questions, + ...POLL, + }); +} + +/** + * 답하기. 낙관적 업데이트 없이 서버 응답으로만 바뀐다. answers의 키는 질문 문장 그대로여야 한다 (다르면 422 QUESTION_INVALID). + * agent_answered 질문에 같은 답을 보내면 확인, 다른 답이면 뒤집기다. + * 409 QUESTION_CLOSED도 상태가 바뀐 것이라 성공·실패 모두 목록을 다시 읽는다 + */ +export function useAnswerQuestion() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, answers }: { id: string; answers: Record }) => + apiFetch(`/questions/${id}/answer`, { method: "POST", body: { answers } }), + onSettled: () => queryClient.invalidateQueries({ queryKey: questionKeys.all }), + }); +} diff --git a/src/entities/question/index.ts b/src/entities/question/index.ts new file mode 100644 index 0000000..28f5812 --- /dev/null +++ b/src/entities/question/index.ts @@ -0,0 +1,2 @@ +export { questionKeys, useAnswerQuestion, useMyQuestions, useProjectQuestions } from "./api/questionApi"; +export { QuestionCard } from "./ui/QuestionCard"; diff --git a/src/entities/question/ui/QuestionCard.tsx b/src/entities/question/ui/QuestionCard.tsx new file mode 100644 index 0000000..3651f7c --- /dev/null +++ b/src/entities/question/ui/QuestionCard.tsx @@ -0,0 +1,214 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { ArrowRight } from "lucide-react"; +import { errorMessage, type Schemas } from "@/shared/api"; +import type { TeamRole } from "@/shared/model"; +import { Badge, Button, Textarea } from "@/shared/ui"; +import { cn, fmtDateTime, relTime } from "@/shared/lib/format"; +import { useAnswerQuestion } from "../api/questionApi"; + +type AgentQuestion = Schemas["AgentQuestion"]; + +const ROLE: Record = { + FRONTEND: { label: "FE", tone: "fe" }, + BACKEND: { label: "BE", tone: "be" }, +}; + +const SOURCE: Record, string> = { + agent: "에이전트가 답함 · 사람 미확인", + human: "사람이 답함", + agent_confirmed: "에이전트 답을 사람이 확인함", + human_override: "사람이 에이전트 답을 뒤집음", +}; + +const STATUS: Record< + AgentQuestion["status"], + { label: string; tone: "warn" | "info" | "success" | "danger" | "neutral" } +> = { + pending: { label: "답 필요", tone: "warn" }, + agent_answered: { label: "에이전트가 답함 · 확인 권장", tone: "info" }, + answered: { label: "답함", tone: "success" }, + expired: { label: "만료", tone: "danger" }, + self_owned: { label: "묻는 쪽 소관", tone: "neutral" }, +}; + +/** multiSelect는 고른 label을 "a, b"처럼 쉼표로 이어 보낸다 */ +function toggleLabel(value: string, label: string) { + const picked = value.split(", ").filter(Boolean); + return (picked.includes(label) ? picked.filter((x) => x !== label) : [...picked, label]).join(", "); +} + +/** + * 에이전트가 다른 역할에게 물은 질문 카드. 답할 역할의 담당자와 대표만 답한다(canAnswer) — 최종 판정은 403 NOT_QUESTION_TARGET. + * 한 카드의 질문(최대 4개)을 전부 답해야 보낼 수 있다. 초안(draft)이 있으면 그 답을 기본값으로 채운다 + */ +export function QuestionCard({ + question: q, + canAnswer, + taskHref, + taskTitle, +}: { + question: AgentQuestion; + canAnswer: boolean; + taskHref: string; + taskTitle?: string; +}) { + const answer = useAnswerQuestion(); + const agentAnswered = q.status === "agent_answered"; + const open = q.status === "pending" || agentAnswered; + // 에이전트가 이미 답했으면 그 답, 아니면 상담 초안 + const initial = agentAnswered ? q.answers : q.draft?.answers; + const [values, setValues] = useState>(() => + Object.fromEntries(q.questions.map((x) => [x.question, initial?.[x.question] ?? ""])), + ); + const set = (question: string, v: string) => setValues((prev) => ({ ...prev, [question]: v })); + + const complete = q.questions.every((x) => values[x.question].trim() !== ""); + // 손대지 않은 답은 원문 그대로 보낸다 — 공백만 달라도 서버는 "뒤집기"로 본다 + const original = (question: string) => (agentAnswered ? q.answers?.[question] : undefined); + const unchanged = (question: string) => values[question].trim() === original(question)?.trim(); + const overriding = agentAnswered && q.questions.some((x) => !unchanged(x.question)); + + const submit = () => + answer.mutate({ + id: q.id, + answers: Object.fromEntries( + q.questions.map((x) => [x.question, unchanged(x.question) ? original(x.question)! : values[x.question].trim()]), + ), + }); + + return ( +
+
+ {ROLE[q.askerRole].label} 에이전트 + + {ROLE[q.targetRole].label} + {STATUS[q.status].label} + + {taskTitle ?? "태스크 보기"} + + + {relTime(q.createdAt)} + +
+ +
    + {q.questions.map((x) => { + const decided = q.draft?.decided[x.question]; + const basis = q.draft?.basis[x.question] ?? []; + return ( +
  1. +
    + {x.header && {x.header}} + {/* decided=false: 코드에 없는 결정 — 사람이 정해야 한다 */} + {open && decided === false && 코드에 없는 결정 · 사람이 정해야 함} + {open && decided === true && 에이전트 초안} +
    +

    {x.question}

    + + {open && canAnswer ? ( + <> + {x.options.length > 0 && ( +
      + {x.options.map((o) => { + const picked = x.multiSelect + ? values[x.question].split(", ").includes(o.label) + : values[x.question] === o.label; + return ( +
    • + +
    • + ); + })} +
    + )} +