"
)
-def _build_vacation_html(client) -> str:
- """休暇エントリ1件分のHTMLを組み立てる(期間 + 詳細)。"""
- period = _format_period(
- _a(client, "vacation_start_date"),
- _a(client, "vacation_end_date", ""),
- _a(client, "vacation_is_current", False),
- )
- header = f"休暇 {_esc(period)}" if period else "休暇"
+def _build_vacation_html(client, fp: str) -> str:
+ """休暇エントリ1件分のHTMLを組み立てる(期間 + 詳細)。fp = この取引先の data-fp プレフィックス。"""
+ start = _a(client, "vacation_start_date")
+ if start:
+ period_html = _format_period_html(
+ start,
+ _a(client, "vacation_end_date", ""),
+ _a(client, "vacation_is_current", False),
+ fp=fp,
+ start_key="vacation_start_date",
+ end_key="vacation_end_date",
+ current_key="vacation_is_current",
+ )
+ header = f"休暇 {period_html}"
+ else:
+ header = "休暇"
description = _a(client, "vacation_description")
body = _md(description) if description else "-"
return (
- f'
'
+ f'
'
f''
- f'
{body}
'
+ f'
{body}
'
f"
"
)
def _build_html(resume: dict) -> str:
- """職務経歴書データからHTML文字列を組み立てる"""
+ """職務経歴書データからHTML文字列を組み立てる。
+
+ 各値ノードに data-fp(form パス)属性を付与する。これは左右 diff プレビューでの
+ 変更ハイライト/スクロール先特定に使われる。PDF 生成では無視される(レイアウト不変)。
+ """
parts: list[str] = []
# タイトル
@@ -162,7 +217,7 @@ def _build_html(resume: dict) -> str:
# 氏名
full_name = resume.get("full_name") or ""
parts.append(
- f'
氏名 {_esc(full_name)}
',
+ f'
氏名 {_esc(full_name)}
',
)
# 記載日(日本時間)
@@ -174,7 +229,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("
■職務経歴 ")
@@ -182,11 +240,15 @@ def _build_html(resume: dict) -> str:
if not experiences:
parts.append("
-
")
else:
- for exp in experiences:
- period = _format_period(
+ for i, exp in enumerate(experiences):
+ period_html = _format_period_html(
_a(exp, "start_date"),
_a(exp, "end_date", ""),
_a(exp, "is_current", False),
+ fp=f"experiences.{i}",
+ start_key="start_date",
+ end_key="end_date",
+ current_key="is_current",
)
company = _esc(_a(exp, "company"))
@@ -198,15 +260,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'",
+ '',
)
parts.append(
f'
' f'{" ".join(info_parts)}
',
@@ -217,23 +287,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("
")
@@ -244,7 +319,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:
@@ -257,27 +332,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"{name} {ds} ")
+ parts.append(
+ f''
+ f'{name} '
+ f'{ds} ',
+ )
parts.append("
")
# 自己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..abf072ca 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,152 @@ 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
+ # 期間はフィールド単位の span に分割し、start_date / end_date を個別に紐づける
+ # (end_date / is_current の編集も左右 diff でハイライトできるようにするため)
+ assert 'data-fp="experiences.0.start_date"' in html
+ assert 'data-fp="experiences.0.end_date"' 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_html_period_binds_is_current() -> None:
+ """在職中の期間は「現在」を is_current に、休暇期間は各日付フィールドに紐づける。"""
+ payload = {
+ "full_name": "山田太郎",
+ "career_summary": "要約",
+ "self_pr": "PR",
+ "qualifications": [],
+ "experiences": [
+ {
+ "company": "Example株式会社",
+ "business_description": "SES事業",
+ "start_date": "2020-04",
+ "end_date": "",
+ "is_current": True,
+ "is_it_company": True,
+ "clients": [
+ {
+ "name": "取引先A",
+ "has_client": True,
+ "is_vacation": True,
+ "vacation_start_date": "2021-01",
+ "vacation_end_date": "2021-03",
+ "vacation_is_current": False,
+ "vacation_description": "休養",
+ }
+ ],
+ }
+ ],
+ }
+
+ html = _build_html(payload)
+
+ # 在職中: 「現在」テキストは is_current に紐づく
+ assert 'data-fp="experiences.0.is_current">現在' in html
+ # 休暇期間も start/end が個別の span になる
+ assert 'data-fp="experiences.0.clients.0.vacation_start_date"' in html
+ assert 'data-fp="experiences.0.clients.0.vacation_end_date"' 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/e2e/career-dirty-indicator.spec.ts b/frontend/e2e/career-dirty-indicator.spec.ts
index 3ea29b10..86fed835 100644
--- a/frontend/e2e/career-dirty-indicator.spec.ts
+++ b/frontend/e2e/career-dirty-indicator.spec.ts
@@ -41,6 +41,18 @@ async function setupResumeApi(page: Page) {
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
+ // 保存前プレビュー(左右 diff モーダル)。整形 HTML と CSS を返す。
+ await page.route("**/api/resumes/preview", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ html: '
氏名 山田 太郎
',
+ css: "",
+ }),
+ }),
+ );
+
// 最新取得
await page.route("**/api/resumes/latest", (route) =>
route.fulfill({
@@ -232,8 +244,9 @@ test.describe("職務経歴書 未保存マーク", () => {
const dirtyCountAfterReturn = await page.getByTestId("dirty-dot").count();
expect(dirtyCountAfterReturn).toBeGreaterThanOrEqual(1);
- // 4. 保存 → 🔴 が全消失
+ // 4. 保存ボタン → 変更点 diff モーダルが開く → 「この内容で保存」で確定 → 🔴 が全消失
await page.getByRole("button", { name: /更新する|保存する/ }).click();
+ await page.getByRole("button", { name: "この内容で保存" }).click();
await expect(page.getByTestId("dirty-dot")).toHaveCount(0);
});
});
diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts
index dadb3415..15122729 100644
--- a/frontend/src/api/generated.ts
+++ b/frontend/src/api/generated.ts
@@ -436,6 +436,30 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/resumes/preview": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Preview Resume
+ * @description 保存せずに、職務経歴書を PDF と同じレイアウトに整形した HTML と画面用 CSS を返す。
+ *
+ * 左右 diff プレビュー(左=保存済み / 右=編集中)の描画に使う。HTML 内の各値ノードには
+ * form パス(``data-fp``)が付与され、FE が変更箇所のハイライト・スクロール先特定に使う。
+ * DB は更新しない。WeasyPrint を通さず HTML 文字列生成のみのため軽量。
+ */
+ post: operations["preview_resume_api_resumes_preview_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/resumes/{resume_id}": {
parameters: {
query?: never;
@@ -1313,6 +1337,20 @@ export interface components {
/** Self Pr */
self_pr: string;
};
+ /**
+ * ResumePreviewResponse
+ * @description 保存前プレビュー(左右 diff 表示)用の整形済み HTML と画面用 CSS。
+ *
+ * DB を更新せず、編集中 payload を PDF と同じレイアウトに整形した HTML を返す。
+ * HTML 内の各値ノードには form パス(``data-fp``)が付与され、FE が変更箇所の
+ * ハイライト・スクロール先特定に使う。
+ */
+ ResumePreviewResponse: {
+ /** Css */
+ css: string;
+ /** Html */
+ html: string;
+ };
/** ResumeQualificationItem */
ResumeQualificationItem: {
/** Acquired Date */
@@ -2233,6 +2271,39 @@ export interface operations {
};
};
};
+ preview_resume_api_resumes_preview_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ResumeCreate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ResumePreviewResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_resume_api_resumes__resume_id__get: {
parameters: {
query?: never;
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..93ebb98b 100644
--- a/frontend/src/api/resumes.ts
+++ b/frontend/src/api/resumes.ts
@@ -1,7 +1,11 @@
import { request } from "./client";
import { downloadBlob, getBlobUrl } from "./download";
import { PATHS } from "./paths";
-import type { ResumeCreate, ResumeResponse } from "./types";
+import type { ResumeCreate, ResumePreviewResponse, ResumeResponse } from "./types";
+
+// 保存前プレビュー(左右 diff 表示)のレスポンス型は OpenAPI 生成物(generated.ts)から
+// 取り込んだ ResumePreviewResponse を types.ts 経由で再エクスポートする。
+export type { ResumePreviewResponse };
export function getLatestCareerResume(): Promise
{
return request(PATHS.resumes.latest);
@@ -39,3 +43,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/api/types.ts b/frontend/src/api/types.ts
index 0fc03f60..76115802 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -59,6 +59,9 @@ export type ResumeResponse = Schemas["ResumeResponse"];
/** 職務経歴の作成/更新ペイロード。backend `schemas/resume.py:ResumeCreate`(ResumeBase 同形)。 */
export type ResumeCreate = Schemas["ResumeCreate"];
+/** 保存前プレビュー(左右 diff 表示)のレスポンス。backend `schemas/resume.py:ResumePreviewResponse`。 */
+export type ResumePreviewResponse = Schemas["ResumePreviewResponse"];
+
/** 職歴(1 社分)。backend `schemas/resume.py:Experience`。 */
export type Experience = Schemas["Experience-Output"];
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..bf55d039
--- /dev/null
+++ b/frontend/src/components/forms/CareerDiffModal.tsx
@@ -0,0 +1,230 @@
+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 (
+ {
+ // 保存処理中は背景クリックでの閉じ操作を無効化する(処理中状態を隠さない)。
+ if (!saving) onCancel();
+ }}
+ >
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-label={D.TITLE}
+ >
+
+
{D.TITLE}
+
{D.DESCRIPTION}
+
+
+
+ {/* 左: 保存済み */}
+
+ {D.PANE_BASELINE}
+ {baselineDoc ? (
+
+ ) : (
+ {D.BASELINE_EMPTY}
+ )}
+
+
+ {/* 右: 編集中 */}
+
+ {D.PANE_EDITED}
+ {error ? (
+ {error}
+ ) : editedDoc ? (
+
+ ) : (
+ {D.PREVIEW_LOADING}
+ )}
+ {loading && editedDoc && {D.PREVIEW_LOADING}
}
+
+
+ {/* 変更点サイドバー */}
+
+ {D.CHANGES_HEADING}
+ {hasChanges ? (
+
+ {changes.map((change) => (
+
+ scrollToChange(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}
+ )}
+
+
+ onRollback(change)}
+ disabled={saving}
+ aria-label={D.ROLLBACK}
+ title={D.ROLLBACK}
+ >
+ ↩
+
+
+ ))}
+
+ ) : (
+ {D.NO_CHANGES}
+ )}
+
+
+
+
+
+ {D.CONFIRM}
+
+
+ {D.CANCEL}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx
index bd177a01..9eb43cbe 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,
@@ -13,14 +13,17 @@ import { UI_MESSAGES } from "../../constants/messages";
import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMappers";
import { useCareerDirty } from "../../hooks/career/useCareerDirty";
import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout";
+import { useResumeDiffPreview } from "../../hooks/career/useResumeDiffPreview";
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 { CareerDiffModal } from "./CareerDiffModal";
import { Skeleton } from "../ui/Skeleton";
import { PdfPreviewModal } from "./PdfPreviewModal";
import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel";
@@ -32,6 +35,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 +74,18 @@ export function CareerResumeForm() {
/** 未保存マーク(🔴)の表示判定に使う dirty マップ */
const dirty = useCareerDirty(form, baseline);
+ /**
+ * baseline(保存済み)と form(編集中)の変更点リスト。左右 diff モーダルのサイドバーと
+ * ハイライト突合に使う。baseline が未ロード(null)のときは form 同士を比較して変更なし扱い。
+ */
+ const changes = useMemo(
+ () => buildCareerChanges(form, baseline ?? form),
+ [form, baseline],
+ );
+
+ /** 左右 diff モーダル用の整形 HTML プレビュー(保存済み / 編集中)。開いている間だけ取得する。 */
+ const preview = useResumeDiffPreview(form, baseline, showSaveConfirm);
+
const {
downloading,
previewUrl,
@@ -98,9 +115,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 +147,20 @@ export function CareerResumeForm() {
confirming={deleting}
/>
)}
+ {showSaveConfirm && (
+ setShowSaveConfirm(false)}
+ onRollback={(change) => setForm((prev) => change.rollback(prev))}
+ />
+ )}
{previewUrl && }