/**
* 対応状況・備考の「編集モード」の下書き(純粋関数のみ。ユーザー指示 2026-10-06。D-165)。
*
* ★編集モードで変えた内容はすぐには送らない★(下書き)。★保存ボタンでまとめて送る★。
* 対象はエラー一覧(/errors)・アラート(/alerts)・D-Sec アラート(/ds-alerts)の3画面 — ★3画面がこのモジュールを使う★(D-Sec は指示書AX から)。
*
* ★期待値表は `lib/prechecks/__tests__/edit-drafts.test.ts`★(P1〜P8 / D1〜D5 / S1〜S3 / H1〜H6)。
*
* 規則:
* - ★下書きは「行の値と違う項目」だけを持つ★。行の値と同じに戻したらその項目を消し、
* 項目が0になったらエントリごと消す(変えた件数 = Map の件数になる)
* - ★備考は行の null と "" を同じとみなす★(比較は素の文字列。トリムはサーバー側の規則。指示書AT §3-6)
* - ★送る本文の対応状況は必須・備考は変えたときだけ★(AT の規則のまま。`note` を省略すると備考は変えない)
* - ★`setDraft*` は入力の Map を書き換えない★(React の state にそのまま入れるため)
*/
import type { PrecheckItem, PrecheckStatus, PrecheckStatusUpdateRequest } from "@/lib/types";
/** 下書きの対象の行。★`ErrorReportItem`・`DsAlert` も同じ3つを持つので3画面で使える★ */
export type EditableRow = Pick<PrecheckItem, "id" | "resolution_status" | "note">;
/** 1行ぶんの下書き。★キーが無い項目は「変えていない」★ */
export interface ResolutionDraft {
status?: PrecheckStatus;
note?: string;
}
/** 行の id → 下書き */
export type ResolutionDrafts = ReadonlyMap<string, ResolutionDraft>;
/** 画面に出す対応状況(下書きがあればそれ、無ければ行の値) */
export function draftStatus(row: EditableRow, draft: ResolutionDraft | undefined): PrecheckStatus {
return draft?.status ?? row.resolution_status;
}
/** 画面に出す備考(下書きがあればそれ、無ければ行の値。★行の null は ""★) */
export function draftNote(row: EditableRow, draft: ResolutionDraft | undefined): string {
return draft?.note ?? row.note ?? "";
}
/**
* 解消日時を出すか(対応状況のバッジ・プルダウンの下)。★保存済みの値が解消で、かつ画面に出す対応状況(下書き優先)も解消のときだけ★
* (指示書BA 項1。D-181)。3画面(エラー一覧・アラート・D-Sec アラート)がこの1本を使う。
* ・★下書きで解消にしただけの行には出さない★(解消日時は保存したときにサーバーが決める)
* ・★保存済みが解消でも、下書きで未解消・対応中に変えた行には出さない★(保存すると解消日時は消える — `status-update.ts` の「戻すと消す」)
*/
export function showsResolvedTime(row: EditableRow, draft: ResolutionDraft | undefined): boolean {
return row.resolution_status === "resolved" && draftStatus(row, draft) === "resolved";
}
/** 行の値と同じ項目を落とした下書き(項目が0なら null) */
function normalizeDraft(row: EditableRow, draft: ResolutionDraft): ResolutionDraft | null {
const next: ResolutionDraft = {};
if (draft.status !== undefined && draft.status !== row.resolution_status) {
next.status = draft.status;
}
if (draft.note !== undefined && draft.note !== (row.note ?? "")) {
next.note = draft.note;
}
return next.status === undefined && next.note === undefined ? null : next;
}
function withDraft(
drafts: ResolutionDrafts,
row: EditableRow,
patch: ResolutionDraft
): Map<string, ResolutionDraft> {
const result = new Map(drafts);
const normalized = normalizeDraft(row, { ...drafts.get(row.id), ...patch });
if (normalized) result.set(row.id, normalized);
else result.delete(row.id);
return result;
}
/**
* 対応状況の下書きを入れる。★新しい Map を返す(入力を書き換えない)★。
* ★行の値と同じに戻したらその項目を消し、項目が0になったらエントリごと消す★。
*/
export function setDraftStatus(
drafts: ResolutionDrafts,
row: EditableRow,
status: PrecheckStatus
): Map<string, ResolutionDraft> {
return withDraft(drafts, row, { status });
}
/**
* 備考の下書きを入れる。★新しい Map を返す(入力を書き換えない)★。
* ★行の値と同じに戻したらその項目を消し、項目が0になったらエントリごと消す★(行の null と "" は同じ)。
*/
export function setDraftNote(
drafts: ResolutionDrafts,
row: EditableRow,
note: string
): Map<string, ResolutionDraft> {
return withDraft(drafts, row, { note });
}
/**
* 下書きから PATCH の本文を作る。★送る必要が無ければ null★。
* ★対応状況は必須(変えていなければ行の値)・備考は変えたときだけ `note` を入れる★(AT の規則のまま)。
* ★備考を消す("" にする)ときも `note: ""` を送る★ — サーバーが空文字を削除として扱う(指示書AT §3-6)。
*/
export function patchRequestFromDraft(
row: EditableRow,
draft: ResolutionDraft | undefined
): PrecheckStatusUpdateRequest | null {
const normalized = draft ? normalizeDraft(row, draft) : null;
if (!normalized) return null;
const request: PrecheckStatusUpdateRequest = { status: draftStatus(row, normalized) };
if (normalized.note !== undefined) request.note = normalized.note;
return request;
}
/** `saveDrafts()` の結果 */
export interface SaveDraftsResult<T> {
/** 成功した行(id → レスポンスの行。画面はこれで差し替える) */
updated: Map<string, T>;
/** 失敗した行(id → 投げられた例外そのまま) */
failed: Map<string, unknown>;
/** ★失敗した行の下書きだけ★(成功した行と、送る必要が無かった行の下書きは消える) */
remaining: Map<string, ResolutionDraft>;
}
/**
* 下書きをまとめて保存する。★`rows` の並び順に、送る必要がある行だけを1行ずつ順に `send` する★
* (並列にしない。ユーザー指示 2026-10-06。D-165)。★1行の失敗で止めない★(残りも送る)。
*
* `send` は失敗を例外で返す。`onProgress` は ★送る行の数で数えて★ 1行終わるごとに `(done, total)` で呼ぶ
* (成功・失敗どちらでも進む)。
*/
export async function saveDrafts<T extends EditableRow>(
rows: readonly T[],
drafts: ResolutionDrafts,
send: (row: T, request: PrecheckStatusUpdateRequest) => Promise<T>,
onProgress?: (done: number, total: number) => void
): Promise<SaveDraftsResult<T>> {
const targets: { row: T; request: PrecheckStatusUpdateRequest }[] = [];
for (const row of rows) {
const request = patchRequestFromDraft(row, drafts.get(row.id));
if (request) targets.push({ row, request });
}
const updated = new Map<string, T>();
const failed = new Map<string, unknown>();
const remaining = new Map<string, ResolutionDraft>();
let done = 0;
for (const { row, request } of targets) {
try {
updated.set(row.id, await send(row, request));
} catch (error) {
failed.set(row.id, error);
const draft = drafts.get(row.id);
if (draft) remaining.set(row.id, draft);
}
done += 1;
onProgress?.(done, targets.length);
}
return { updated, failed, remaining };
}