feat: add TechLog project decision authoring
This commit is contained in:
@@ -27,7 +27,7 @@ export function CommonDocumentFields({
|
||||
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
</div>
|
||||
<RelationEditor relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
||||
<RelationEditor evidence={draft.kind === "PROJECT_DECISION"} relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? <CaseFields draft={controller.draft} onChange={controller.replace} />
|
||||
: controller.draft.kind === "REFERENCE"
|
||||
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
|
||||
: <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />}
|
||||
: controller.draft.kind === "QUESTION"
|
||||
? <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />
|
||||
: <ProjectDecisionFields draft={controller.draft} onChange={controller.replace} />}
|
||||
</div>
|
||||
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
||||
<InstantPreview draft={controller.draft} catalog={catalog} />
|
||||
|
||||
@@ -9,6 +9,7 @@ const kindLabel = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Question",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
const publicationLabel = {
|
||||
NEVER_PUBLISHED: "게시 전",
|
||||
@@ -91,7 +92,7 @@ export function DocumentList() {
|
||||
<p className="studio-eyebrow">WORKING COPIES</p>
|
||||
<h1>작업본</h1>
|
||||
<p>
|
||||
세션에 있는 Case, Reference, Question을 찾고 다음 작업으로 이동합니다.
|
||||
세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
<GuardedStudioLink className="studio-primary-action" href="/studio/documents/new">
|
||||
@@ -126,6 +127,7 @@ export function DocumentList() {
|
||||
<option value="CASE">Case</option>
|
||||
<option value="REFERENCE">Reference</option>
|
||||
<option value="QUESTION">Question</option>
|
||||
<option value="PROJECT_DECISION">Decision</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
@@ -8,13 +8,20 @@ const labels = {
|
||||
CONFLICT: "저장 충돌",
|
||||
} as const;
|
||||
|
||||
const kindLabels = {
|
||||
CASE: "CASE",
|
||||
REFERENCE: "REFERENCE",
|
||||
QUESTION: "QUESTION",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
|
||||
export function DocumentStatusRail({ controller }: { controller: DocumentEditorController }) {
|
||||
return (
|
||||
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
||||
<p className="studio-eyebrow">WORKING COPY</p>
|
||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{controller.draft.kind}</dd></div></dl>
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</GuardedStudioLink>
|
||||
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p> : <p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>}
|
||||
|
||||
@@ -26,6 +26,12 @@ const types = [
|
||||
description: "아직 닫히지 않은 판단과 다음 검증을 관리합니다.",
|
||||
fields: "상태 · 사실 · 가정 · 미지수 · 선택지",
|
||||
},
|
||||
{
|
||||
kind: "PROJECT_DECISION",
|
||||
title: "Decision",
|
||||
description: "프로젝트가 선택한 방향과 그 근거·영향을 기록합니다.",
|
||||
fields: "상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function emptyDocument(kind: RecordKind): CreateDocumentInput {
|
||||
@@ -61,17 +67,28 @@ function emptyDocument(kind: RecordKind): CreateDocumentInput {
|
||||
verifiedOn: null,
|
||||
};
|
||||
}
|
||||
if (kind === "QUESTION") {
|
||||
return {
|
||||
...common,
|
||||
kind,
|
||||
questionStatus: null,
|
||||
facts: [],
|
||||
assumptions: [],
|
||||
unknowns: [],
|
||||
constraints: [],
|
||||
options: [],
|
||||
nextValidation: "",
|
||||
resolution: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
kind,
|
||||
questionStatus: null,
|
||||
facts: [],
|
||||
assumptions: [],
|
||||
unknowns: [],
|
||||
constraints: [],
|
||||
options: [],
|
||||
nextValidation: "",
|
||||
resolution: null,
|
||||
decisionStatus: null,
|
||||
decidedOn: null,
|
||||
statement: "",
|
||||
rationale: "",
|
||||
consequences: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||
|
||||
type ProjectDecisionInput = components["schemas"]["ProjectDecisionInput"];
|
||||
|
||||
export function ProjectDecisionFields({
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
draft: ProjectDecisionInput;
|
||||
onChange(draft: ProjectDecisionInput): void;
|
||||
}) {
|
||||
const update = (patch: Partial<ProjectDecisionInput>) => {
|
||||
onChange({ ...draft, ...patch });
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="studio-editor-section"
|
||||
aria-labelledby="studio-project-decision-fields-title"
|
||||
>
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">PROJECT DECISION</p>
|
||||
<h2 id="studio-project-decision-fields-title">프로젝트 결정</h2>
|
||||
</div>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field">
|
||||
<span>결정 상태</span>
|
||||
<select
|
||||
value={draft.decisionStatus ?? ""}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
update({
|
||||
decisionStatus: value === "PROPOSED" || value === "ADOPTED"
|
||||
? value
|
||||
: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">아직 정하지 않음</option>
|
||||
<option value="PROPOSED">PROPOSED</option>
|
||||
<option value="ADOPTED">ADOPTED</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>결정일</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draft.decidedOn ?? ""}
|
||||
onChange={(event) => {
|
||||
update({ decidedOn: event.currentTarget.value || null });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>결정문</span>
|
||||
<textarea
|
||||
value={draft.statement}
|
||||
onChange={(event) => update({ statement: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>판단 이유</span>
|
||||
<textarea
|
||||
value={draft.rationale}
|
||||
onChange={(event) => update({ rationale: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<OrderedTextList
|
||||
label="영향"
|
||||
fieldId="studio-field-consequences"
|
||||
items={draft.consequences}
|
||||
onChange={(consequences) => update({ consequences })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const kindLabels = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Question",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
|
||||
function dateTime(value: string) {
|
||||
|
||||
@@ -8,7 +8,8 @@ function ordered(relations: RelationInput[]): RelationInput[] {
|
||||
return relations.map((relation, order) => ({ ...relation, order }));
|
||||
}
|
||||
|
||||
export function RelationEditor({ relations, catalog, onChange }: { relations: RelationInput[]; catalog: CatalogEntry[]; onChange(relations: RelationInput[]): void }) {
|
||||
export function RelationEditor({ relations, catalog, evidence = false, onChange }: { relations: RelationInput[]; catalog: CatalogEntry[]; evidence?: boolean; onChange(relations: RelationInput[]): void }) {
|
||||
const noun = evidence ? "근거" : "관계";
|
||||
const update = (index: number, patch: Partial<RelationInput>) => onChange(ordered(relations.map((relation, candidateIndex) => candidateIndex === index ? { ...relation, ...patch } : relation)));
|
||||
const move = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
@@ -19,16 +20,16 @@ export function RelationEditor({ relations, catalog, onChange }: { relations: Re
|
||||
};
|
||||
return (
|
||||
<fieldset className="studio-ordered-list">
|
||||
<legend>관계</legend>
|
||||
{relations.length === 0 ? <p>연결한 공개 기록이 없습니다.</p> : null}
|
||||
<legend>{evidence ? "근거 기록" : "관계"}</legend>
|
||||
{relations.length === 0 ? <p>{evidence ? "연결한 근거 기록이 없습니다." : "연결한 공개 기록이 없습니다."}</p> : null}
|
||||
{relations.map((relation, index) => (
|
||||
<div className="studio-relation-item" key={relation.id ?? `relation-${index}`}>
|
||||
<label><span>관계 {index + 1} 대상</span><select value={relation.targetId ?? ""} onChange={(event) => update(index, { targetId: event.currentTarget.value || null })}><option value="">대상 선택</option>{catalog.map((entry) => <option key={entry.id} value={entry.id} disabled={relations.some((candidate, candidateIndex) => candidateIndex !== index && candidate.targetId === entry.id)}>{entry.label}</option>)}</select></label>
|
||||
<label><span>관계 {index + 1} 이유</span><input value={relation.reason} onChange={(event) => update(index, { reason: event.currentTarget.value })} /></label>
|
||||
<label><span>{noun} {index + 1} 대상</span><select value={relation.targetId ?? ""} onChange={(event) => update(index, { targetId: event.currentTarget.value || null })}><option value="">대상 선택</option>{catalog.map((entry) => <option key={entry.id} value={entry.id} disabled={relations.some((candidate, candidateIndex) => candidateIndex !== index && candidate.targetId === entry.id)}>{entry.label}</option>)}</select></label>
|
||||
<label><span>{noun} {index + 1} 이유</span><input value={relation.reason} onChange={(event) => update(index, { reason: event.currentTarget.value })} /></label>
|
||||
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||
</div>
|
||||
))}
|
||||
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>관계 추가</button>
|
||||
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const kindLabel = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Question",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
const actionLabel = {
|
||||
CONTINUE_EDITING: "작성 계속",
|
||||
|
||||
Reference in New Issue
Block a user