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 2b62093f..ff93badb 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 @@ -41,6 +56,33 @@ def _format_period(start: str, end: str, is_current: bool) -> str: return f"{s}〜{e}" +def _format_period_html( + start: str, + end: str, + is_current: bool, + *, + fp: str, + start_key: str, + end_key: str, + current_key: str, +) -> str: + """1 期間を per-field の data-fp 付き span に分けてフォーマットする。 + + 開始日は ``{fp}.{start_key}``、終了側は在籍状況に応じて在職中なら ``{fp}.{current_key}`` + (「現在」テキスト)、それ以外は ``{fp}.{end_key}`` に紐づける。期間全体を start_date だけに + 紐づけると end_date / is_current の編集が左右 diff でハイライトされないため、フィールド単位で + 分割する。表示テキストは ``_format_period`` と同一なので PDF レイアウトは不変。 + """ + s = start.replace("-", " 年 ") + " 月" if "-" in start else start + start_span = f'{_esc(s)}' + if is_current: + end_span = f'現在' + else: + e = end.replace("-", " 年 ") + " 月" if "-" in end else end + end_span = f'{_esc(e)}' + return f"{start_span}〜{end_span}" + + def _format_periods(periods: list) -> str: """複数期間を「、」区切りで連結してフォーマットする(Project 用)。""" parts: list[str] = [] @@ -72,8 +114,8 @@ def _format_team(project) -> str: return f"{total_text}{member_text}" -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", []) @@ -84,21 +126,22 @@ 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行目: 役割(体制の人数情報を「、体制:…」として役割の右側に併記する) team_text = _format_team(project) - role_part = f"役割:{_esc(role)}" if role else "" - team_part = f"体制:{team_text}" if team_text else "" + role_part = f'役割:{_esc(role)}' if role else "" + team_part = f'体制:{team_text}' if team_text else "" line2 = "、".join(p for p in [role_part, team_part] if p) # 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 = "" @@ -125,35 +168,47 @@ def _build_project_html(project) -> str: # 体制は表のカラムではなく役割行に併記する(line2 で処理済み)。 return ( - f'
{header_html}' + f'
{header_html}' f'' f"" - f'' - f'' + f'' + f'' f"
業務内容スキルセット
{left_content}{right_content}
{left_content}{right_content}
" ) -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'
{header}
' - 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'
' f"{period} {company}
", + '
' + f"{period_html} " + f'{company}
', ) 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"") + 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..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 ? ( +