From 77a2ce7d9a392859118f385063ea89939395b8c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 10:58:09 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(frontend):=20=E7=B5=8C=E6=AD=B4?= =?UTF-8?q?=E6=9B=B8=20=E4=BF=9D=E5=AD=98=E6=99=82=E3=81=AE=E5=A4=89?= =?UTF-8?q?=E6=9B=B4=E7=82=B9=E7=A2=BA=E8=AA=8D=EF=BC=8B=E9=A0=85=E7=9B=AE?= =?UTF-8?q?=E5=88=A5=E3=83=AD=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=83=80=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保存ボタン押下時に baseline(保存済み)と編集中フォームの差分を一覧表示し、 旧→新/追加・削除・修正を区別して確認できるダイアログを追加。各変更行で 「元に戻す」を押すと、その項目だけ baseline 値へ戻し、他の変更は保持したまま保存できる。 - utils/setAtPath.ts: immutable な path 単位の set/insert/remove(ロールバック適用の土台) - utils/careerDiff.ts: baseline vs form の変更点リスト生成(既存 isDeepEqual を再利用、 配列は useCareerDirty と同じ index 突合。途中削除は近似表示) - components/forms/CareerSaveConfirmDialog: 確認+項目別ロールバック UI - CareerResumeForm: 保存フローにダイアログを差し込み(setForm/save を流用) - constants/messages.ts: CAREER_DIFF_LABELS / DIFF_DIALOG_MESSAGES を追加 - careerDiff.test.ts / setAtPath.test.ts: ユニットテスト https://claude.ai/code/session_01NKa8AgchLkcdc3fXi737AF --- .../src/components/forms/CareerResumeForm.tsx | 37 ++- .../forms/CareerSaveConfirmDialog.module.css | 133 ++++++++ .../forms/CareerSaveConfirmDialog.tsx | 96 ++++++ frontend/src/constants/messages.ts | 74 +++++ frontend/src/utils/careerDiff.test.ts | 140 +++++++++ frontend/src/utils/careerDiff.ts | 297 ++++++++++++++++++ frontend/src/utils/setAtPath.test.ts | 56 ++++ frontend/src/utils/setAtPath.ts | 92 ++++++ 8 files changed, 923 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/forms/CareerSaveConfirmDialog.module.css create mode 100644 frontend/src/components/forms/CareerSaveConfirmDialog.tsx create mode 100644 frontend/src/utils/careerDiff.test.ts create mode 100644 frontend/src/utils/careerDiff.ts create mode 100644 frontend/src/utils/setAtPath.test.ts create mode 100644 frontend/src/utils/setAtPath.ts diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index bd177a01..a24fd25b 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, FormEvent, useRef, useState } from "react"; +import { CSSProperties, FormEvent, useMemo, useRef, useState } from "react"; import { createCareerResume, @@ -16,11 +16,13 @@ import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout"; import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist"; import { useDocumentForm } from "../../hooks/useDocumentForm"; import { buildCareerPayload } from "../../payloadBuilders"; +import { buildCareerChanges } from "../../utils/careerDiff"; import type { CareerTextFieldKey } from "../../formTypes"; import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData"; import { usePdfActions } from "../../hooks/usePdfActions"; import shared from "../../styles/shared.module.css"; import { ConfirmDialog } from "../ConfirmDialog"; +import { CareerSaveConfirmDialog } from "./CareerSaveConfirmDialog"; import { Skeleton } from "../ui/Skeleton"; import { PdfPreviewModal } from "./PdfPreviewModal"; import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel"; @@ -32,6 +34,8 @@ import { CareerSelfPrSection } from "./sections/CareerSelfPrSection"; export function CareerResumeForm() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + // 保存時の変更点確認ダイアログの表示状態。 + const [showSaveConfirm, setShowSaveConfirm] = useState(false); // PDF 原本ビュー(右カラム)の折りたたみ状態。折りたたむと入力フォームが全幅に広がる。 const [pdfCollapsed, setPdfCollapsed] = useState(false); const assist = useResumeImportAssist(); @@ -69,6 +73,15 @@ export function CareerResumeForm() { /** 未保存マーク(🔴)の表示判定に使う dirty マップ */ const dirty = useCareerDirty(form, baseline); + /** + * baseline(保存済み)と form(編集中)の変更点リスト。保存確認ダイアログで表示する。 + * baseline が未ロード(null)のときは form 同士を比較して変更なし扱いにする。 + */ + const changes = useMemo( + () => buildCareerChanges(form, baseline ?? form), + [form, baseline], + ); + const { downloading, previewUrl, @@ -98,9 +111,20 @@ export function CareerResumeForm() { setForm((prev) => ({ ...prev, [key]: value })); }; - const onSubmit = async (event: FormEvent) => { + const onSubmit = (event: FormEvent) => { event.preventDefault(); + // 変更が無ければ確認を挟まずそのまま保存。変更があれば確認ダイアログを開く。 + if (changes.length === 0) { + void save(); + return; + } + setShowSaveConfirm(true); + }; + + /** 確認ダイアログで「この内容で保存」を押したときの確定処理。 */ + const handleConfirmSave = async () => { await save(); + setShowSaveConfirm(false); }; const handleDelete = async () => { @@ -119,6 +143,15 @@ export function CareerResumeForm() { confirming={deleting} /> )} + {showSaveConfirm && ( + setShowSaveConfirm(false)} + onRollback={(change) => setForm((prev) => change.rollback(prev))} + /> + )} {previewUrl && }
diff --git a/frontend/src/components/forms/CareerSaveConfirmDialog.module.css b/frontend/src/components/forms/CareerSaveConfirmDialog.module.css new file mode 100644 index 00000000..2899ef8b --- /dev/null +++ b/frontend/src/components/forms/CareerSaveConfirmDialog.module.css @@ -0,0 +1,133 @@ +.overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.dialog { + background: var(--bg-card); + border-radius: 12px; + padding: 1.5rem; + max-width: 560px; + width: 90vw; + max-height: 85vh; + display: flex; + flex-direction: column; +} + +.title { + margin: 0 0 0.25rem; + font-size: 1.1rem; +} + +.description { + margin: 0 0 1rem; + font-size: 0.85rem; + color: var(--text-muted, #666); + line-height: 1.5; +} + +.list { + list-style: none; + margin: 0 0 1.25rem; + padding: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; +} + +.rowMain { + min-width: 0; + flex: 1; +} + +.rowHead { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.badge { + flex-shrink: 0; + font-size: 0.7rem; + font-weight: 600; + padding: 0.1rem 0.45rem; + border-radius: 999px; + color: #fff; +} + +.modified { + background: #2563eb; +} + +.added { + background: #16a34a; +} + +.removed { + background: #dc2626; +} + +.label { + font-size: 0.85rem; + font-weight: 600; + word-break: break-word; +} + +.values { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; + font-size: 0.85rem; +} + +.oldValue { + color: var(--text-muted, #888); + text-decoration: line-through; + word-break: break-word; +} + +.arrow { + color: var(--text-muted, #888); +} + +.newValue { + color: var(--text-primary, #111); + font-weight: 500; + word-break: break-word; +} + +.rollback { + flex-shrink: 0; + font-size: 0.8rem; + white-space: nowrap; +} + +.empty { + margin: 0 0 1.25rem; + color: var(--text-muted, #666); +} + +.actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} diff --git a/frontend/src/components/forms/CareerSaveConfirmDialog.tsx b/frontend/src/components/forms/CareerSaveConfirmDialog.tsx new file mode 100644 index 00000000..fc17f9db --- /dev/null +++ b/frontend/src/components/forms/CareerSaveConfirmDialog.tsx @@ -0,0 +1,96 @@ +import { DIFF_DIALOG_MESSAGES as D } from "../../constants/messages"; +import type { CareerChange, ChangeKind } from "../../utils/careerDiff"; +import styles from "./CareerSaveConfirmDialog.module.css"; + +/** 種別ごとのバッジ文言。 */ +const KIND_LABEL: Record = { + modified: D.MODIFIED_LABEL, + added: D.ADDED_LABEL, + removed: D.REMOVED_LABEL, +}; + +/** + * 経歴書 保存時の変更点確認ダイアログ。 + * baseline と編集中フォームの差分(旧→新/追加・削除・修正)を一覧表示し、 + * 各行で「元に戻す」(項目別ロールバック)を実行できる。 + */ +export function CareerSaveConfirmDialog({ + changes, + saving, + onConfirm, + onCancel, + onRollback, +}: { + changes: CareerChange[]; + saving: boolean; + onConfirm: () => void; + onCancel: () => void; + onRollback: (change: CareerChange) => void; +}) { + const hasChanges = changes.length > 0; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={D.TITLE} + > +

{D.TITLE}

+

{D.DESCRIPTION}

+ + {hasChanges ? ( +
    + {changes.map((change) => ( +
  • +
    +
    + + {KIND_LABEL[change.kind]} + + {change.label} +
    +
    + {change.kind !== "added" && ( + {change.oldValue || D.EMPTY_VALUE} + )} + {change.kind === "modified" && } + {change.kind !== "removed" && ( + {change.newValue || D.EMPTY_VALUE} + )} +
    +
    + +
  • + ))} +
+ ) : ( +

{D.NO_CHANGES}

+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index 491efbd5..37834e32 100644 --- a/frontend/src/constants/messages.ts +++ b/frontend/src/constants/messages.ts @@ -137,6 +137,80 @@ export const LOADING_MESSAGES = { GITHUB_LINK: "GitHubプロフィールを取得中...", } as const; +/** + * 経歴書 保存時の変更点確認ダイアログのラベル(セグメント名・フィールド名)。 + * `utils/careerDiff.ts` が「職歴1 > 取引先2 > 案件名」のような人間可読パスを組み立てる際に参照する。 + */ +export const CAREER_DIFF_LABELS = { + // トップレベル + FULL_NAME: "氏名", + CAREER_SUMMARY: "職務要約", + SELF_PR: "自己PR", + // 配列のセグメント名(末尾に連番が付く: 「職歴1」) + EXPERIENCE: "職歴", + CLIENT: "取引先", + PROJECT: "プロジェクト", + PERIOD: "期間", + TEAM_MEMBER: "メンバー", + TECH_STACK: "技術スタック", + PHASE: "フェーズ", + QUALIFICATION: "資格", + // 職歴フィールド + COMPANY: "会社名", + BUSINESS_DESCRIPTION: "事業内容", + START_DATE: "開始年月", + END_DATE: "終了年月", + IS_CURRENT: "在職中", + EMPLOYEE_COUNT: "従業員数", + CAPITAL: "資本金", + CAPITAL_UNIT: "資本金単位", + IS_IT_COMPANY: "IT企業", + DESCRIPTION: "詳細", + // 取引先フィールド + CLIENT_NAME: "取引先名", + HAS_CLIENT: "取引先あり", + IS_VACATION: "休暇", + VACATION_START_DATE: "休暇開始年月", + VACATION_END_DATE: "休暇終了年月", + VACATION_IS_CURRENT: "休暇継続中", + VACATION_DESCRIPTION: "休暇内容", + // プロジェクトフィールド + PROJECT_NAME: "案件名", + ROLE: "役割", + PROJECT_DESCRIPTION: "案件詳細", + TEAM_TOTAL: "体制人数", + // 体制メンバーフィールド + MEMBER_ROLE: "役割", + MEMBER_COUNT: "人数", + // 技術スタックフィールド + TECH_CATEGORY: "カテゴリ", + TECH_NAME: "技術名", + // 資格フィールド + QUALIFICATION_NAME: "資格名", + ACQUIRED_DATE: "取得日", +} as const; + +/** 経歴書 保存時の変更点確認ダイアログの固定文言・表示記号。 */ +export const DIFF_DIALOG_MESSAGES = { + TITLE: "変更内容の確認", + DESCRIPTION: "保存前に変更点を確認できます。「元に戻す」で項目ごとに編集前の値へ戻せます。", + CONFIRM: "この内容で保存", + CANCEL: "キャンセル", + ROLLBACK: "元に戻す", + NO_CHANGES: "変更はありません。", + /** 空文字の値を表示するときの代替テキスト */ + EMPTY_VALUE: "(空)", + /** パスセグメントの区切り(「職歴1 > 会社名」) */ + PATH_SEPARATOR: " > ", + /** 種別バッジの文言 */ + ADDED_LABEL: "追加", + REMOVED_LABEL: "削除", + MODIFIED_LABEL: "修正", + /** boolean 値の表示 */ + BOOL_TRUE: "あり", + BOOL_FALSE: "なし", +} as const; + /** ダウンロード失敗時のメッセージにファイル名を付与する。 */ export function downloadFailureMessage(filename: string): string { return `${FALLBACK_MESSAGES.DOWNLOAD}: ${filename}`; diff --git a/frontend/src/utils/careerDiff.test.ts b/frontend/src/utils/careerDiff.test.ts new file mode 100644 index 00000000..516f6283 --- /dev/null +++ b/frontend/src/utils/careerDiff.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; + +import { + blankCareerClient, + blankCareerExperience, + blankCareerProject, + blankCareerTechnologyStack, + blankResumeQualification, +} from "../constants"; +import type { CareerFormState } from "../payloadBuilders"; +import { buildCareerChanges } from "./careerDiff"; + +/** ネストを含めて完全にコピーした form を作る(テスト間で参照を共有しないため)。 */ +function buildForm(): CareerFormState { + return structuredClone({ + full_name: "山田 太郎", + career_summary: "サマリー", + self_pr: "自己PR", + experiences: [ + { + ...blankCareerExperience, + company: "株式会社A", + business_description: "受託開発", + start_date: "2020-04", + clients: [ + { + ...blankCareerClient, + projects: [ + { + ...blankCareerProject, + name: "プロジェクトX", + role: "SE", + technology_stacks: [{ ...blankCareerTechnologyStack, name: "TypeScript" }], + }, + ], + }, + ], + }, + ], + qualifications: [ + { ...blankResumeQualification, name: "基本情報", acquired_date: "2021-04-01" }, + ], + }); +} + +describe("buildCareerChanges", () => { + it("変更が無ければ空配列を返す", () => { + const form = buildForm(); + expect(buildCareerChanges(form, buildForm())).toEqual([]); + }); + + it("トップレベル文字列の修正を modified として検出する", () => { + const baseline = buildForm(); + const form = buildForm(); + form.full_name = "佐藤 花子"; + + const changes = buildCareerChanges(form, baseline); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + kind: "modified", + oldValue: "山田 太郎", + newValue: "佐藤 花子", + }); + expect(changes[0].label).toBe("氏名"); + }); + + it("職歴の追加を added として検出する", () => { + const baseline = buildForm(); + const form = buildForm(); + form.experiences.push({ ...structuredClone(blankCareerExperience), company: "株式会社B" }); + + const changes = buildCareerChanges(form, baseline); + const added = changes.find((c) => c.kind === "added"); + expect(added).toBeDefined(); + expect(added?.label).toBe("職歴2"); + expect(added?.newValue).toBe("株式会社B"); + }); + + it("職歴の削除を removed として検出し、ロールバックで復元できる", () => { + const baseline = buildForm(); + baseline.experiences.push({ ...structuredClone(blankCareerExperience), company: "株式会社B" }); + const form = buildForm(); // 1 社だけ + + const changes = buildCareerChanges(form, baseline); + const removed = changes.find((c) => c.kind === "removed"); + expect(removed).toBeDefined(); + expect(removed?.label).toBe("職歴2"); + expect(removed?.oldValue).toBe("株式会社B"); + + const restored = removed!.rollback(form); + expect(restored.experiences).toHaveLength(2); + expect(restored.experiences[1].company).toBe("株式会社B"); + }); + + it("ネストした技術スタックの変更を検出する", () => { + const baseline = buildForm(); + const form = buildForm(); + form.experiences[0].clients[0].projects[0].technology_stacks[0].name = "Rust"; + + const changes = buildCareerChanges(form, baseline); + const techChange = changes.find((c) => c.newValue === "Rust"); + expect(techChange).toBeDefined(); + expect(techChange?.kind).toBe("modified"); + expect(techChange?.oldValue).toBe("TypeScript"); + expect(techChange?.label).toContain("技術名"); + }); + + it("項目別ロールバックは当該フィールドのみ baseline に戻し、他の変更は保持する", () => { + const baseline = buildForm(); + const form = buildForm(); + form.full_name = "佐藤 花子"; + form.career_summary = "新サマリー"; + + const changes = buildCareerChanges(form, baseline); + expect(changes).toHaveLength(2); + + // 氏名だけロールバック + const nameChange = changes.find((c) => c.label === "氏名")!; + const rolledBack = nameChange.rollback(form); + + expect(rolledBack.full_name).toBe("山田 太郎"); // baseline に戻る + expect(rolledBack.career_summary).toBe("新サマリー"); // 他の変更は残る + + // 再計算すると氏名の行は消え、職務要約だけ残る + const remaining = buildCareerChanges(rolledBack, baseline); + expect(remaining).toHaveLength(1); + expect(remaining[0].label).toBe("職務要約"); + }); + + it("boolean フィールドは「あり / なし」で表示する", () => { + const baseline = buildForm(); + const form = buildForm(); + form.experiences[0].is_current = !form.experiences[0].is_current; + + const changes = buildCareerChanges(form, baseline); + const boolChange = changes.find((c) => c.label.includes("在職中")); + expect(boolChange).toBeDefined(); + expect([boolChange?.oldValue, boolChange?.newValue].sort()).toEqual(["あり", "なし"]); + }); +}); diff --git a/frontend/src/utils/careerDiff.ts b/frontend/src/utils/careerDiff.ts new file mode 100644 index 00000000..8a32b407 --- /dev/null +++ b/frontend/src/utils/careerDiff.ts @@ -0,0 +1,297 @@ +/** + * 経歴書フォームの「保存済み(baseline)」と「編集中(form)」の差分を、 + * 変更点リスト(旧→新/追加・削除・修正+項目別ロールバック)として算出する純関数群。 + * + * 保存時の確認ダイアログ(`CareerSaveConfirmDialog`)が表示するモデルを生成する。 + * + * ## 突合方針(index ベース) + * `CareerFormState` の配列要素には安定 ID が無いため、`useCareerDirty` と同じく + * **index による突合**で「追加 / 削除」を判定する。長さ差分の末尾を追加/削除とみなすため、 + * 配列途中の要素削除は「以降の要素がすべて修正+末尾が削除」と近似表示される。 + * これは安定 ID が無い以上の制約であり、既存 dirty 判定と挙動を一致させている。 + * 突合戦略を差し替えたい場合は `diffArray` のマッチングだけ変更すればよい。 + * + * 等価判定は `isDeepEqual`(`utils/deepEqual.ts`)に集約済みのものを再利用する。 + */ +import { CAREER_DIFF_LABELS as L, DIFF_DIALOG_MESSAGES as D } from "../constants/messages"; +import type { + CareerClientForm, + CareerExperienceForm, + CareerFormState, + CareerProjectForm, + CareerProjectPeriodForm, + TeamMemberForm, +} from "../payloadBuilders"; +import type { ResumeQualificationItem, TechnologyStackItem } from "../api/types"; +import { isDeepEqual } from "./deepEqual"; +import { insertAtPath, removeAtPath, setAtPath } from "./setAtPath"; + +/** 変更の種別。 */ +export type ChangeKind = "modified" | "added" | "removed"; + +/** 1 件の変更点。ダイアログ表示とロールバックの両方に必要な情報を持つ。 */ +export type CareerChange = { + /** form 上の対象パス(ロールバック適用・key 生成に使う)。 */ + path: (string | number)[]; + /** 人間可読ラベル(例: 「職歴1 > 取引先2 > 案件名」)。 */ + label: string; + kind: ChangeKind; + /** 表示用の旧値(modified / removed で意味を持つ)。 */ + oldValue: string; + /** 表示用の新値(modified / added で意味を持つ)。 */ + newValue: string; + /** この変更だけを baseline 値へ戻した form を返す純関数。 */ + rollback: (form: CareerFormState) => CareerFormState; +}; + +/** 表示用に値を文字列へ整形する。boolean は「あり / なし」、空値は代替テキスト。 */ +function formatScalar(value: unknown): string { + if (typeof value === "boolean") return value ? D.BOOL_TRUE : D.BOOL_FALSE; + if (value === "" || value === null || value === undefined) return D.EMPTY_VALUE; + return String(value); +} + +/** ラベルセグメントを区切り文字で連結する。 */ +function joinLabel(segments: string[]): string { + return segments.join(D.PATH_SEPARATOR); +} + +/** + * スカラーフィールドの差分を 1 件 push する。等しければ何もしない。 + * rollback は当該パスを base 値へ書き戻す。 + */ +function pushScalar( + changes: CareerChange[], + labelSegments: string[], + fieldLabel: string, + path: (string | number)[], + current: unknown, + base: unknown, +): void { + if (isDeepEqual(current, base)) return; + changes.push({ + path, + label: joinLabel([...labelSegments, fieldLabel]), + kind: "modified", + oldValue: formatScalar(base), + newValue: formatScalar(current), + rollback: (form) => setAtPath(form, path, base), + }); +} + +/** + * 配列の差分を index 突合で算出する汎用ヘルパ。 + * - 片側にしか無い要素 → 追加 / 削除として 1 件 push(ロールバックは要素の除去 / 復元) + * - 両側にある要素 → `diffItem` に委譲(中身のフィールド差分を再帰展開) + */ +function diffArray( + changes: CareerChange[], + current: T[], + base: T[], + path: (string | number)[], + labelSegments: string[], + itemLabel: string, + summarize: (item: T) => string, + diffItem: ( + changes: CareerChange[], + cur: T, + bas: T, + itemPath: (string | number)[], + itemLabelSegments: string[], + ) => void, +): void { + const maxLen = Math.max(current.length, base.length); + for (let i = 0; i < maxLen; i++) { + const cur = current[i]; + const bas = base[i]; + const itemPath = [...path, i]; + const itemLabelSegments = [...labelSegments, `${itemLabel}${i + 1}`]; + + if (cur !== undefined && bas === undefined) { + changes.push({ + path: itemPath, + label: joinLabel(itemLabelSegments), + kind: "added", + oldValue: "", + newValue: summarize(cur) || D.EMPTY_VALUE, + rollback: (form) => removeAtPath(form, itemPath), + }); + } else if (cur === undefined && bas !== undefined) { + changes.push({ + path: itemPath, + label: joinLabel(itemLabelSegments), + kind: "removed", + oldValue: summarize(bas) || D.EMPTY_VALUE, + newValue: "", + rollback: (form) => insertAtPath(form, itemPath, bas), + }); + } else if (cur !== undefined && bas !== undefined) { + diffItem(changes, cur, bas, itemPath, itemLabelSegments); + } + } +} + +function diffTeamMember( + changes: CareerChange[], + cur: TeamMemberForm, + bas: TeamMemberForm, + path: (string | number)[], + seg: string[], +): void { + pushScalar(changes, seg, L.MEMBER_ROLE, [...path, "role"], cur.role, bas.role); + pushScalar(changes, seg, L.MEMBER_COUNT, [...path, "count"], cur.count, bas.count); +} + +function diffPeriod( + changes: CareerChange[], + cur: CareerProjectPeriodForm, + bas: CareerProjectPeriodForm, + path: (string | number)[], + seg: string[], +): void { + pushScalar(changes, seg, L.START_DATE, [...path, "start_date"], cur.start_date, bas.start_date); + pushScalar(changes, seg, L.END_DATE, [...path, "end_date"], cur.end_date, bas.end_date); + pushScalar(changes, seg, L.IS_CURRENT, [...path, "is_current"], cur.is_current, bas.is_current); +} + +function diffTechStack( + changes: CareerChange[], + cur: TechnologyStackItem, + bas: TechnologyStackItem, + path: (string | number)[], + seg: string[], +): void { + pushScalar(changes, seg, L.TECH_CATEGORY, [...path, "category"], cur.category, bas.category); + pushScalar(changes, seg, L.TECH_NAME, [...path, "name"], cur.name, bas.name); +} + +/** phases は string[]。セグメント名(「フェーズN」)自体がラベルになるので直接 push する。 */ +function diffPhase( + changes: CareerChange[], + cur: string, + bas: string, + path: (string | number)[], + seg: string[], +): void { + if (isDeepEqual(cur, bas)) return; + changes.push({ + path, + label: joinLabel(seg), + kind: "modified", + oldValue: formatScalar(bas), + newValue: formatScalar(cur), + rollback: (form) => setAtPath(form, path, bas), + }); +} + +function diffProject( + changes: CareerChange[], + cur: CareerProjectForm, + bas: CareerProjectForm, + path: (string | number)[], + seg: string[], +): void { + pushScalar(changes, seg, L.PROJECT_NAME, [...path, "name"], cur.name, bas.name); + pushScalar(changes, seg, L.ROLE, [...path, "role"], cur.role, bas.role); + pushScalar(changes, seg, L.PROJECT_DESCRIPTION, [...path, "description"], cur.description, bas.description); + pushScalar(changes, seg, L.TEAM_TOTAL, [...path, "team", "total"], cur.team.total, bas.team.total); + + diffArray( + changes, cur.team.members, bas.team.members, [...path, "team", "members"], seg, + L.TEAM_MEMBER, (m) => m.role, diffTeamMember, + ); + diffArray( + changes, cur.periods, bas.periods, [...path, "periods"], seg, + L.PERIOD, (p) => p.start_date, diffPeriod, + ); + diffArray( + changes, cur.technology_stacks, bas.technology_stacks, [...path, "technology_stacks"], seg, + L.TECH_STACK, (t) => t.name, diffTechStack, + ); + diffArray( + changes, cur.phases, bas.phases, [...path, "phases"], seg, + L.PHASE, (p) => p, diffPhase, + ); +} + +function diffClient( + changes: CareerChange[], + cur: CareerClientForm, + bas: CareerClientForm, + path: (string | number)[], + seg: string[], +): void { + pushScalar(changes, seg, L.CLIENT_NAME, [...path, "name"], cur.name, bas.name); + pushScalar(changes, seg, L.HAS_CLIENT, [...path, "has_client"], cur.has_client, bas.has_client); + pushScalar(changes, seg, L.IS_VACATION, [...path, "is_vacation"], cur.is_vacation, bas.is_vacation); + pushScalar(changes, seg, L.VACATION_START_DATE, [...path, "vacation_start_date"], cur.vacation_start_date, bas.vacation_start_date); + pushScalar(changes, seg, L.VACATION_END_DATE, [...path, "vacation_end_date"], cur.vacation_end_date, bas.vacation_end_date); + pushScalar(changes, seg, L.VACATION_IS_CURRENT, [...path, "vacation_is_current"], cur.vacation_is_current, bas.vacation_is_current); + pushScalar(changes, seg, L.VACATION_DESCRIPTION, [...path, "vacation_description"], cur.vacation_description, bas.vacation_description); + + diffArray( + changes, cur.projects, bas.projects, [...path, "projects"], seg, + L.PROJECT, (p) => p.name, diffProject, + ); +} + +function diffExperience( + changes: CareerChange[], + cur: CareerExperienceForm, + bas: CareerExperienceForm, + path: (string | number)[], + seg: string[], +): void { + pushScalar(changes, seg, L.COMPANY, [...path, "company"], cur.company, bas.company); + pushScalar(changes, seg, L.BUSINESS_DESCRIPTION, [...path, "business_description"], cur.business_description, bas.business_description); + pushScalar(changes, seg, L.START_DATE, [...path, "start_date"], cur.start_date, bas.start_date); + pushScalar(changes, seg, L.END_DATE, [...path, "end_date"], cur.end_date, bas.end_date); + pushScalar(changes, seg, L.IS_CURRENT, [...path, "is_current"], cur.is_current, bas.is_current); + pushScalar(changes, seg, L.EMPLOYEE_COUNT, [...path, "employee_count"], cur.employee_count, bas.employee_count); + pushScalar(changes, seg, L.CAPITAL, [...path, "capital"], cur.capital, bas.capital); + pushScalar(changes, seg, L.CAPITAL_UNIT, [...path, "capital_unit"], cur.capital_unit, bas.capital_unit); + pushScalar(changes, seg, L.IS_IT_COMPANY, [...path, "is_it_company"], cur.is_it_company, bas.is_it_company); + pushScalar(changes, seg, L.DESCRIPTION, [...path, "description"], cur.description, bas.description); + + diffArray( + changes, cur.clients, bas.clients, [...path, "clients"], seg, + L.CLIENT, (c) => c.name, diffClient, + ); +} + +function diffQualification( + changes: CareerChange[], + cur: ResumeQualificationItem, + bas: ResumeQualificationItem, + path: (string | number)[], + seg: string[], +): void { + pushScalar(changes, seg, L.QUALIFICATION_NAME, [...path, "name"], cur.name, bas.name); + pushScalar(changes, seg, L.ACQUIRED_DATE, [...path, "acquired_date"], cur.acquired_date, bas.acquired_date); +} + +/** + * 編集中フォーム(form)と保存済みスナップショット(baseline)の差分を変更点リストにする。 + * 変更が無ければ空配列を返す。 + */ +export function buildCareerChanges( + form: CareerFormState, + baseline: CareerFormState, +): CareerChange[] { + const changes: CareerChange[] = []; + + pushScalar(changes, [], L.FULL_NAME, ["full_name"], form.full_name, baseline.full_name); + pushScalar(changes, [], L.CAREER_SUMMARY, ["career_summary"], form.career_summary, baseline.career_summary); + pushScalar(changes, [], L.SELF_PR, ["self_pr"], form.self_pr, baseline.self_pr); + + diffArray( + changes, form.experiences, baseline.experiences, ["experiences"], [], + L.EXPERIENCE, (e) => e.company, diffExperience, + ); + diffArray( + changes, form.qualifications, baseline.qualifications, ["qualifications"], [], + L.QUALIFICATION, (q) => q.name, diffQualification, + ); + + return changes; +} diff --git a/frontend/src/utils/setAtPath.test.ts b/frontend/src/utils/setAtPath.test.ts new file mode 100644 index 00000000..4ff2934e --- /dev/null +++ b/frontend/src/utils/setAtPath.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; + +import { insertAtPath, removeAtPath, setAtPath } from "./setAtPath"; + +describe("setAtPath", () => { + it("オブジェクトの 1 フィールドを更新し、元オブジェクトを破壊しない", () => { + const original = { a: 1, b: { c: 2 } }; + const next = setAtPath(original, ["b", "c"], 99); + expect(next).toEqual({ a: 1, b: { c: 99 } }); + expect(original.b.c).toBe(2); // 非破壊 + expect(next).not.toBe(original); + expect(next.b).not.toBe(original.b); + }); + + it("配列の index を更新する", () => { + const original = { items: [{ name: "x" }, { name: "y" }] }; + const next = setAtPath(original, ["items", 1, "name"], "z"); + expect(next.items[1].name).toBe("z"); + expect(original.items[1].name).toBe("y"); + expect(next.items[0]).toBe(original.items[0]); // 触らない要素は参照共有 + }); + + it("空パスはルート置換になる", () => { + expect(setAtPath({ a: 1 }, [], { b: 2 })).toEqual({ b: 2 }); + }); +}); + +describe("removeAtPath", () => { + it("配列要素を取り除く", () => { + const original = { items: ["a", "b", "c"] }; + const next = removeAtPath(original, ["items", 1]); + expect(next.items).toEqual(["a", "c"]); + expect(original.items).toEqual(["a", "b", "c"]); // 非破壊 + }); + + it("ネストした配列要素を取り除く", () => { + const original = { groups: [{ items: ["a", "b"] }] }; + const next = removeAtPath(original, ["groups", 0, "items", 0]); + expect(next.groups[0].items).toEqual(["b"]); + }); +}); + +describe("insertAtPath", () => { + it("配列の指定位置に要素を挿入する", () => { + const original = { items: ["a", "c"] }; + const next = insertAtPath(original, ["items", 1], "b"); + expect(next.items).toEqual(["a", "b", "c"]); + expect(original.items).toEqual(["a", "c"]); // 非破壊 + }); + + it("末尾を超える index への挿入は末尾に追加される", () => { + const original = { items: ["a"] }; + const next = insertAtPath(original, ["items", 5], "b"); + expect(next.items).toEqual(["a", "b"]); + }); +}); diff --git a/frontend/src/utils/setAtPath.ts b/frontend/src/utils/setAtPath.ts new file mode 100644 index 00000000..c03400ad --- /dev/null +++ b/frontend/src/utils/setAtPath.ts @@ -0,0 +1,92 @@ +/** + * フォーム値(プリミティブ/配列/プレーンオブジェクトのみで構成)に対し、 + * パス指定で 1 点だけを更新・挿入・削除する immutable な純関数群。 + * + * 経歴書の「変更点を項目ごとに baseline へ戻す(ロールバック)」処理で用いる。 + * 元のオブジェクトは一切破壊せず、辿った経路だけ浅いコピーした新しい値を返す。 + */ + +type Path = ReadonlyArray; + +/** + * `path` が指す位置の値を `value` で置き換えた新しいオブジェクトを返す。 + * path が空配列のときは `value` 自体を返す(ルート置換)。 + */ +export function setAtPath(target: T, path: Path, value: unknown): T { + if (path.length === 0) return value as T; + const [head, ...rest] = path; + + if (Array.isArray(target)) { + const index = head as number; + const copy = target.slice(); + copy[index] = setAtPath(copy[index], rest, value); + return copy as unknown as T; + } + + const obj = target as Record; + return { + ...obj, + [head]: setAtPath(obj[head as string], rest, value), + } as unknown as T; +} + +/** + * `path` の末尾セグメントが指す配列要素を取り除いた新しいオブジェクトを返す。 + * 経歴書で「追加された項目」を baseline 状態(=項目が無い状態)へ戻すのに使う。 + */ +export function removeAtPath(target: T, path: Path): T { + if (path.length === 0) return target; + const [head, ...rest] = path; + + if (rest.length === 0) { + if (Array.isArray(target)) { + const copy = target.slice(); + copy.splice(head as number, 1); + return copy as unknown as T; + } + // 配列以外への remove は今回の用途では発生しない(呼び出し側がパスを保証する)。 + return target; + } + + if (Array.isArray(target)) { + const copy = target.slice(); + copy[head as number] = removeAtPath(copy[head as number], rest); + return copy as unknown as T; + } + + const obj = target as Record; + return { + ...obj, + [head]: removeAtPath(obj[head as string], rest), + } as unknown as T; +} + +/** + * `path` の末尾セグメントが指す位置に `value` を挿入した新しいオブジェクトを返す。 + * 経歴書で「削除された項目」を baseline の値で復元するのに使う。 + */ +export function insertAtPath(target: T, path: Path, value: unknown): T { + if (path.length === 0) return target; + const [head, ...rest] = path; + + if (rest.length === 0) { + if (Array.isArray(target)) { + const copy = target.slice(); + copy.splice(head as number, 0, value); + return copy as unknown as T; + } + return target; + } + + if (Array.isArray(target)) { + const copy = target.slice(); + copy[head as number] = insertAtPath(copy[head as number], rest, value); + return copy as unknown as T; + } + + const obj = target as Record; + return { + ...obj, + [head]: insertAtPath(obj[head as string], rest, value), + } as unknown as T; +} From 55b0fba5b06da85fcaf0c3ed9ddcf4905705829f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 13:02:58 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(resume):=20=E7=B5=8C=E6=AD=B4=E6=9B=B8?= =?UTF-8?q?=20=E4=BF=9D=E5=AD=98=E6=99=82=E3=81=AE=E5=B7=A6=E5=8F=B3=20dif?= =?UTF-8?q?f=20=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=EF=BC=88VSCod?= =?UTF-8?q?e=20=E9=A2=A8=EF=BC=89=E3=81=AB=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第1弾のフィールド要約ダイアログを、PDF プレビューのレイアウトで左右に並べる diff モーダルに作り替えた。左=保存済み / 右=編集中を整形 HTML で並べ、変更箇所を 緑(追加)/赤(削除)/黄(修正)でハイライト。変更点サイドバーから矢印(↩)で項目別ロールバック。 主な変更: - backend: POST /api/resumes/preview(認証・DB非更新)を新設し整形 HTML+CSS を返す。 _build_html の各値ノードに data-fp(careerDiff パスと一致)、各項目に data-unit を付与 (PDF 出力は不変)。CSS 読込を _load_css(for_screen) に分離。 - frontend: CareerDiffModal(iframe srcdoc・sandbox)/ useResumeDiffPreview(baseline 1回・ 編集中 debounce)/ diffHighlight(着色・変更なし領域の折りたたみ・削除プレースホルダ)を追加。 CareerSaveConfirmDialog は廃止。careerDiff / setAtPath は流用。 - 変更なし項目は
で折りたたみ、削除項目は編集中側に「(削除)…」スタブを表示。 検証: frontend は vitest(238)/eslint/messages-lint/build green。 backend は新規エンドポイント/生成系のテストを追加(実行は nix/WeasyPrint 環境が必要)。 https://claude.ai/code/session_01NKa8AgchLkcdc3fXi737AF --- backend/app/routers/resumes.py | 27 +- backend/app/schemas/__init__.py | 2 + backend/app/schemas/resume.py | 12 + .../pdf/generators/resume_generator.py | 136 +++++++--- backend/tests/test_endpoints.py | 77 ++++++ backend/tests/test_pdf_generator.py | 109 +++++++- frontend/src/api/paths.ts | 1 + frontend/src/api/resumes.ts | 24 ++ .../forms/CareerDiffModal.module.css | 245 ++++++++++++++++++ .../src/components/forms/CareerDiffModal.tsx | 224 ++++++++++++++++ .../src/components/forms/CareerResumeForm.tsx | 17 +- .../forms/CareerSaveConfirmDialog.module.css | 133 ---------- .../forms/CareerSaveConfirmDialog.tsx | 96 ------- frontend/src/constants/messages.ts | 21 +- .../hooks/career/useResumeDiffPreview.test.ts | 77 ++++++ .../src/hooks/career/useResumeDiffPreview.ts | 110 ++++++++ frontend/src/utils/diffHighlight.test.ts | 185 +++++++++++++ frontend/src/utils/diffHighlight.ts | 211 +++++++++++++++ 18 files changed, 1432 insertions(+), 275 deletions(-) create mode 100644 frontend/src/components/forms/CareerDiffModal.module.css create mode 100644 frontend/src/components/forms/CareerDiffModal.tsx delete mode 100644 frontend/src/components/forms/CareerSaveConfirmDialog.module.css delete mode 100644 frontend/src/components/forms/CareerSaveConfirmDialog.tsx create mode 100644 frontend/src/hooks/career/useResumeDiffPreview.test.ts create mode 100644 frontend/src/hooks/career/useResumeDiffPreview.ts create mode 100644 frontend/src/utils/diffHighlight.test.ts create mode 100644 frontend/src/utils/diffHighlight.ts diff --git a/backend/app/routers/resumes.py b/backend/app/routers/resumes.py index 8f8e4180..b305b56f 100644 --- a/backend/app/routers/resumes.py +++ b/backend/app/routers/resumes.py @@ -9,11 +9,19 @@ from ..db import get_db from ..models import Resume, User from ..repositories import ResumeRepository -from ..schemas import ResumeCreate, ResumeResponse, ResumeUpdate +from ..schemas import ( + ResumeCreate, + ResumePreviewResponse, + ResumeResponse, + ResumeUpdate, +) from ..services.markdown.generators.resume_generator import ( build_resume_markdown, ) -from ..services.pdf.generators.resume_generator import build_resume_pdf +from ..services.pdf.generators.resume_generator import ( + build_resume_pdf, + build_resume_preview, +) from .download_utils import stream_markdown, stream_pdf router = APIRouter(prefix="/api/resumes", tags=["resumes"]) @@ -60,6 +68,21 @@ def create_resume( ) from exc +@router.post("/preview", response_model=ResumePreviewResponse) +def preview_resume( + payload: ResumeCreate, + current_user: User = Depends(get_current_user), +) -> ResumePreviewResponse: + """保存せずに、職務経歴書を PDF と同じレイアウトに整形した HTML と画面用 CSS を返す。 + + 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。HTML 内の各値ノードには + form パス(``data-fp``)が付与され、FE が変更箇所のハイライト・スクロール先特定に使う。 + DB は更新しない。WeasyPrint を通さず HTML 文字列生成のみのため軽量。 + """ + html, css = build_resume_preview(payload.model_dump()) + return ResumePreviewResponse(html=html, css=css) + + @router.get("/latest", response_model=ResumeResponse) def get_latest_resume( db: Session = Depends(get_db), diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index dc6b33e1..c207a940 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -29,6 +29,7 @@ Project, ProjectTeam, ResumeCreate, + ResumePreviewResponse, ResumeQualificationItem, ResumeResponse, ResumeUpdate, @@ -58,6 +59,7 @@ "Project", "ProjectTeam", "ResumeCreate", + "ResumePreviewResponse", "ResumeQualificationItem", "ResumeResponse", "ResumeUpdate", diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index 54c72e76..b15b335d 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -246,3 +246,15 @@ class ResumeResponse(ResumeBase): def serialize_as_jst(self, dt: datetime) -> str: """UTC datetime を JST (UTC+9) の ISO 8601 文字列にシリアライズする。""" return to_jst(dt).isoformat() + + +class ResumePreviewResponse(BaseModel): + """保存前プレビュー(左右 diff 表示)用の整形済み HTML と画面用 CSS。 + + DB を更新せず、編集中 payload を PDF と同じレイアウトに整形した HTML を返す。 + HTML 内の各値ノードには form パス(``data-fp``)が付与され、FE が変更箇所の + ハイライト・スクロール先特定に使う。 + """ + + html: str + css: str diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index 56ad0c5e..64e86866 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -1,3 +1,4 @@ +import re from datetime import datetime from html import escape as _html_escape from pathlib import Path @@ -21,6 +22,9 @@ Path(__file__).resolve().parent.parent.parent.parent / "fonts" / "NotoSansJP-Regular.ttf" ) +# 画面プレビュー(左右 diff)で除去する PDF 専用フォント定義。url 解決が画面では不要なため。 +_FONT_FACE_RE = re.compile(r"@font-face\s*\{[^}]*\}") + def _esc(text: str) -> str: """HTMLエスケープのショートカット""" @@ -32,6 +36,17 @@ def _md(text: str) -> str: return markdown.markdown(str(text), extensions=["tables"]) +def _fp(*segments) -> str: + """form パス属性(data-fp)の値を組み立てる。 + + frontend `utils/careerDiff.ts` の change.path を "." 連結した文字列と一致させる契約。 + 例: _fp("experiences", 0, "clients", 1, "projects", 0, "role") + → "experiences.0.clients.1.projects.0.role" + 左右 diff のハイライト・スクロール先特定に使う。WeasyPrint の PDF 出力には影響しない。 + """ + return ".".join(str(s) for s in segments) + + def _format_period(start: str, end: str, is_current: bool) -> str: """1 期間をフォーマットする。在籍中は end を "" で受ける契約(Experience 用)。""" s = start.replace("-", " 年 ") + " 月" if "-" in start else start @@ -54,8 +69,8 @@ def _format_periods(periods: list) -> str: return "、".join(parts) -def _build_project_html(project) -> str: - """プロジェクト1件分のHTMLを組み立てる""" +def _build_project_html(project, fp: str) -> str: + """プロジェクト1件分のHTMLを組み立てる(fp = このプロジェクトの data-fp プレフィックス)""" # ヘッダー(3行構成: 期間/プロジェクト名、役割、工程) name = _a(project, "name") periods = _a(project, "periods", []) @@ -66,18 +81,19 @@ def _build_project_html(project) -> str: line1_parts: list[str] = [] period_str = _format_periods(periods) if period_str: - line1_parts.append(period_str) + line1_parts.append(f'{period_str}') if name: - line1_parts.append(_esc(name)) + line1_parts.append(f'{_esc(name)}') line1 = " / ".join(line1_parts) if line1_parts else "" # 2行目: 役割 - line2 = f"役割:{_esc(role)}" if role else "" + line2 = f'役割:{_esc(role)}' if role else "" # 3行目: 工程 line3 = "" if phases: - line3 = f"工程:{'/'.join(_esc(p) for p in phases)}" + joined = "/".join(_esc(p) for p in phases) + line3 = f'工程:{joined}' header_lines = [ln for ln in [line1, line2, line3] if ln] header_html = "" @@ -118,18 +134,18 @@ def _build_project_html(project) -> str: team_text = "
".join(team_parts) if team_parts else "-" return ( - f'
{header_html}' + f'
{header_html}' f'' f"" - f'' - f'' - f'' + f'' + f'' + f'' f"
業務内容開発環境体制
{left_content}{right_content}{team_text}
{left_content}{right_content}{team_text}
" ) -def _build_vacation_html(client) -> str: - """休暇エントリ1件分のHTMLを組み立てる(期間 + 詳細)。""" +def _build_vacation_html(client, fp: str) -> str: + """休暇エントリ1件分のHTMLを組み立てる(期間 + 詳細)。fp = この取引先の data-fp プレフィックス。""" period = _format_period( _a(client, "vacation_start_date"), _a(client, "vacation_end_date", ""), @@ -139,15 +155,19 @@ def _build_vacation_html(client) -> str: description = _a(client, "vacation_description") body = _md(description) if description else "-" return ( - f'
' - f'
{header}
' - f'
{body}
' + f'
' + f'
{header}
' + f'
{body}
' f"
" ) def _build_html(resume: dict) -> str: - """職務経歴書データからHTML文字列を組み立てる""" + """職務経歴書データからHTML文字列を組み立てる。 + + 各値ノードに data-fp(form パス)属性を付与する。これは左右 diff プレビューでの + 変更ハイライト/スクロール先特定に使われる。PDF 生成では無視される(レイアウト不変)。 + """ parts: list[str] = [] # タイトル @@ -156,7 +176,7 @@ def _build_html(resume: dict) -> str: # 氏名 full_name = resume.get("full_name") or "" parts.append( - f'
氏名 {_esc(full_name)}
', + f'
氏名 {_esc(full_name)}
', ) # 記載日(日本時間) @@ -168,7 +188,10 @@ def _build_html(resume: dict) -> str: # 職務要約 parts.append("

■職務要約

") career_summary = resume.get("career_summary", "") - parts.append(f'
{_md(career_summary)}
') + parts.append( + '
' + f"{_md(career_summary)}
", + ) # 職務経歴 parts.append("

■職務経歴

") @@ -176,7 +199,7 @@ def _build_html(resume: dict) -> str: if not experiences: parts.append("

-

") else: - for exp in experiences: + for i, exp in enumerate(experiences): period = _format_period( _a(exp, "start_date"), _a(exp, "end_date", ""), @@ -192,15 +215,23 @@ def _build_html(resume: dict) -> str: capital_unit = _a(exp, "capital_unit", "千万円") capital = f"{_esc(capital_raw)}{_esc(capital_unit)}" if capital_raw else "" emp = f"{_esc(emp_raw)}名" if emp_raw else "" - info_parts = [f"事業内容:{biz}"] + info_parts = [ + f'事業内容:{biz}', + ] if capital: - info_parts.append(f"資本金:{capital}") + info_parts.append( + f'資本金:{capital}', + ) if emp: - info_parts.append(f"従業員数:{emp}") + info_parts.append( + f'従業員数:{emp}', + ) - parts.append('
') + parts.append(f'
') parts.append( - f'
' f"{period} {company}
", + '
' + f'{period} ' + f'{company}
', ) parts.append( f'
' f'{" ".join(info_parts)}
', @@ -211,23 +242,28 @@ def _build_html(resume: dict) -> str: # 非IT企業: 取引先/プロジェクトの代わりに詳細を表示 detail = _a(exp, "description") parts.append( - f'
{_md(detail)}
' if detail else "

-

", + f'
' + f"{_md(detail)}
" + if detail + else "

-

", ) else: # 取引先 → プロジェクト(後方互換含む正規化は shared に集約) clients = normalize_clients(exp) - for client in clients: + for j, client in enumerate(clients): + client_fp = f"experiences.{i}.clients.{j}" if _a(client, "is_vacation", False): - parts.append(_build_vacation_html(client)) + parts.append(_build_vacation_html(client, client_fp)) continue client_name = _a(client, "name") if client_name: parts.append( - f'
' f"取引先名:{_esc(client_name)}
", + '
取引先名:' + f'{_esc(client_name)}
', ) projects = _a(client, "projects", []) - for proj in projects: - parts.append(_build_project_html(proj)) + for k, proj in enumerate(projects): + parts.append(_build_project_html(proj, f"{client_fp}.projects.{k}")) parts.append("
") @@ -238,7 +274,7 @@ def _build_html(resume: dict) -> str: parts.append("

-

") else: parts.append('') - for q in qualifications: + for i, q in enumerate(qualifications): name = _esc(_a(q, "name")) raw_date = _a(q, "acquired_date") if raw_date and "-" in raw_date: @@ -251,27 +287,51 @@ def _build_html(resume: dict) -> str: ds = f"{_esc(raw_date)}取得" else: ds = f"{_esc(raw_date)}取得" if raw_date else "" - parts.append(f"") + parts.append( + f'' + f'' + f'', + ) parts.append("
{name}{ds}
{name}{ds}
") # 自己PR parts.append("

■自己PR

") self_pr = resume.get("self_pr", "") - parts.append(f'
{_md(self_pr)}
') + parts.append( + '
' f"{_md(self_pr)}
", + ) return "\n".join(parts) +def _load_css(for_screen: bool) -> str: + """resume.css を読み込む。 + + for_screen=True(左右 diff プレビュー): PDF 用フォント(@font-face)を除去し、 + body の font-family フォールバック(sans-serif)に任せる。 + for_screen=False(PDF 生成): {{ font_path }} を実フォントの URI に置換する。 + """ + css_text = _CSS_PATH.read_text(encoding="utf-8") + if for_screen: + return _FONT_FACE_RE.sub("", css_text) + return css_text.replace("{{ font_path }}", _FONT_PATH.as_uri()) + + +def build_resume_preview(resume: dict) -> tuple[str, str]: + """保存前プレビュー(左右 diff)用に (body HTML, 画面用 CSS) を返す。 + + HTML は data-fp 付き(`_build_html`)。CSS は PDF 専用フォントを除いた画面表示版。 + frontend は両者を iframe srcdoc に流し込んで baseline / 編集中を並べて描画する。 + """ + return _build_html(resume), _load_css(for_screen=True) + + def build_resume_pdf(resume: dict) -> bytes: """職務経歴書データからPDFバイト列を生成する""" html_body = _build_html(resume) # CSSテンプレートを読み込み、フォントパスを埋め込む - css_text = _CSS_PATH.read_text(encoding="utf-8") - css_text = css_text.replace( - "{{ font_path }}", - _FONT_PATH.as_uri(), - ) + css_text = _load_css(for_screen=False) full_html = ( "" diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 757a1dcb..2682891a 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -10,6 +10,7 @@ "method,path", [ ("post", "/api/resumes"), + ("post", "/api/resumes/preview"), ("get", "/api/resumes/latest"), ], ) @@ -305,3 +306,79 @@ def test_resume_not_found(client: TestClient) -> None: headers=headers, ) assert resp.status_code == 404 + + +# ── 保存前プレビュー(左右 diff 用 HTML)──────────────────────── + + +def test_resume_preview_returns_annotated_html(client: TestClient) -> None: + """保存せずに整形 HTML(data-fp 付き)と CSS を返す。""" + headers = auth_header(client, "resume-preview-user") + resp = client.post( + "/api/resumes/preview", + json={ + "full_name": "山田太郎", + "career_summary": "キャリアサマリー", + "self_pr": "自己PR", + "experiences": [ + { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2020-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "name": "取引先A", + "has_client": True, + "projects": [ + { + "name": "案件X", + "role": "SE", + "periods": [ + { + "start_date": "2020-04", + "end_date": "2021-03", + "is_current": False, + } + ], + } + ], + } + ], + } + ], + "qualifications": [{"acquired_date": "2020-04", "name": "応用情報技術者"}], + }, + headers=headers, + ) + assert resp.status_code == 200 + body = resp.json() + html = body["html"] + # form パス(data-fp)が careerDiff のパス文字列と一致して付与されていること + assert 'data-fp="full_name"' in html + assert 'data-fp="experiences.0.company"' in html + assert 'data-fp="experiences.0.clients.0.name"' in html + assert 'data-fp="experiences.0.clients.0.projects.0.role"' in html + assert 'data-fp="qualifications.0.name"' in html + # 画面用 CSS は PDF 専用フォント定義を含まない + assert "@font-face" not in body["css"] + assert ".project-table" in body["css"] + + +def test_resume_preview_validation_error(client: TestClient) -> None: + """必須欠落(full_name 空)は 422 を返す。""" + headers = auth_header(client, "resume-preview-invalid-user") + resp = client.post( + "/api/resumes/preview", + json={ + "full_name": "", + "career_summary": "x", + "self_pr": "y", + "experiences": [], + "qualifications": [], + }, + headers=headers, + ) + assert resp.status_code == 422 diff --git a/backend/tests/test_pdf_generator.py b/backend/tests/test_pdf_generator.py index 7f7437a1..bab8d719 100644 --- a/backend/tests/test_pdf_generator.py +++ b/backend/tests/test_pdf_generator.py @@ -1,6 +1,10 @@ import base64 -from app.services.pdf.generators.resume_generator import build_resume_pdf +from app.services.pdf.generators.resume_generator import ( + _build_html, + build_resume_pdf, + build_resume_preview, +) from app.services.pdf.utils.pdf_utils import ( decode_photo as _decode_photo, ) @@ -76,6 +80,109 @@ def test_build_resume_pdf_with_non_it_and_vacation() -> None: assert len(pdf_bytes) > 100 +def test_build_html_annotates_data_fp() -> None: + """_build_html が careerDiff のパスと一致する data-fp を各値ノードに付与する。""" + payload = { + "full_name": "山田太郎", + "career_summary": "要約", + "self_pr": "PR", + "qualifications": [{"acquired_date": "2020-04", "name": "応用情報技術者"}], + "experiences": [ + { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2020-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "name": "取引先A", + "has_client": True, + "projects": [ + { + "name": "案件X", + "role": "SE", + "periods": [ + {"start_date": "2020-04", "end_date": "2021-03"} + ], + } + ], + } + ], + } + ], + } + + html = _build_html(payload) + + assert 'data-fp="full_name"' in html + assert 'data-fp="career_summary"' in html + assert 'data-fp="self_pr"' in html + assert 'data-fp="experiences.0.company"' in html + assert 'data-fp="experiences.0.business_description"' in html + assert 'data-fp="experiences.0.clients.0.name"' in html + assert 'data-fp="experiences.0.clients.0.projects.0.role"' in html + assert 'data-fp="experiences.0.clients.0.projects.0.technology_stacks"' in html + assert 'data-fp="qualifications.0.name"' in html + # 折りたたみ用の項目コンテナ(data-unit)も付与される + assert 'data-unit="experiences.0"' in html + assert 'data-unit="experiences.0.clients.0.projects.0"' in html + assert 'data-unit="qualifications.0"' in html + + +def test_build_resume_pdf_still_works_with_annotations() -> None: + """data-fp 付与後も PDF 生成は従来どおり成功する(属性はレイアウトに影響しない)。""" + payload = { + "full_name": "山田太郎", + "career_summary": "要約", + "self_pr": "PR", + "qualifications": [], + "experiences": [ + { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2020-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "name": "取引先A", + "has_client": True, + "projects": [ + { + "name": "案件X", + "role": "SE", + "periods": [ + {"start_date": "2020-04", "end_date": "2021-03"} + ], + } + ], + } + ], + } + ], + } + pdf_bytes = build_resume_pdf(payload) + assert pdf_bytes.startswith(b"%PDF") + + +def test_build_resume_preview_returns_html_and_screen_css() -> None: + """プレビューは (data-fp 付き HTML, 画面用 CSS) を返し、CSS は @font-face を含まない。""" + payload = { + "full_name": "山田太郎", + "career_summary": "要約", + "self_pr": "PR", + "qualifications": [], + "experiences": [], + } + html, css = build_resume_preview(payload) + assert 'data-fp="full_name"' in html + assert "@font-face" not in css + assert ".company" in css + + def test_parse_date_ym() -> None: assert _parse_date_ym("2020-04") == ("2020", "4") assert _parse_date_ym("2020-12-01") == ("2020", "12") diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts index 16fecbad..bba168a9 100644 --- a/frontend/src/api/paths.ts +++ b/frontend/src/api/paths.ts @@ -30,6 +30,7 @@ export const PATHS = { resumes: { base: "/api/resumes", latest: "/api/resumes/latest", + preview: "/api/resumes/preview", byId: (id: string) => `/api/resumes/${id}`, pdf: (id: string) => `/api/resumes/${id}/pdf`, markdown: (id: string) => `/api/resumes/${id}/markdown`, diff --git a/frontend/src/api/resumes.ts b/frontend/src/api/resumes.ts index 8ff87fd7..68727db4 100644 --- a/frontend/src/api/resumes.ts +++ b/frontend/src/api/resumes.ts @@ -3,6 +3,17 @@ import { downloadBlob, getBlobUrl } from "./download"; import { PATHS } from "./paths"; import type { ResumeCreate, ResumeResponse } from "./types"; +/** + * 保存前プレビュー(左右 diff 表示)のレスポンス。 + * backend `schemas/resume.py:ResumePreviewResponse` のミラー(OpenAPI 生成物には未反映のため手書き)。 + */ +export type ResumePreviewResponse = { + /** PDF と同じレイアウトに整形した HTML(各値ノードに data-fp 付き) */ + html: string; + /** 画面表示用 CSS(PDF 専用フォント定義を除いたもの) */ + css: string; +}; + export function getLatestCareerResume(): Promise { return request(PATHS.resumes.latest); } @@ -39,3 +50,16 @@ export function downloadCareerResumeMarkdown(id: string): Promise { export function getCareerResumePdfBlobUrl(id: string): Promise { return getBlobUrl(PATHS.resumes.pdf(id)); } + +/** + * 保存せずに、職務経歴書を整形した HTML(data-fp 付き)と画面用 CSS を取得する。 + * 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。 + */ +export function getCareerResumePreview( + payload: ResumeCreate, +): Promise { + return request(PATHS.resumes.preview, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/frontend/src/components/forms/CareerDiffModal.module.css b/frontend/src/components/forms/CareerDiffModal.module.css new file mode 100644 index 00000000..3befaf3a --- /dev/null +++ b/frontend/src/components/forms/CareerDiffModal.module.css @@ -0,0 +1,245 @@ +.overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal { + background: var(--bg-card); + border-radius: 12px; + width: 94vw; + height: 92vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + padding: 1rem 1.25rem 0.5rem; +} + +.title { + margin: 0 0 0.25rem; + font-size: 1.1rem; +} + +.description { + margin: 0; + font-size: 0.82rem; + color: var(--text-muted, #666); + line-height: 1.5; +} + +/* 左ペイン / 右ペイン / サイドバー の 3 カラム。 */ +.body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1fr 320px; + gap: 0.75rem; + padding: 0.5rem 1rem 0.75rem; +} + +.pane { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + overflow: hidden; + position: relative; +} + +.paneHead { + flex-shrink: 0; + padding: 0.4rem 0.7rem; + font-size: 0.8rem; + font-weight: 700; + background: var(--bg-page, #f5f5f5); + border-bottom: 1px solid var(--border-color, #e0e0e0); +} + +.frame { + flex: 1; + border: none; + width: 100%; + background: #fff; +} + +.placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted, #888); + font-size: 0.85rem; + padding: 1rem; + text-align: center; +} + +/* 右ペインの再取得中インジケータ(既存表示の上に重ねる)。 */ +.refetching { + position: absolute; + top: 0.4rem; + right: 0.5rem; + font-size: 0.7rem; + color: var(--text-muted, #888); + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 999px; + padding: 0.1rem 0.5rem; +} + +.sidebar { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + overflow: hidden; +} + +.sidebarHead { + flex-shrink: 0; + padding: 0.4rem 0.7rem; + font-size: 0.8rem; + font-weight: 700; + background: var(--bg-page, #f5f5f5); + border-bottom: 1px solid var(--border-color, #e0e0e0); +} + +.list { + list-style: none; + margin: 0; + padding: 0.5rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.row { + display: flex; + align-items: stretch; + gap: 0.4rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + overflow: hidden; +} + +/* 行本体はクリックでスクロールするボタン。装飾はテキストブロック風に戻す。 */ +.rowMain { + flex: 1; + min-width: 0; + text-align: left; + background: transparent; + color: inherit; + border: none; + border-radius: 0; + padding: 0.45rem 0.55rem; + font-weight: 400; + cursor: pointer; +} + +.rowHead { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.2rem; +} + +.badge { + flex-shrink: 0; + font-size: 0.65rem; + font-weight: 600; + padding: 0.05rem 0.4rem; + border-radius: 999px; + color: #fff; +} + +.modified { + background: #ca8a04; +} + +.added { + background: #16a34a; +} + +.removed { + background: #dc2626; +} + +.label { + font-size: 0.78rem; + font-weight: 600; + word-break: break-word; +} + +.values { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + font-size: 0.78rem; +} + +.oldValue { + color: var(--text-muted, #888); + text-decoration: line-through; + word-break: break-word; +} + +.arrow { + color: var(--text-muted, #888); +} + +.newValue { + color: var(--text-primary, #111); + font-weight: 500; + word-break: break-word; +} + +/* VSCode の diff の revert 矢印風。各変更行を編集前(保存済み)へ戻すアイコンボタン。 */ +.rollback { + flex-shrink: 0; + align-self: center; + margin-right: 0.4rem; + width: 26px; + height: 26px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; + line-height: 1; + background: transparent; + color: var(--accent, #c96442); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; +} + +.rollback:hover:not(:disabled) { + background: rgba(201, 100, 66, 0.12); + border-color: var(--accent, #c96442); +} + +.empty { + margin: 0; + padding: 0.75rem; + color: var(--text-muted, #666); + font-size: 0.85rem; +} + +.actions { + flex-shrink: 0; + display: flex; + gap: 0.5rem; + justify-content: flex-end; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--border-color, #e0e0e0); +} diff --git a/frontend/src/components/forms/CareerDiffModal.tsx b/frontend/src/components/forms/CareerDiffModal.tsx new file mode 100644 index 00000000..7f6d90cb --- /dev/null +++ b/frontend/src/components/forms/CareerDiffModal.tsx @@ -0,0 +1,224 @@ +import { useMemo, useRef } from "react"; + +import { DIFF_DIALOG_MESSAGES as D } from "../../constants/messages"; +import type { CareerChange, ChangeKind } from "../../utils/careerDiff"; +import { + annotateHtml, + buildPathKindMap, + foldUnchanged, + injectRemovedPlaceholders, +} from "../../utils/diffHighlight"; +import styles from "./CareerDiffModal.module.css"; + +/** 種別ごとのバッジ文言。 */ +const KIND_LABEL: Record = { + modified: D.MODIFIED_LABEL, + added: D.ADDED_LABEL, + removed: D.REMOVED_LABEL, +}; + +/** + * iframe 内に注入する diff 着色 CSS。backend の resume.css に追記する形で srcDoc に埋め込む。 + * 緑=追加 / 赤=削除 / 黄=修正。VSCode 系 diff の配色に寄せる。 + */ +const DIFF_CSS = ` + body { margin: 0; padding: 12px 16px; background: #fff; } + .diff-mark { border-radius: 2px; padding: 0 1px; } + .diff-added { background: rgba(22,163,74,0.18); box-shadow: 0 0 0 1px rgba(22,163,74,0.45); } + .diff-removed { background: rgba(220,38,38,0.16); box-shadow: 0 0 0 1px rgba(220,38,38,0.40); } + .diff-modified { background: rgba(234,179,8,0.25); box-shadow: 0 0 0 1px rgba(234,179,8,0.50); } + details.fold { margin: 3px 0; } + summary.fold-summary { + cursor: pointer; list-style: none; font-size: 8pt; color: #6b7280; + background: #f3f4f6; border: 1px dashed #cbd5e1; border-radius: 4px; padding: 2px 8px; + } + summary.fold-summary::-webkit-details-marker { display: none; } + summary.fold-summary::before { content: "▸ "; } + details[open] > summary.fold-summary::before { content: "▾ "; } + .diff-removed-stub { + font-size: 8pt; color: #b91c1c; background: rgba(220,38,38,0.08); + border: 1px dashed rgba(220,38,38,0.5); border-radius: 4px; padding: 2px 6px; margin: 2px 0; + } + .diff-removed-stub td { border: none; color: #b91c1c; } +`; + +function buildSrcDoc(css: string, bodyHtml: string): string { + return ( + "" + + `${bodyHtml}` + ); +} + +/** + * 経歴書 保存時の左右 diff モーダル(VSCode フォーク風)。 + * + * 左=保存済み(baseline) / 右=編集中(form) を PDF と同じ整形レイアウト(iframe srcdoc)で並べ、 + * 変更箇所を緑/赤/黄でハイライト。右サイドバーに変更点リストを出し、各行で項目別ロールバック。 + */ +export function CareerDiffModal({ + changes, + baselineHtml, + editedHtml, + css, + loading, + error, + saving, + onConfirm, + onCancel, + onRollback, +}: { + changes: CareerChange[]; + baselineHtml: string | null; + editedHtml: string | null; + css: string; + loading: boolean; + error: string | null; + saving: boolean; + onConfirm: () => void; + onCancel: () => void; + onRollback: (change: CareerChange) => void; +}) { + const editedFrameRef = useRef(null); + const hasChanges = changes.length > 0; + + const pathKindMap = useMemo(() => buildPathKindMap(changes), [changes]); + + // 着色(annotateHtml)→ 変更なし領域を畳む(foldUnchanged)の順で整形する。 + const baselineDoc = useMemo(() => { + if (baselineHtml === null) return null; + return buildSrcDoc(css, foldUnchanged(annotateHtml(baselineHtml, pathKindMap), pathKindMap)); + }, [baselineHtml, css, pathKindMap]); + + const editedDoc = useMemo(() => { + if (editedHtml === null) return null; + // 着色 → 削除跡のプレースホルダ挿入 → 変更なし領域の折りたたみ、の順で整形する。 + const annotated = annotateHtml(editedHtml, pathKindMap); + const withStubs = injectRemovedPlaceholders(annotated, changes); + return buildSrcDoc(css, foldUnchanged(withStubs, pathKindMap)); + }, [editedHtml, css, pathKindMap, changes]); + + /** 変更点行クリックで、右ペイン(編集中)の該当ノードへスクロールする。 */ + const scrollToChange = (change: CareerChange) => { + const doc = editedFrameRef.current?.contentDocument; + if (!doc) return; + const fp = change.path.join("."); + const escaped = CSS.escape(fp); + const target = + doc.querySelector(`[data-fp="${escaped}"]`) ?? + doc.querySelector(`[data-fp^="${escaped}."]`); + target?.scrollIntoView({ block: "center", behavior: "smooth" }); + }; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={D.TITLE} + > +
+

{D.TITLE}

+

{D.DESCRIPTION}

+
+ +
+ {/* 左: 保存済み */} +
+
{D.PANE_BASELINE}
+ {baselineDoc ? ( +