feat: 프로젝트를 편집하고 활동을 남길 수 있게 한다

홈의 집중 카드가 제목만 보여 주고 "현재 목표"·"다음 작업" 칸이 비어 있었다.
프로젝트 페이지의 "활동" 도 늘 비어 있었다.

프로젝트를 만들 수는 있었지만 고칠 화면이 없었다. 그래서 이름과 slug 말고는 아무
값도 가질 수 없었고, 그 값을 읽는 공개 화면들은 빈칸을 그렸다 — 백엔드의
`updateProject` 는 처음부터 구현돼 있었고 채울 화면만 없었다.

`/studio/projects/:id` 를 연다. 프로젝트 필드 전부와 활동 목록을 한 화면에 둔다 —
프로젝트 밖의 활동은 존재하지 않고, 무엇이 공개 타임라인에 실리는지를 프로젝트 필드와
같은 자리에서 보는 편이 낫다.

이미 공개된 프로젝트는 저장한 뒤 투영도 함께 갱신한다. 투영은 게시할 때 세워지므로,
저장만 하면 공개 화면에는 예전 값이 남는다.

단계와 활동 유형의 선택지는 DB CHECK 제약과 같은 목록이다. 화면이 더 많은 값을
보여 주면 저장이 제약에서 터지고, 작성자는 왜 안 되는지 알 수 없다.

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — FE-GATE-009 는 설치된 라우트마다 수동
접근성 증거를 하나씩 요구하고 그 집합이 정확히 일치하지 않으면 거절한다. 아티팩트
기준선 132→133, 증거 개수 111→112, 게이트 형태 다이제스트 재계산(이전 상수 187dbd96…
을 이전 gates.json 에서 먼저 재현해 계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
DongHyeonka
2026-08-23 19:45:00 +09:00
co-authored by Claude Opus 5
parent 0eb3c86839
commit 16e5b9f4ec
22 changed files with 880 additions and 80 deletions
@@ -0,0 +1,445 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import type {
ProjectActivityResponse,
ProjectEditResponse,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { slugFromName } from "./slug-from-name.ts";
import { useStudio } from "../use-studio.ts";
/** `project_phase_check` 와 같은 목록이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
const PHASES = [
["RESEARCH", "조사"],
["DESIGN", "설계"],
["IMPLEMENTATION", "구현"],
["VERIFICATION", "검증"],
["MAINTENANCE", "유지"],
["PAUSED", "중단"],
["COMPLETED", "완료"],
] as const;
/** `project_activity_activity_type_check` 와 같은 목록이다. */
const ACTIVITY_TYPES = [
["MILESTONE_REACHED", "이정표 도달"],
["PHASE_CHANGED", "단계 전환"],
["QUESTION_OPENED", "질문 열림"],
["QUESTION_RESOLVED", "질문 해결"],
["DECISION_ACCEPTED", "결정 수락"],
["CASE_PUBLISHED", "Case 게시"],
["REFERENCE_PUBLISHED", "Reference 게시"],
["PROJECT_PAUSED", "프로젝트 중단"],
["PROJECT_RESUMED", "프로젝트 재개"],
] as const;
/** `<input type="datetime-local">` 은 초와 시간대가 없는 지역 시각을 쓴다. */
function toLocalInput(isoTimestamp: string): string {
const value = new Date(isoTimestamp);
if (Number.isNaN(value.getTime())) return "";
const pad = (part: number) => String(part).padStart(2, "0");
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(
value.getHours(),
)}:${pad(value.getMinutes())}`;
}
function fromLocalInput(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
/**
* 프로젝트 한 편의 편집 화면.
*
* <p>이 화면이 없는 동안 프로젝트는 이름과 slug 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음
* 작업"·"목적" 이 늘 비어 있었고, 홈의 focus 카드도 제목만 남았다 — 백엔드의 `updateProject` 는 처음부터
* 구현돼 있었고 채울 화면만 없었다.
*
* <p>활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개
* 타임라인에 실리는지를 프로젝트 필드와 같은 화면에서 보는 편이 낫다.
*/
export function ProjectEditor() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
const projectId = params.id;
const { managementGateway, setRequestAnnouncement } = useStudio();
const [project, setProject] = useState<ProjectEditResponse | null>(null);
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
const [draft, setDraft] = useState<Record<string, string>>({});
const [labels, setLabels] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
void Promise.all([
managementGateway.getProject(projectId),
managementGateway.listProjectActivities(projectId),
]).then(
([loaded, activityList]) => {
if (cancelled) return;
setProject(loaded);
setActivities(activityList);
setDraft({
name: loaded.name ?? "",
slug: loaded.slug ?? "",
oneLinePurpose: loaded.oneLinePurpose ?? "",
purposeMarkdown: loaded.purposeMarkdown ?? "",
boundaryMarkdown: loaded.boundaryMarkdown ?? "",
systemOverviewMarkdown: loaded.systemOverviewMarkdown ?? "",
phase: loaded.phase,
currentObjective: loaded.currentObjective ?? "",
nextStep: loaded.nextStep ?? "",
});
setLabels((loaded.technologyLabels ?? []).join(", "));
setError("");
},
() => {
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
},
);
return () => {
cancelled = true;
};
}, [managementGateway, projectId, generation]);
const set = (key: string, value: string) =>
setDraft((current) => ({ ...current, [key]: value }));
const save = async (event: FormEvent) => {
event.preventDefault();
if (pending || !project) return;
setPending(true);
setError("");
try {
/*
slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히
빈 값으로 저장되었고, 게시 화면에 가서야 "slug 가 없다" 는 말을 들었다.
*/
const slug = draft.slug?.trim() || slugFromName(draft.name ?? "");
const saved = await managementGateway.updateProject(project.id, {
expectedVersion: project.version,
name: (draft.name ?? "").trim(),
slug,
oneLinePurpose: draft.oneLinePurpose ?? "",
purposeMarkdown: draft.purposeMarkdown ?? "",
boundaryMarkdown: draft.boundaryMarkdown ?? "",
systemOverviewMarkdown: draft.systemOverviewMarkdown ?? "",
phase: draft.phase ?? "RESEARCH",
currentObjective: draft.currentObjective ?? "",
nextStep: draft.nextStep ?? "",
technologyLabels: labels
.split(",")
.map((label) => label.trim())
.filter(Boolean),
targetVisibility:
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
? project.targetVisibility
: "PRIVATE",
featuredOrder: project.featuredOrder,
});
setProject(saved);
setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`);
/*
이미 공개된 프로젝트라면 공개 투영도 함께 갱신해야 한다. 투영은 게시할 때 세워지므로,
저장만 하면 화면에는 예전 값이 남는다.
*/
if (saved.targetVisibility !== "PRIVATE") {
await managementGateway.publishProject(
saved.id,
saved.version,
saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC",
);
}
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "프로젝트를 저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const addActivity = async () => {
if (pending || !project) return;
setPending(true);
setError("");
try {
await managementGateway.createProjectActivity(project.id, {
expectedProjectVersion: project.version,
activityType: "MILESTONE_REACHED",
title: "새 활동",
summary: "",
visibility: "PUBLIC",
occurredAt: new Date().toISOString(),
});
setRequestAnnouncement("활동을 추가했습니다.");
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다."));
} finally {
setPending(false);
}
};
const saveActivity = async (
activity: ProjectActivityResponse,
changes: Partial<Pick<ProjectActivityResponse, "title" | "summary" | "visibility" | "occurredAt">>,
) => {
if (pending || !project) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateProjectActivity(project.id, activity.id, {
expectedVersion: activity.version,
title: changes.title ?? activity.title,
summary: changes.summary ?? activity.summary ?? "",
visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE",
occurredAt: changes.occurredAt ?? activity.occurredAt,
});
setActivities((current) =>
current.map((item) => (item.id === saved.id ? saved : item)),
);
setRequestAnnouncement("활동을 저장했습니다.");
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const removeActivity = async (activity: ProjectActivityResponse) => {
if (pending || !project) return;
setPending(true);
setError("");
try {
await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version);
setActivities((current) => current.filter((item) => item.id !== activity.id));
setRequestAnnouncement("활동을 삭제했습니다.");
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다."));
} finally {
setPending(false);
}
};
const field = (
key: string,
label: string,
hint?: string,
multiline = false,
) => (
<div className="studio-field" key={key}>
<label htmlFor={`project-${key}`}>{label}</label>
{multiline ? (
<textarea
className="studio-control studio-markdown"
id={`project-${key}`}
rows={5}
value={draft[key] ?? ""}
onChange={(event) => set(key, event.target.value)}
/>
) : (
<input
className="studio-control"
id={`project-${key}`}
type="text"
value={draft[key] ?? ""}
onChange={(event) => set(key, event.target.value)}
/>
)}
{hint ? <p className="studio-field-note">{hint}</p> : null}
</div>
);
return (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">PROJECT</p>
<h1>{project?.name ?? "프로젝트"}</h1>
<p>
&ldquo; &rdquo;,
.
</p>
</div>
<GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy">
</GuardedStudioLink>
</header>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!project && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{project ? (
<>
<form className="studio-home-focus-form" onSubmit={save}>
{field("name", "이름")}
{field("slug", "slug", "공개 주소가 됩니다. 비우면 이름에서 만듭니다.")}
<div className="studio-field">
<label htmlFor="project-phase"></label>
<select
className="studio-control"
id="project-phase"
value={draft.phase ?? "RESEARCH"}
onChange={(event) => set("phase", event.target.value)}
>
{PHASES.map(([value, label]) => (
<option key={value} value={value}>
{label} ({value})
</option>
))}
</select>
</div>
{field("oneLinePurpose", "한 줄 목적", "프로젝트 목록과 프로필 카드에 나옵니다.")}
{field("currentObjective", "현재 목표", "홈의 집중 카드에 나옵니다.")}
{field("nextStep", "다음 작업", "홈의 집중 카드에 나옵니다.")}
<div className="studio-field">
<label htmlFor="project-labels"> </label>
<input
className="studio-control"
id="project-labels"
type="text"
value={labels}
onChange={(event) => setLabels(event.target.value)}
/>
<p className="studio-field-note"> .</p>
</div>
{field("purposeMarkdown", "목적", undefined, true)}
{field("boundaryMarkdown", "경계", undefined, true)}
{field("systemOverviewMarkdown", "시스템 개요", undefined, true)}
<div className="studio-row-actions">
<button className="studio-primary-button" type="submit" disabled={pending}>
{pending ? "저장하는 중" : "저장"}
</button>
</div>
</form>
<section className="studio-work-section" aria-labelledby="project-activity-title">
<div className="studio-section-title">
<h2 id="project-activity-title"></h2>
<button
className="studio-text-action"
type="button"
disabled={pending}
onClick={() => void addActivity()}
>
</button>
</div>
<p className="studio-field-note">
.
</p>
{activities.length === 0 ? (
<p className="studio-empty-inline"> .</p>
) : (
<div className="studio-document-list">
{activities.map((activity) => (
<article className="studio-document-row" key={activity.id}>
<p className="studio-row-label">
{ACTIVITY_TYPES.find(([value]) => value === activity.activityType)?.[1] ??
activity.activityType}
</p>
<div className="studio-document-title">
<div className="studio-field">
<label htmlFor={`activity-title-${activity.id}`}></label>
<input
className="studio-control"
id={`activity-title-${activity.id}`}
type="text"
defaultValue={activity.title}
onBlur={(event) =>
event.target.value !== activity.title &&
void saveActivity(activity, { title: event.target.value })
}
/>
</div>
<div className="studio-field">
<label htmlFor={`activity-summary-${activity.id}`}></label>
<input
className="studio-control"
id={`activity-summary-${activity.id}`}
type="text"
defaultValue={activity.summary ?? ""}
onBlur={(event) =>
event.target.value !== (activity.summary ?? "") &&
void saveActivity(activity, { summary: event.target.value })
}
/>
</div>
</div>
<dl>
<div>
<dt></dt>
<dd>
<input
className="studio-control"
type="datetime-local"
aria-label="활동 일시"
defaultValue={toLocalInput(activity.occurredAt)}
onBlur={(event) =>
void saveActivity(activity, {
occurredAt: fromLocalInput(event.target.value),
})
}
/>
</dd>
</div>
<div>
<dt></dt>
<dd>
<select
className="studio-control"
aria-label="활동 노출"
value={activity.visibility}
onChange={(event) =>
void saveActivity(activity, { visibility: event.target.value })
}
>
<option value="PUBLIC"></option>
<option value="PRIVATE"></option>
</select>
</dd>
</div>
</dl>
<div className="studio-row-actions">
<button
className="studio-secondary-button"
type="button"
disabled={pending || activity.origin !== "MANUAL"}
title={
activity.origin === "MANUAL"
? undefined
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
}
onClick={() => void removeActivity(activity)}
>
</button>
</div>
</article>
))}
</div>
)}
</section>
</>
) : null}
</div>
);
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { slugFromName } from "./slug-from-name.ts";
import { useStudio } from "../use-studio.ts";
@@ -300,7 +301,11 @@ export function TaxonomyManager() {
<article className="studio-document-row" key={project.id}>
<p className="studio-row-label">PROJECT</p>
<div className="studio-document-title">
<h2>{project.name}</h2>
<h2>
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
{project.name}
</GuardedStudioLink>
</h2>
<p>{project.currentObjective ?? "목표 미지정"}</p>
</div>
<dl>
@@ -0,0 +1,5 @@
import { ProjectEditor } from "../components/project-editor.tsx";
export function ProjectEditPage() {
return <ProjectEditor />;
}