fix: show the reason the server gave for a failed action

Every failure in the management screens printed a guess. Deleting a working
copy said "it may be published, or something may reference it, or someone may
have edited it first" — three maybes, while the server had answered with
exactly one: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다". A version
conflict read as "in use" because the same sentence covered both, and an author
watching some deletions succeed and others fail had no way to tell them apart.

The gateway now carries the server's client-safe message and the screens show
it. The canned sentences remain only as a fallback for a failure that never
reached the server.

The topic status label said "사용 중" for every active topic, including one
created seconds earlier that nothing references. Next to a refusal about
records that use a topic, the two read as the same statement. It says "활성"
now, which is what the status means.

Publishing also stops demanding a finished document — the mock validator moves
with the real one, so what an author sees against fixtures matches production.
This commit is contained in:
DongHyeonka
2026-08-21 19:29:04 +09:00
parent 7345500ef3
commit 1801414592
39 changed files with 1234 additions and 69 deletions
@@ -10,6 +10,7 @@ import type {
TopicEdit,
} from "../../contracts/management/contract.ts";
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
import { ManagementGatewayError } from "../../application/ports/management-gateway-error.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
export type { ManagementGateway };
@@ -23,17 +24,10 @@ const ROUTE_ID = "TECH_LOG_STUDIO";
* Studio 쪽 상태 처리를 그대로 쓴다. 여기서 Result 로 감싸면 이 표면만 다른 규약이 된다.
*/
export class ManagementGatewayError extends Error {
readonly operationId: string;
readonly code: string;
constructor(operationId: string, code: string) {
super(`${operationId}: ${code}`);
this.name = "ManagementGatewayError";
this.operationId = operationId;
this.code = code;
}
}
export {
ManagementGatewayError,
managementFailureMessage,
} from "../../application/ports/management-gateway-error.ts";
export function createHttpManagementGateway(
deps: Readonly<{ operations: StudioOperationExecutor }>,
@@ -46,7 +40,11 @@ export function createHttpManagementGateway(
// the code under `error` — reading `problem.code` found nothing and every
// failure surfaced as the literal "PROBLEM", matching no i18n key.
const body = outcome.problem as
| Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>
| Readonly<{
code?: unknown;
detail?: unknown;
error?: Readonly<{ code?: unknown; message?: unknown }>;
}>
| null;
const code =
typeof body?.code === "string"
@@ -54,9 +52,15 @@ export function createHttpManagementGateway(
: typeof body?.error?.code === "string"
? body.error.code
: "PROBLEM";
throw new ManagementGatewayError(operationId, code);
const detail =
typeof body?.error?.message === "string"
? body.error.message
: typeof body?.detail === "string"
? body.detail
: "";
throw new ManagementGatewayError(operationId, code, detail);
}
throw new ManagementGatewayError(operationId, outcome.kind);
throw new ManagementGatewayError(operationId, outcome.kind, "");
}
return Object.freeze({
@@ -170,16 +170,16 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
const has = (id: string | null, type: CatalogEntry["type"]) => Boolean(id && dependencies.catalog.some((entry) => entry.id === id && entry.type === type));
if (blank(document.title)) error("TITLE_REQUIRED", "/title", "제목을 입력하세요.");
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
if (blank(document.summary)) error("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
if (!has(document.topicId, "TOPIC")) error("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
if (blank(document.summary)) warning("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
if (!has(document.topicId, "TOPIC")) warning("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
if (!document.projectId) {
if (document.kind === "PROJECT_DECISION") error("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
if (document.kind === "PROJECT_DECISION") warning("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
else warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다.");
} else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
if (document.kind === "CASE") {
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
if (blank(document.problem)) warning("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) warning("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
if (blank(document.bodyMarkdown)) warning("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
else try {
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
// The key gate is the preview projection's gate, verbatim: a resolvable
@@ -202,20 +202,20 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
}
}
} catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); }
if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
if (!document.lastVerifiedOn) warning("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
} else if (document.kind === "REFERENCE") {
if (blank(document.purpose)) error("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) error("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) error("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) error("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
if (blank(document.purpose)) warning("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) warning("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) warning("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) warning("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
} else if (document.kind === "QUESTION") {
if (!document.questionStatus) error("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) error("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) error("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
if (!document.questionStatus) warning("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) warning("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) warning("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
if (document.questionStatus === "RESOLVED") { if (!document.resolution) error("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) error("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) error("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
if (document.questionStatus === "RESOLVED") { if (!document.resolution) warning("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) warning("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) warning("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
} else {
if (!document.decisionStatus) error("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
if (!document.decidedOn) error("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
if (blank(document.statement)) error("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
if (blank(document.rationale)) error("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
if (!document.consequences.length) error("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
if (!document.decisionStatus) warning("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
if (!document.decidedOn) warning("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
if (blank(document.statement)) warning("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
if (blank(document.rationale)) warning("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
if (!document.consequences.length) warning("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
if (!document.relations.length) error("DECISION_EVIDENCE_REQUIRED", "/relations", "근거 기록이 하나 이상 필요합니다.");
}
const validatedAt = dependencies.now.toISOString();
@@ -0,0 +1,32 @@
/**
* 관리 표면의 실패.
*
* <p>어댑터가 아니라 포트 계층에 두는 이유는 화면이 이것을 읽어야 하기 때문이다 — `presentation`
* 은 `adapters` 를 보지 않는다 (`feature-presentation-does-not-know-outbound-adapters`).
* Studio 쪽 `studio-gateway-error.ts` 가 같은 이유로 같은 자리에 있다.
*/
export class ManagementGatewayError extends Error {
readonly operationId: string;
readonly code: string;
/**
* 서버가 준, 사람이 읽을 수 있는 이유.
*
* <p>이것이 없어서 화면은 실패할 때마다 자기가 지어낸 문구를 보여 줬다 — "게시 중이거나,
* 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다" 같은 추측 셋. 서버는 정확히
* 무엇인지 알고 그것을 보내 주는데도 그랬고, 그래서 버전 충돌도 "사용 중" 으로 읽혔다.
*/
readonly detail: string;
constructor(operationId: string, code: string, detail: string) {
super(`${operationId}: ${code}`);
this.name = "ManagementGatewayError";
this.operationId = operationId;
this.code = code;
this.detail = detail;
}
}
/** 실패에서 서버가 준 문구를 꺼낸다. 없으면 부른 쪽이 준 기본값을 쓴다. */
export function managementFailureMessage(error: unknown, fallback: string): string {
return error instanceof ManagementGatewayError && error.detail ? error.detail : fallback;
}
@@ -2,7 +2,7 @@
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
"sourceRevision": "65a04fc",
"sourceRevision": "b195b29",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
@@ -2,7 +2,7 @@
"packageId": "@tech-log/public-contract",
"version": "2.0.0",
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
"sourceRevision": "65a04fc",
"sourceRevision": "b195b29",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
"sourceRevision": "65a04fc",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
"sourceRevision": "b195b29",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -871,7 +871,7 @@ components:
required: [id, text, order]
properties:
id: { type: string, format: uuid }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
ReferenceRule:
type: object
@@ -1242,7 +1242,7 @@ components:
kind: { $ref: "#/components/schemas/RecordKind" }
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
title: { type: string, minLength: 1, maxLength: 120 }
summary: { type: string, minLength: 1, maxLength: 300 }
summary: { type: string, maxLength: 300 }
publicPath: { type: string, minLength: 1, maxLength: 500 }
topic: { $ref: "#/components/schemas/DisplayTarget" }
project:
@@ -1257,7 +1257,7 @@ components:
required: [type, text]
properties:
type: { type: string, enum: [TEXT] }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
InlineContainer:
type: object
required: [type, children]
@@ -1483,8 +1483,8 @@ components:
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
properties:
kind: { type: string, enum: [CASE] }
problem: { type: string, minLength: 1, maxLength: 100000 }
conclusion: { type: string, minLength: 1, maxLength: 100000 }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
reproduction: { type: string, maxLength: 100000 }
lastVerifiedOn: { type: string, format: date }
@@ -1497,7 +1497,7 @@ components:
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, minLength: 1, maxLength: 100000 }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -1508,7 +1508,7 @@ components:
additionalProperties: false
required: [summary, evidenceTarget, linkLabel]
properties:
summary: { type: string, minLength: 1, maxLength: 100000 }
summary: { type: string, maxLength: 100000 }
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
linkLabel: { type: string, minLength: 1, maxLength: 120 }
QuestionPublicRenderModel:
@@ -1528,7 +1528,7 @@ components:
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
nextValidation: { type: string, minLength: 1, maxLength: 100000 }
nextValidation: { type: string, maxLength: 100000 }
resolution:
oneOf:
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
@@ -1543,8 +1543,8 @@ components:
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
statement: { type: string, minLength: 1, maxLength: 100000 }
rationale: { type: string, minLength: 1, maxLength: 100000 }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
PublicRenderModel:
oneOf:
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
import type { ListDocumentsQuery } from "../../../application/ports/studio-gateway.ts";
import type { DocumentPage } from "../../../contracts/studio/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
@@ -98,10 +99,10 @@ export function DocumentList() {
? { ...current, items: current.items.filter((row) => row.id !== item.id) }
: current,
);
} catch {
setDeleteError(
"삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.",
);
} catch (error) {
// 서버는 무엇이 막았는지 정확히 알고 그것을 보내 준다. 예전에는 그 문구를 버리고 추측 셋을
// 늘어놓아, 버전 충돌도 "사용 중" 으로 읽혔다.
setDeleteError(managementFailureMessage(error, "삭제하지 못했습니다."));
} finally {
setDeletingId(null);
}
@@ -5,6 +5,7 @@ import type {
ReleaseIndexItem,
ReleaseUpdateRequest,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useStudio } from "../use-studio.ts";
/**
@@ -150,8 +151,8 @@ export function ReleaseManager() {
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
setSelectedId(created.id);
reload();
} catch {
setError("릴리즈를 만들지 못했습니다.");
} catch (error) {
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
} finally {
setPending(false);
}
@@ -180,8 +181,8 @@ export function ReleaseManager() {
setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload();
} catch {
setError("저장하지 못했습니다. 같은 버전이 이미 있거나 다른 곳에서 먼저 수정되었을 수 있습니다.");
} catch (error) {
setError(managementFailureMessage(error, "저장하지 못했습니다."));
} finally {
setPending(false);
}
@@ -214,8 +215,8 @@ export function ReleaseManager() {
await managementGateway.archiveRelease(selectedId, draft.expectedVersion);
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
reload();
} catch {
setError("공개에서 내리지 못했습니다.");
} catch (error) {
setError(managementFailureMessage(error, "공개에서 내리지 못했습니다."));
} finally {
setPending(false);
}
@@ -230,8 +231,8 @@ export function ReleaseManager() {
if (selectedId === release.id) setSelectedId(null);
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("삭제하지 못했습니다. 공개된 릴리즈는 삭제 대신 공개에서 내려야 합니다.");
} catch (error) {
setError(managementFailureMessage(error, "삭제하지 못했습니다."));
} finally {
setPending(false);
}
@@ -1,6 +1,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 { slugFromName } from "./slug-from-name.ts";
import { useStudio } from "../use-studio.ts";
@@ -75,8 +76,8 @@ export function TaxonomyManager() {
setTopicSlug("");
setRequestAnnouncement(`주제 ${name} 을(를) 만들었습니다.`);
reload();
} catch {
setError("주제를 만들지 못했습니다. 같은 이름이나 slug 가 이미 있을 수 있습니다.");
} catch (error) {
setError(managementFailureMessage(error, "주제를 만들지 못했습니다."));
} finally {
setPending(false);
}
@@ -97,8 +98,8 @@ export function TaxonomyManager() {
setProjectTitle("");
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
reload();
} catch {
setError("프로젝트를 만들지 못했습니다.");
} catch (error) {
setError(managementFailureMessage(error, "프로젝트를 만들지 못했습니다."));
} finally {
setPending(false);
}
@@ -112,8 +113,8 @@ export function TaxonomyManager() {
await managementGateway.deleteTopic(topic.id, topic.version);
setRequestAnnouncement(`주제 ${topic.name} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("주제를 삭제하지 못했습니다. 이 주제를 쓰는 기록이 있을 수 있습니다.");
} catch (error) {
setError(managementFailureMessage(error, "주제를 삭제하지 못했습니다."));
} finally {
setPending(false);
}
@@ -127,8 +128,8 @@ export function TaxonomyManager() {
await managementGateway.deleteProject(project.id, project.version);
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("프로젝트를 삭제하지 못했습니다. 연결된 기록이 있을 수 있습니다.");
} catch (error) {
setError(managementFailureMessage(error, "프로젝트를 삭제하지 못했습니다."));
} finally {
setPending(false);
}
@@ -210,7 +211,12 @@ export function TaxonomyManager() {
<dl>
<div>
<dt></dt>
<dd>{topic.status === "ARCHIVED" ? "보관" : "사용 중"}</dd>
{/*
`ACTIVE` 를 "사용 중" 이라 적었더니, 방금 만들어 아무도 쓰지 않는 주제까지
"사용 중" 으로 보였다. 그 상태에서 삭제가 거절되면 작성자는 둘을 같은
말로 읽는다 — 실제로는 서로 다른 이야기다.
*/}
<dd>{topic.status === "ARCHIVED" ? "보관" : "활성"}</dd>
</div>
</dl>
<button