diff --git a/frontend/src/components/forms/MarkdownDocumentView.tsx b/frontend/src/components/forms/MarkdownDocumentView.tsx
index a8d9b5a3..8a445278 100644
--- a/frontend/src/components/forms/MarkdownDocumentView.tsx
+++ b/frontend/src/components/forms/MarkdownDocumentView.tsx
@@ -1,20 +1,10 @@
-import { useCallback, useEffect, useState } from "react";
+import { useEffect, useState } from "react";
import { IMPORT_ASSIST_MESSAGES } from "../../constants/messages";
import { renderMarkdown } from "../../utils/markdown";
+import { useSelectionFill, type DocumentViewProps } from "./documentView";
import styles from "./ResumeSourceTracePanel.module.css";
-type Props = {
- /** 描画対象の Markdown ファイル */
- file: File;
- /** ズーム倍率(1 = 等倍)。フォントサイズの em 倍率として適用する。 */
- zoom?: number;
- /** 原本上で選択された文字列を流し込むコールバック */
- onFill: (text: string) => void;
- /** 描画エラー・エラー解除を親へ伝えるコールバック */
- onError: (message: string | null) => void;
-};
-
/**
* Markdown を整形描画し、テキスト選択を「流し込み」に変換する内側ビュー。
*
@@ -24,7 +14,7 @@ type Props = {
* フォーカスした入力欄)は useResumeImportAssist 側が担うため、ここは
* 「選択文字列を onFill に渡す」ことだけに専念する。
*/
-export default function MarkdownDocumentView({ file, zoom = 1, onFill, onError }: Props) {
+export default function MarkdownDocumentView({ file, zoom = 1, onFill, onError }: DocumentViewProps) {
const [html, setHtml] = useState
(null);
// ファイル本文をテキストとして読み込み、sanitize 済み HTML へ変換する。
@@ -50,13 +40,7 @@ export default function MarkdownDocumentView({ file, zoom = 1, onFill, onError }
};
}, [file, onError]);
- const handleSelection = useCallback(() => {
- const text = window.getSelection()?.toString() ?? "";
- if (!text.trim()) return;
- onFill(text);
- // 流し込み後は選択を解除し、次にどこを掴んだか分かりやすくする。
- window.getSelection()?.removeAllRanges();
- }, [onFill]);
+ const handleSelection = useSelectionFill(onFill);
if (html === null) {
return {IMPORT_ASSIST_MESSAGES.RENDERING}
;
diff --git a/frontend/src/components/forms/PdfDocumentView.tsx b/frontend/src/components/forms/PdfDocumentView.tsx
index 4594d9a3..af71e238 100644
--- a/frontend/src/components/forms/PdfDocumentView.tsx
+++ b/frontend/src/components/forms/PdfDocumentView.tsx
@@ -2,21 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Document, Page } from "react-pdf";
import { IMPORT_ASSIST_MESSAGES } from "../../constants/messages";
+import { useSelectionFill, type DocumentViewProps } from "./documentView";
import styles from "./ResumeSourceTracePanel.module.css";
// worker 設定(副作用 import)。このモジュールごと React.lazy で遅延ロードされる。
import "../../utils/pdfjs";
-type Props = {
- /** 描画対象の PDF */
- file: File;
- /** ズーム倍率(1 = カラム幅にフィット)。1 超で横スクロールが発生する。 */
- zoom?: number;
- /** PDF 上で選択された文字列を流し込むコールバック */
- onFill: (text: string) => void;
- /** 描画エラー・エラー解除を親へ伝えるコールバック */
- onError: (message: string | null) => void;
-};
-
/** ドキュメント全体の最小テキスト量。これ未満ならスキャン PDF とみなして警告する。 */
const MIN_TEXT_CHARS = 20;
@@ -28,7 +18,7 @@ const MIN_TEXT_CHARS = 20;
* 流し込み先の決定(最後にフォーカスした入力欄)は useResumeImportAssist 側が担うため、
* ここは「選択文字列を onFill に渡す」ことだけに専念する。
*/
-export default function PdfDocumentView({ file, zoom = 1, onFill, onError }: Props) {
+export default function PdfDocumentView({ file, zoom = 1, onFill, onError }: DocumentViewProps) {
const containerRef = useRef(null);
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
@@ -59,13 +49,7 @@ export default function PdfDocumentView({ file, zoom = 1, onFill, onError }: Pro
// 別の PDF への切り替えは呼び出し側が key で再マウントするため、ここでのリセットは不要。
- const handleSelection = useCallback(() => {
- const text = window.getSelection()?.toString() ?? "";
- if (!text.trim()) return;
- onFill(text);
- // 流し込み後は選択を解除し、次にどこを掴んだか分かりやすくする。
- window.getSelection()?.removeAllRanges();
- }, [onFill]);
+ const handleSelection = useSelectionFill(onFill);
const handleLoadSuccess = useCallback(
(pdf: { numPages: number }) => {
diff --git a/frontend/src/components/forms/documentView.ts b/frontend/src/components/forms/documentView.ts
new file mode 100644
index 00000000..92c90da7
--- /dev/null
+++ b/frontend/src/components/forms/documentView.ts
@@ -0,0 +1,34 @@
+import { useCallback } from "react";
+
+/**
+ * 原本ビュー({@link MarkdownDocumentView} / {@link PdfDocumentView})共通の Props。
+ *
+ * レンダリング技術(DOMPurify HTML / react-pdf)は実装ごとに異なるが、
+ * 「ファイルを受け取り、テキスト選択を onFill に流し、描画エラーを onError で伝える」
+ * という入出力契約は共通なのでここに集約する。
+ */
+export type DocumentViewProps = {
+ /** 描画対象のファイル */
+ file: File;
+ /** ズーム倍率(1 = 等倍 / カラム幅フィット) */
+ zoom?: number;
+ /** 原本上で選択された文字列を流し込むコールバック */
+ onFill: (text: string) => void;
+ /** 描画エラー・エラー解除を親へ伝えるコールバック */
+ onError: (message: string | null) => void;
+};
+
+/**
+ * テキスト選択を「流し込み」に変換する共通ハンドラを返す。
+ *
+ * 選択文字列があれば onFill に渡し、流し込み後は選択を解除して
+ * 次にどこを掴んだか分かりやすくする。Markdown / PDF 両ビューで共用する。
+ */
+export function useSelectionFill(onFill: (text: string) => void) {
+ return useCallback(() => {
+ const text = window.getSelection()?.toString() ?? "";
+ if (!text.trim()) return;
+ onFill(text);
+ window.getSelection()?.removeAllRanges();
+ }, [onFill]);
+}
diff --git a/frontend/src/components/forms/sections/CareerFormToolbar.tsx b/frontend/src/components/forms/sections/CareerFormToolbar.tsx
new file mode 100644
index 00000000..13dd5da9
--- /dev/null
+++ b/frontend/src/components/forms/sections/CareerFormToolbar.tsx
@@ -0,0 +1,112 @@
+import { UI_MESSAGES } from "../../../constants/messages";
+import { SaveIcon } from "../../icons/SaveIcon";
+import { EyeIcon } from "../../icons/EyeIcon";
+import { TrashIcon } from "../../icons/TrashIcon";
+import { PdfDownloadIcon } from "../../icons/PdfDownloadIcon";
+import { MarkdownDownloadIcon } from "../../icons/MarkdownDownloadIcon";
+import shared from "../../../styles/shared.module.css";
+import layout from "../CareerResumeForm.module.css";
+
+type Props = {
+ /** 保存ボタンのラベル(保存 / 更新)。 */
+ saveButtonText: string;
+ /** 保存可能か(マスタ含むデータが揃っているか)。 */
+ canSubmit: boolean;
+ /** 保存処理中。 */
+ saving: boolean;
+ /** プレビュー / PDF / Markdown の出力ボタンを非活性にするか。 */
+ exportDisabled: boolean;
+ /** PDF ダウンロード処理中。 */
+ downloading: boolean;
+ /** 削除ボタンを非活性にするか。 */
+ deleteDisabled: boolean;
+ onPreview: () => void;
+ onDownloadPdf: () => void;
+ onDownloadMarkdown: () => void;
+ onDelete: () => void;
+};
+
+/**
+ * 職務経歴書フォームのヘッダーツールバー(保存・プレビュー・PDF・Markdown・削除)。
+ *
+ * 表示専用。保存ボタンは form の submit、それ以外は親から渡されたハンドラを呼ぶだけで、
+ * 認証ゲートや出力ロジックは {@link useCareerExportActions} 側が担う。
+ */
+export function CareerFormToolbar({
+ saveButtonText,
+ canSubmit,
+ saving,
+ exportDisabled,
+ downloading,
+ deleteDisabled,
+ onPreview,
+ onDownloadPdf,
+ onDownloadMarkdown,
+ onDelete,
+}: Props) {
+ return (
+
+
{UI_MESSAGES.CAREER_RESUME_TITLE}
+
+ {/* ファイル取り込みは右カラムの原本ビュー(ドラッグ&ドロップ / クリック)に集約。 */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/constants/agentModels.test.ts b/frontend/src/constants/agentModels.test.ts
new file mode 100644
index 00000000..3dde631c
--- /dev/null
+++ b/frontend/src/constants/agentModels.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ AGENT_MODEL_OPTIONS,
+ getModelOption,
+ getModelOptionsByProvider,
+} from "./agentModels";
+
+/**
+ * agentModels の SSoT 整合テスト。
+ *
+ * モデルエイリアス集合の正本は backend `AgentModelAlias`(OpenAPI codegen 経由で
+ * `generated.ts` に反映)。`AGENT_MODEL_META` を `Record` で
+ * 型付けしているため網羅性はコンパイル時に保証されるが、本テストは「プロバイダ列分け
+ * の漏れ」「未知エイリアスのフォールバック」といった runtime 不変条件を固定する。
+ */
+describe("agentModels", () => {
+ it("全 option の alias は一意", () => {
+ const aliases = AGENT_MODEL_OPTIONS.map((o) => o.alias);
+ expect(new Set(aliases).size).toBe(aliases.length);
+ });
+
+ it("getModelOption は既知 alias の表示メタを返す", () => {
+ expect(getModelOption("sonnet").name).toBe("Sonnet 4.6");
+ expect(getModelOption("gemini-pro").provider).toBe("google");
+ });
+
+ it("getModelOption は未知 alias を先頭(haiku)にフォールバックする", () => {
+ // @ts-expect-error 型に無い値を渡して runtime フォールバックを検証する
+ expect(getModelOption("unknown-model").alias).toBe("haiku");
+ });
+
+ it("getModelOptionsByProvider は全 option を漏れなくいずれかの列に含む", () => {
+ // 新プロバイダのモデルが AGENT_PROVIDER_ORDER 漏れで列から落ちる drift を検知する
+ const grouped = getModelOptionsByProvider()
+ .flatMap((g) => g.options)
+ .map((o) => o.alias)
+ .sort();
+ const all = AGENT_MODEL_OPTIONS.map((o) => o.alias).sort();
+ expect(grouped).toEqual(all);
+ });
+});
diff --git a/frontend/src/constants/agentModels.ts b/frontend/src/constants/agentModels.ts
index 7efca32f..e9524142 100644
--- a/frontend/src/constants/agentModels.ts
+++ b/frontend/src/constants/agentModels.ts
@@ -42,8 +42,19 @@ export const AGENT_PROVIDER_ORDER: readonly AgentModelProvider[] = [
// 1 クレジット = ¥1 なので 1,000 クレジット = ¥1,000(手に取りやすい基準額)
export const CREDIT_ESTIMATE_REFERENCE = 1_000;
-export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
- {
+/**
+ * エイリアス → 表示メタデータ。`Record` で型付けすることで、
+ * backend が `AgentModelAlias`(OpenAPI codegen 経由で `generated.ts` に反映)へ
+ * 新モデルを追加した瞬間、ここにメタ未定義のままだと**コンパイルエラー**になる。
+ * これにより「モデル選択 UI に新モデルが出ない」同期忘れを型で防ぐ(BE 側の
+ * model_catalog ↔ AgentModelAlias drift ガードと対になる FE 側ガード)。
+ *
+ * `isPaid` の正本は backend `model_catalog.is_free`(その反転)。runtime では
+ * model_rates API(`is_free`)が SSoT で、ここは API 未取得時の静的フォールバック。
+ * 両者の乖離は agentModels.test.ts の drift テストで検出する。
+ */
+const AGENT_MODEL_META: Record = {
+ haiku: {
alias: "haiku",
provider: "anthropic",
name: "Haiku 4.5",
@@ -51,7 +62,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
tagline: AGENT_MODEL_MESSAGES.HAIKU_TAGLINE,
costHint: AGENT_MODEL_MESSAGES.HAIKU_COST,
},
- {
+ sonnet: {
alias: "sonnet",
provider: "anthropic",
name: "Sonnet 4.6",
@@ -59,7 +70,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
tagline: AGENT_MODEL_MESSAGES.SONNET_TAGLINE,
costHint: AGENT_MODEL_MESSAGES.SONNET_COST,
},
- {
+ "gpt-mini": {
alias: "gpt-mini",
provider: "openai",
name: "GPT-4o mini",
@@ -67,7 +78,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
tagline: AGENT_MODEL_MESSAGES.GPT_MINI_TAGLINE,
costHint: AGENT_MODEL_MESSAGES.GPT_MINI_COST,
},
- {
+ gpt: {
alias: "gpt",
provider: "openai",
name: "GPT-4.1",
@@ -75,7 +86,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
tagline: AGENT_MODEL_MESSAGES.GPT_TAGLINE,
costHint: AGENT_MODEL_MESSAGES.GPT_COST,
},
- {
+ "gemini-flash": {
alias: "gemini-flash",
provider: "google",
name: "Gemini 2.5 Flash",
@@ -83,7 +94,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
tagline: AGENT_MODEL_MESSAGES.GEMINI_FLASH_TAGLINE,
costHint: AGENT_MODEL_MESSAGES.GEMINI_FLASH_COST,
},
- {
+ "gemini-pro": {
alias: "gemini-pro",
provider: "google",
name: "Gemini 2.5 Pro",
@@ -91,7 +102,11 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
tagline: AGENT_MODEL_MESSAGES.GEMINI_PRO_TAGLINE,
costHint: AGENT_MODEL_MESSAGES.GEMINI_PRO_COST,
},
-];
+};
+
+// 表示順は Record の定義順(プロバイダごとに無料→有料)。getModelOptionsByProvider が
+// AGENT_PROVIDER_ORDER で列に振り分ける。
+export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = Object.values(AGENT_MODEL_META);
/** エイリアスから表示メタデータを引く。未知の値は先頭(haiku)にフォールバック。 */
export function getModelOption(alias: AgentModelAlias): AgentModelOption {
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index 7554d605..c28b9d1f 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -152,6 +152,8 @@ export const UI_MESSAGES = {
REPORT_ISSUE: "GitHub Issueに報告",
OPENS_IN_NEW_TAB: "(新しいタブで開きます)",
COPYRIGHT: "© 2026 DevForge",
+ // 職務経歴書ページのタイトル見出し
+ CAREER_RESUME_TITLE: "職務経歴書",
// 職務経歴書ヘッダーのアイコンボタン(aria-label / title 用)
RESUME_PREVIEW: "プレビュー",
RESUME_EXPORT_PDF: "PDF出力",
diff --git a/frontend/src/hooks/career/useCareerExportActions.ts b/frontend/src/hooks/career/useCareerExportActions.ts
new file mode 100644
index 00000000..481d7ff2
--- /dev/null
+++ b/frontend/src/hooks/career/useCareerExportActions.ts
@@ -0,0 +1,71 @@
+import {
+ downloadCareerResumeMarkdown,
+ downloadCareerResumePdf,
+ getCareerResumePdfBlobUrl,
+} from "../../api";
+import { SUCCESS_MESSAGES } from "../../constants/messages";
+import { usePdfActions } from "../usePdfActions";
+
+type Params = {
+ /** 認証状態。未ログインなら各操作はログイン導線へ流す。 */
+ isAuthenticated: boolean;
+ /** 保存済み経歴書の ID(未保存なら null)。 */
+ resumeId: string | null;
+ /** ローディング等で操作をブロックしたいときのフラグ。 */
+ formLocked: boolean;
+ /** 未ログイン時に開くログイン促進モーダル。 */
+ requestLogin: () => void;
+};
+
+/**
+ * 経歴書の出力操作(プレビュー / PDF / Markdown ダウンロード)を一元管理するフック。
+ *
+ * `usePdfActions` を内包し、職務経歴書の API バインドと「未ログインなら requestLogin、
+ * ログイン済みなら resumeId を渡して実行」という認証ゲートをまとめる。
+ * CareerResumeForm から出力系の責務を切り離して本体を薄く保つのが目的。
+ */
+export function useCareerExportActions({ isAuthenticated, resumeId, formLocked, requestLogin }: Params) {
+ const {
+ downloading,
+ previewUrl,
+ closePreview,
+ onDownloadPdf,
+ onDownloadMarkdown,
+ onPreviewPdf,
+ error,
+ success,
+ } = usePdfActions({
+ downloadPdf: downloadCareerResumePdf,
+ downloadMarkdown: downloadCareerResumeMarkdown,
+ getPdfBlobUrl: getCareerResumePdfBlobUrl,
+ });
+
+ const handlePreview = () => {
+ if (!isAuthenticated) return requestLogin();
+ if (resumeId) onPreviewPdf(resumeId);
+ };
+ const handleDownloadPdf = () => {
+ if (!isAuthenticated) return requestLogin();
+ if (resumeId) onDownloadPdf(resumeId, SUCCESS_MESSAGES.CAREER_PDF_DOWNLOADED);
+ };
+ const handleDownloadMarkdown = () => {
+ if (!isAuthenticated) return requestLogin();
+ if (resumeId) onDownloadMarkdown(resumeId);
+ };
+
+ // 未ログインでは要ログイン機能ボタンを活性にしてログイン導線にする。
+ // ログイン済みでは保存済み(resumeId あり)まで非活性。
+ const exportDisabled = formLocked || (isAuthenticated && !resumeId);
+
+ return {
+ downloading,
+ previewUrl,
+ closePreview,
+ handlePreview,
+ handleDownloadPdf,
+ handleDownloadMarkdown,
+ exportDisabled,
+ error,
+ success,
+ };
+}