From 0355b644a0aff6a68cb2d255ac9dba265325ece8 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Mon, 17 Aug 2026 17:23:42 +0900 Subject: [PATCH] feat: add TechLog project decision authoring --- .../adapters/mock/validate-working-copy.ts | 33 +++- .../application/ports/studio-gateway.ts | 2 +- .../tech-log/contracts/studio/generated.ts | 59 +++++- .../contracts/studio/studio-api.openapi.yaml | 47 ++++- .../project-public-render-model.ts | 24 ++- .../public-render/public-record-renderer.tsx | 75 +++++++- .../components/common-document-fields.tsx | 2 +- .../studio/components/document-editor.tsx | 5 +- .../studio/components/document-list.tsx | 4 +- .../components/document-status-rail.tsx | 9 +- .../studio/components/new-document-form.tsx | 33 +++- .../components/project-decision-fields.tsx | 78 ++++++++ .../studio/components/publication-list.tsx | 1 + .../studio/components/relation-editor.tsx | 13 +- .../studio/components/studio-dashboard.tsx | 1 + .../studio-decision-authoring.test.tsx | 179 ++++++++++++++++++ 16 files changed, 532 insertions(+), 33 deletions(-) create mode 100644 src/features/tech-log/presentation/studio/components/project-decision-fields.tsx create mode 100644 tests/features/tech-log/studio-decision-authoring.test.tsx diff --git a/src/features/tech-log/adapters/mock/validate-working-copy.ts b/src/features/tech-log/adapters/mock/validate-working-copy.ts index 09759bd..b429777 100644 --- a/src/features/tech-log/adapters/mock/validate-working-copy.ts +++ b/src/features/tech-log/adapters/mock/validate-working-copy.ts @@ -30,16 +30,19 @@ function runtimeShapeErrors(value: unknown): FieldError[] { ? ["purpose", "rules", "applyWhen", "exceptions", "examples", "verifiedOn"] : kind === "QUESTION" ? ["questionStatus", "facts", "assumptions", "unknowns", "constraints", "options", "nextValidation", "resolution"] + : kind === "PROJECT_DECISION" + ? ["decisionStatus", "decidedOn", "statement", "rationale", "consequences"] : []; - if (!(["CASE", "REFERENCE", "QUESTION"] as unknown[]).includes(kind)) errors.push({ path: "/kind", message: "Invalid record kind." }); + if (!(["CASE", "REFERENCE", "QUESTION", "PROJECT_DECISION"] as unknown[]).includes(kind)) errors.push({ path: "/kind", message: "Invalid record kind." }); const allowed = new Set([...base, ...branch]); for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push({ path: `/${escape(key)}`, message: "Additional properties are not allowed." }); for (const key of [...base, ...branch]) if (!Object.hasOwn(value, key)) errors.push({ path: `/${key}`, message: "Field is required." }); - const stringFields = ["title", "slug", "summary", ...(kind === "CASE" ? ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] : []), ...(kind === "REFERENCE" ? ["purpose"] : []), ...(kind === "QUESTION" ? ["nextValidation"] : [])]; + const stringFields = ["title", "slug", "summary", ...(kind === "CASE" ? ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] : []), ...(kind === "REFERENCE" ? ["purpose"] : []), ...(kind === "QUESTION" ? ["nextValidation"] : []), ...(kind === "PROJECT_DECISION" ? ["statement", "rationale"] : [])]; for (const key of stringFields) if (Object.hasOwn(value, key) && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a string." }); for (const key of ["topicId", "projectId"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a UUID or null." }); - for (const key of ["lastVerifiedOn", "verifiedOn"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a date or null." }); + for (const key of ["lastVerifiedOn", "verifiedOn", "decidedOn"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a date or null." }); if (kind === "QUESTION" && Object.hasOwn(value, "questionStatus") && ![null, "OPEN", "RESOLVED"].includes(value.questionStatus as never)) errors.push({ path: "/questionStatus", message: "Invalid question status." }); + if (kind === "PROJECT_DECISION" && Object.hasOwn(value, "decisionStatus") && ![null, "PROPOSED", "ADOPTED"].includes(value.decisionStatus as never)) errors.push({ path: "/decisionStatus", message: "Invalid decision status." }); const checkItem = (item: unknown, path: string, fields: Record) => { if (!object(item)) { errors.push({ path, message: "Must be an object." }); return; } @@ -66,6 +69,9 @@ function runtimeShapeErrors(value: unknown): FieldError[] { checkArray("options", { id: "string", title: "string", description: "string", order: "number" }); if (Object.hasOwn(value, "resolution") && value.resolution !== null) checkItem(value.resolution, "/resolution", { summary: "string", evidenceTargetId: "nullable", linkLabel: "string" }); } + if (kind === "PROJECT_DECISION") { + checkArray("consequences", { id: "string", text: "string", order: "number" }); + } return errors.filter((entry, index) => errors.findIndex(({ path }) => path === entry.path) === index); } @@ -121,11 +127,16 @@ export function validateWorkingCopyInputStructure(input: WorkingCopyInput): Fiel ordered(errors, "/rules", input.rules, [["title", 120, 1], ["body", 100_000, 1]]); for (const [path, items] of [["/applyWhen", input.applyWhen], ["/exceptions", input.exceptions], ["/examples", input.examples]] as const) ordered(errors, path, items, [["text", 100_000, 1]]); date(errors, "/verifiedOn", input.verifiedOn); - } else { + } else if (input.kind === "QUESTION") { for (const [path, items] of [["/facts", input.facts], ["/assumptions", input.assumptions], ["/unknowns", input.unknowns], ["/constraints", input.constraints]] as const) ordered(errors, path, items, [["text", 100_000, 1]]); ordered(errors, "/options", input.options, [["title", 120, 1], ["description", 100_000, 0]]); text(errors, "/nextValidation", input.nextValidation, 100_000); if (input.resolution) { text(errors, "/resolution/summary", input.resolution.summary, 100_000); uuid(errors, "/resolution/evidenceTargetId", input.resolution.evidenceTargetId, true); text(errors, "/resolution/linkLabel", input.resolution.linkLabel, 120); } + } else { + text(errors, "/statement", input.statement, 100_000); + text(errors, "/rationale", input.rationale, 100_000); + date(errors, "/decidedOn", input.decidedOn); + ordered(errors, "/consequences", input.consequences, [["text", 100_000, 1]]); } return errors; } @@ -142,7 +153,10 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat 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 (!document.projectId) warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다."); else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다."); + if (!document.projectId) { + if (document.kind === "PROJECT_DECISION") error("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", "결론을 입력하세요."); @@ -156,11 +170,18 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat 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일이 지났습니다."); } 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", "예시를 권장합니다."); - } else { + } 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 === "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.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.relations.length) error("DECISION_EVIDENCE_REQUIRED", "/relations", "근거 기록이 하나 이상 필요합니다."); } const validatedAt = dependencies.now.toISOString(); return { validationId: dependencies.validationId, documentId: document.id, validatedVersion: document.version, diff --git a/src/features/tech-log/application/ports/studio-gateway.ts b/src/features/tech-log/application/ports/studio-gateway.ts index af37524..9f54ee9 100644 --- a/src/features/tech-log/application/ports/studio-gateway.ts +++ b/src/features/tech-log/application/ports/studio-gateway.ts @@ -8,7 +8,7 @@ import type { export type RequestOptions = { signal?: AbortSignal }; export type IdempotentOptions = RequestOptions & { idempotencyKey: string }; export type ListDocumentsQuery = { - q?: string; kind?: "CASE" | "REFERENCE" | "QUESTION"; + q?: string; kind?: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_DECISION"; publicationStatus?: "NEVER_PUBLISHED" | "PUBLISHED" | "UNPUBLISHED"; nextAction?: "CONTINUE_EDITING" | "VALIDATE" | "FIX_VALIDATION" | "CREATE_PREVIEW" | "PUBLISH" | "NONE"; projectId?: string; sort?: "UPDATED_DESC" | "UPDATED_ASC" | "TITLE_ASC"; diff --git a/src/features/tech-log/contracts/studio/generated.ts b/src/features/tech-log/contracts/studio/generated.ts index b419ce4..7c579bb 100644 --- a/src/features/tech-log/contracts/studio/generated.ts +++ b/src/features/tech-log/contracts/studio/generated.ts @@ -197,7 +197,7 @@ export type webhooks = Record; export interface components { schemas: { /** @enum {string} */ - RecordKind: "CASE" | "REFERENCE" | "QUESTION"; + RecordKind: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_DECISION"; /** @enum {string} */ PublicationStatus: "NEVER_PUBLISHED" | "PUBLISHED" | "UNPUBLISHED"; /** @enum {string} */ @@ -308,7 +308,24 @@ export interface components { */ kind: "QUESTION"; }; - WorkingCopyInput: components["schemas"]["CaseInput"] | components["schemas"]["ReferenceInput"] | components["schemas"]["QuestionInput"]; + ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & { + /** @constant */ + kind: "PROJECT_DECISION"; + /** @enum {string|null} */ + decisionStatus: "PROPOSED" | "ADOPTED" | null; + /** Format: date */ + decidedOn: string | null; + statement: string; + rationale: string; + consequences: components["schemas"]["OrderedText"][]; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "PROJECT_DECISION"; + }; + WorkingCopyInput: components["schemas"]["CaseInput"] | components["schemas"]["ReferenceInput"] | components["schemas"]["QuestionInput"] | components["schemas"]["ProjectDecisionInput"]; WorkingCopyBase: components["schemas"]["WorkingCopyInputBase"] & { /** Format: uuid */ id: string; @@ -370,7 +387,24 @@ export interface components { */ kind: "QUESTION"; }; - WorkingCopy: components["schemas"]["CaseWorkingCopy"] | components["schemas"]["ReferenceWorkingCopy"] | components["schemas"]["QuestionWorkingCopy"]; + ProjectDecisionWorkingCopy: components["schemas"]["WorkingCopyBase"] & { + /** @constant */ + kind: "PROJECT_DECISION"; + /** @enum {string|null} */ + decisionStatus: "PROPOSED" | "ADOPTED" | null; + /** Format: date */ + decidedOn: string | null; + statement: string; + rationale: string; + consequences: components["schemas"]["OrderedText"][]; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "PROJECT_DECISION"; + }; + WorkingCopy: components["schemas"]["CaseWorkingCopy"] | components["schemas"]["ReferenceWorkingCopy"] | components["schemas"]["QuestionWorkingCopy"] | components["schemas"]["ProjectDecisionWorkingCopy"]; CreateDocumentInput: components["schemas"]["WorkingCopyInput"]; SaveDocumentCommand: { expectedVersion: number; @@ -686,7 +720,24 @@ export interface components { */ kind: "QUESTION"; }; - PublicRenderModel: components["schemas"]["CasePublicRenderModel"] | components["schemas"]["ReferencePublicRenderModel"] | components["schemas"]["QuestionPublicRenderModel"]; + ProjectDecisionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { + /** @constant */ + kind: "PROJECT_DECISION"; + /** @enum {string} */ + status: "PROPOSED" | "ADOPTED"; + /** Format: date */ + decidedOn: string; + statement: string; + rationale: string; + consequences: components["schemas"]["OrderedText"][]; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "PROJECT_DECISION"; + }; + PublicRenderModel: components["schemas"]["CasePublicRenderModel"] | components["schemas"]["ReferencePublicRenderModel"] | components["schemas"]["QuestionPublicRenderModel"] | components["schemas"]["ProjectDecisionPublicRenderModel"]; PublicPreview: { /** Format: uuid */ previewId: string; diff --git a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml index 3e02f35..508b0d5 100644 --- a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml +++ b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml @@ -233,7 +233,7 @@ components: RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } schemas: - RecordKind: { type: string, enum: [CASE, REFERENCE, QUESTION] } + RecordKind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT_DECISION] } PublicationStatus: { type: string, enum: [NEVER_PUBLISHED, PUBLISHED, UNPUBLISHED] } NextAction: { type: string, enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE] } RelationInput: @@ -349,17 +349,32 @@ components: oneOf: - { $ref: "#/components/schemas/QuestionResolution" } - { type: "null" } + ProjectDecisionInput: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyInputBase" } + - type: object + required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, const: PROJECT_DECISION } + decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] } + decidedOn: { type: [string, "null"], format: date } + statement: { type: string, maxLength: 100000 } + rationale: { type: string, maxLength: 100000 } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } WorkingCopyInput: oneOf: - { $ref: "#/components/schemas/CaseInput" } - { $ref: "#/components/schemas/ReferenceInput" } - { $ref: "#/components/schemas/QuestionInput" } + - { $ref: "#/components/schemas/ProjectDecisionInput" } discriminator: propertyName: kind mapping: CASE: "#/components/schemas/CaseInput" REFERENCE: "#/components/schemas/ReferenceInput" QUESTION: "#/components/schemas/QuestionInput" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionInput" WorkingCopyBase: allOf: - { $ref: "#/components/schemas/WorkingCopyInputBase" } @@ -417,17 +432,32 @@ components: oneOf: - { $ref: "#/components/schemas/QuestionResolution" } - { type: "null" } + ProjectDecisionWorkingCopy: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/WorkingCopyBase" } + - type: object + required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, const: PROJECT_DECISION } + decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] } + decidedOn: { type: [string, "null"], format: date } + statement: { type: string, maxLength: 100000 } + rationale: { type: string, maxLength: 100000 } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } WorkingCopy: oneOf: - { $ref: "#/components/schemas/CaseWorkingCopy" } - { $ref: "#/components/schemas/ReferenceWorkingCopy" } - { $ref: "#/components/schemas/QuestionWorkingCopy" } + - { $ref: "#/components/schemas/ProjectDecisionWorkingCopy" } discriminator: propertyName: kind mapping: CASE: "#/components/schemas/CaseWorkingCopy" REFERENCE: "#/components/schemas/ReferenceWorkingCopy" QUESTION: "#/components/schemas/QuestionWorkingCopy" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionWorkingCopy" CreateDocumentInput: { $ref: "#/components/schemas/WorkingCopyInput" } SaveDocumentCommand: type: object @@ -778,17 +808,32 @@ components: oneOf: - { $ref: "#/components/schemas/ResolvedQuestionResolution" } - { type: "null" } + ProjectDecisionPublicRenderModel: + unevaluatedProperties: false + allOf: + - { $ref: "#/components/schemas/PublicRenderModelBase" } + - type: object + required: [kind, status, decidedOn, statement, rationale, consequences] + properties: + kind: { type: string, const: 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 } + consequences: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } PublicRenderModel: oneOf: - { $ref: "#/components/schemas/CasePublicRenderModel" } - { $ref: "#/components/schemas/ReferencePublicRenderModel" } - { $ref: "#/components/schemas/QuestionPublicRenderModel" } + - { $ref: "#/components/schemas/ProjectDecisionPublicRenderModel" } discriminator: propertyName: kind mapping: CASE: "#/components/schemas/CasePublicRenderModel" REFERENCE: "#/components/schemas/ReferencePublicRenderModel" QUESTION: "#/components/schemas/QuestionPublicRenderModel" + PROJECT_DECISION: "#/components/schemas/ProjectDecisionPublicRenderModel" PublicPreview: type: object additionalProperties: false diff --git a/src/features/tech-log/domain/content-format/project-public-render-model.ts b/src/features/tech-log/domain/content-format/project-public-render-model.ts index ced551c..85c1c0f 100644 --- a/src/features/tech-log/domain/content-format/project-public-render-model.ts +++ b/src/features/tech-log/domain/content-format/project-public-render-model.ts @@ -72,7 +72,11 @@ function renderContext( }; } -function publicPath(input: WorkingCopyInput) { +function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) { + if (input.kind === "PROJECT_DECISION") { + if (!project?.publicPath) fail("PROJECT public path is required"); + return `${project.publicPath}/decisions#${input.slug}`; + } const prefix = input.kind === "CASE" ? "cases" @@ -124,13 +128,16 @@ export function projectWorkingCopy( const topic = catalogEntry(catalog, input.topicId, "TOPIC", true); const project = catalogEntry(catalog, input.projectId, "PROJECT", false); if (!topic) fail("TOPIC catalog entry is required"); + if (input.kind === "PROJECT_DECISION" && !project) { + fail("PROJECT catalog entry is required"); + } const base = { kind: input.kind, slug: input.slug, title: input.title, summary: input.summary, - publicPath: publicPath(input), + publicPath: publicPath(input, project), topic: displayTarget(topic), project: project ? displayTarget(project) : null, relations: resolvedRelations(input, catalog), @@ -211,5 +218,18 @@ export function projectWorkingCopy( resolution, }; } + + case "PROJECT_DECISION": + if (!input.decisionStatus) fail("Decision status is required"); + if (!input.decidedOn) fail("Decision date is required"); + return { + ...base, + kind: "PROJECT_DECISION", + status: input.decisionStatus, + decidedOn: input.decidedOn, + statement: input.statement, + rationale: input.rationale, + consequences: ordered(input.consequences), + }; } } diff --git a/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx b/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx index 46cfdb1..8d5d3e6 100644 --- a/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx +++ b/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx @@ -21,6 +21,8 @@ type ReferencePublicRenderModel = components["schemas"]["ReferencePublicRenderModel"]; type QuestionPublicRenderModel = components["schemas"]["QuestionPublicRenderModel"]; +type ProjectDecisionPublicRenderModel = + components["schemas"]["ProjectDecisionPublicRenderModel"]; type RenderDependencies = { resolveEvidenceAsset: ResolveEvidenceAsset; @@ -31,6 +33,7 @@ const kindLabels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", + PROJECT_DECISION: "Decision", } as const; function assertNever(value: never): never { @@ -52,7 +55,8 @@ function publishedLabel( function explorePath(kind: PublicRenderModelBase["kind"]) { if (kind === "CASE") return "/explore/cases"; if (kind === "REFERENCE") return "/explore/references"; - return "/explore/questions"; + if (kind === "QUESTION") return "/explore/questions"; + return "/projects"; } function TargetLink({ @@ -438,6 +442,71 @@ function QuestionDocument({ ); } +function ProjectDecisionDocument({ + model, + embedded, +}: { + model: ProjectDecisionPublicRenderModel; + embedded: boolean; +}) { + const Root = embedded ? "div" : "main"; + const evidence = publicRelations(model.relations); + return ( + + +
    +
  1. +
    +
    +
    + {model.status} + +
    +

    {model.title}

    +

    {model.statement}

    +
    +
    +

    판단 이유

    +

    {model.rationale}

    +
    +
    +

    영향

    + +
    +
    +

    근거 기록

    + {evidence.length ? ( +
      + {evidence.map((item) => ( +
    • + {item.title} +
    • + ))} +
    + ) : ( +

    연결한 근거 기록이 없습니다.

    + )} +
    +
    +
  2. +
+
+ ); +} + export function PublicRecordRenderer({ model, embedded = false, @@ -481,6 +550,10 @@ export function PublicRecordRenderer({ resolvePublishedLabel={resolvePublishedLabel} /> ); + case "PROJECT_DECISION": + return ( + + ); default: return assertNever(model); } diff --git a/src/features/tech-log/presentation/studio/components/common-document-fields.tsx b/src/features/tech-log/presentation/studio/components/common-document-fields.tsx index 2640cc7..1474e88 100644 --- a/src/features/tech-log/presentation/studio/components/common-document-fields.tsx +++ b/src/features/tech-log/presentation/studio/components/common-document-fields.tsx @@ -27,7 +27,7 @@ export function CommonDocumentFields({ - onUpdate({ relations: next })} /> + onUpdate({ relations: next })} /> ); } diff --git a/src/features/tech-log/presentation/studio/components/document-editor.tsx b/src/features/tech-log/presentation/studio/components/document-editor.tsx index ce21d74..734fea5 100644 --- a/src/features/tech-log/presentation/studio/components/document-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/document-editor.tsx @@ -6,6 +6,7 @@ import { CaseFields } from "./case-fields.tsx"; import { CommonDocumentFields } from "./common-document-fields.tsx"; import { DocumentStatusRail } from "./document-status-rail.tsx"; import { InstantPreview } from "./instant-preview.tsx"; +import { ProjectDecisionFields } from "./project-decision-fields.tsx"; import { QuestionFields } from "./question-fields.tsx"; import { ReferenceFields } from "./reference-fields.tsx"; @@ -52,7 +53,9 @@ export function DocumentEditor({ controller, catalog }: { controller: DocumentEd ? : controller.draft.kind === "REFERENCE" ? - : } + : controller.draft.kind === "QUESTION" + ? + : } @@ -126,6 +127,7 @@ export function DocumentList() { +