From 73eed3100c6a108cd8a29ff73e130cae6600f1ef Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Wed, 27 May 2026 19:49:25 +0900 Subject: [PATCH 1/5] =?UTF-8?q?project=20form=20=E8=AA=B2=E9=A1=8C=20?= =?UTF-8?q?=E6=88=90=E6=9E=9C=20=E5=AE=9F=E7=B8=BE=E3=82=92=E7=B5=B1?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0037_merge_project_caf_into_description.py | 50 +++++++++++++++++++ backend/app/models/resume.py | 5 +- backend/app/repositories/resume.py | 4 +- backend/app/schemas/resume.py | 5 +- .../markdown/generators/resume_generator.py | 12 ++--- .../pdf/generators/resume_generator.py | 12 ++--- backend/tests/test_delete_documents.py | 4 +- backend/tests/test_endpoints.py | 4 +- backend/tests/test_schemas.py | 5 +- frontend/e2e/career-dirty-indicator.spec.ts | 4 +- .../components/forms/ProjectModal.test.tsx | 8 +-- .../src/components/forms/ProjectModal.tsx | 26 ++-------- frontend/src/constants.ts | 4 +- frontend/src/formTypes.ts | 4 +- .../hooks/career/useProjectFormDirty.test.ts | 4 +- .../src/hooks/career/useProjectFormDirty.ts | 8 +-- .../hooks/career/useProjectModalForm.test.ts | 4 +- .../hooks/career/useProjectModalState.test.ts | 4 +- frontend/src/payloadBuilders.test.ts | 4 +- frontend/src/payloadBuilders.ts | 10 ++-- frontend/src/types.ts | 5 +- frontend/tests/payloadBuilders.test.cjs | 8 +-- 22 files changed, 90 insertions(+), 104 deletions(-) create mode 100644 backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py diff --git a/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py b/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py new file mode 100644 index 00000000..327a603c --- /dev/null +++ b/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py @@ -0,0 +1,50 @@ +"""merge resume_projects challenge/action/result into description(課題・行動・成果の統合) + +プロジェクトの「課題」「行動」「成果」は分割する必要がないため、単一の自由記述欄 +「詳細」(description) に統合した。これに伴い resume_projects の challenge / action / +result カラムを削除し、description カラムを新設する。 + +統合対象の既存本番データは存在しないため、データ移行(3 カラムの連結)は行わない。 + +libSQL (SQLite 互換) は ALTER COLUMN / DROP COLUMN を直接サポートしないため、 +batch_alter_table(テーブル再作成)でカラムを入れ替える。 + +Revision ID: 0037_merge_project_caf_into_description +Revises: 0036_rename_github_analysis_to_github_link +Create Date: 2026-05-27 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0037_merge_project_caf_into_description" +down_revision: Union[str, None] = "0036_rename_github_analysis_to_github_link" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("resume_projects") as batch_op: + batch_op.add_column( + sa.Column("description", sa.Text(), nullable=False, server_default=""), + ) + batch_op.drop_column("challenge") + batch_op.drop_column("action") + batch_op.drop_column("result") + + +def downgrade() -> None: + with op.batch_alter_table("resume_projects") as batch_op: + batch_op.add_column( + sa.Column("challenge", sa.Text(), nullable=False, server_default=""), + ) + batch_op.add_column( + sa.Column("action", sa.Text(), nullable=False, server_default=""), + ) + batch_op.add_column( + sa.Column("result", sa.Text(), nullable=False, server_default=""), + ) + batch_op.drop_column("description") diff --git a/backend/app/models/resume.py b/backend/app/models/resume.py index 6f574962..0211fa96 100644 --- a/backend/app/models/resume.py +++ b/backend/app/models/resume.py @@ -167,9 +167,8 @@ class ResumeProject(Base): end_date_value: Mapped[date | None] = mapped_column("end_date", Date, nullable=True) is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) role: Mapped[str] = mapped_column(String(200), nullable=False, default="") - challenge: Mapped[str] = mapped_column(Text, nullable=False, default="") - action: Mapped[str] = mapped_column(Text, nullable=False, default="") - result: Mapped[str] = mapped_column(Text, nullable=False, default="") + # 課題・行動・成果を統合した自由記述欄。見出しは「詳細」。 + description: Mapped[str] = mapped_column(Text, nullable=False, default="") team_total: Mapped[str] = mapped_column(String(60), nullable=False, default="") team_member_rows: Mapped[list["ResumeProjectTeamMember"]] = relationship( back_populates="project", diff --git a/backend/app/repositories/resume.py b/backend/app/repositories/resume.py index f777790b..ebe96b41 100644 --- a/backend/app/repositories/resume.py +++ b/backend/app/repositories/resume.py @@ -105,9 +105,7 @@ def _build_project_row(self, index: int, payload: dict[str, object]) -> ResumePr ), is_current=payload.get("is_current", False), role=payload.get("role", ""), - challenge=payload.get("challenge", ""), - action=payload.get("action", ""), - result=payload.get("result", ""), + description=payload.get("description", ""), team_total=team.get("total", ""), team_member_rows=[ ResumeProjectTeamMember( diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index 6dc13960..40bbe11f 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -74,9 +74,8 @@ class Project(BaseModel): end_date: str = Field(default="", max_length=30) is_current: bool = False role: str = Field(max_length=200, default="") - challenge: str = Field(max_length=1500, default="") - action: str = Field(max_length=1500, default="") - result: str = Field(max_length=1500, default="") + # 課題・行動・成果を統合した自由記述欄(見出し「詳細」) + description: str = Field(max_length=4500, default="") team: ProjectTeam = Field(default_factory=ProjectTeam) technology_stacks: list[TechnologyStackItem] = Field(default_factory=list) phases: list[str] = Field(default_factory=list) diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py index 9962da03..ee2e23f8 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -84,15 +84,9 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: role = _a(proj, "role") if role: lines.append(field_line("担当", role)) - challenge = _a(proj, "challenge") - if challenge: - lines.append(field_line("課題", challenge)) - action = _a(proj, "action") - if action: - lines.append(field_line("行動", action)) - result = _a(proj, "result") - if result: - lines.append(field_line("成果", result)) + description = _a(proj, "description") + if description: + lines.append(field_line("詳細", description)) # 体制(後方互換: 旧 scale → team の正規化は shared に集約) team = normalize_team(proj) if team: diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index 8a7e1a42..b4d87e07 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -78,15 +78,9 @@ def _build_project_html(project) -> str: # 左カラム: 業務内容 left_parts: list[str] = [] - challenge = _a(project, "challenge") - if challenge: - left_parts.append(f"【課題】{_md(challenge)}") - action = _a(project, "action") - if action: - left_parts.append(f"【行動】{_md(action)}") - result = _a(project, "result") - if result: - left_parts.append(f"【成果】{_md(result)}") + description = _a(project, "description") + if description: + left_parts.append(f"【詳細】{_md(description)}") left_content = "".join(left_parts) if left_parts else "-" # 右カラム: 開発環境(技術スタック) diff --git a/backend/tests/test_delete_documents.py b/backend/tests/test_delete_documents.py index e8b9e1dc..0f0d1c56 100644 --- a/backend/tests/test_delete_documents.py +++ b/backend/tests/test_delete_documents.py @@ -29,9 +29,7 @@ "end_date": "2022-03", "is_current": False, "role": "SE", - "challenge": "", - "action": "", - "result": "", + "description": "", "team": {"total": "5", "members": []}, "technology_stacks": [], "phases": [], diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index ee9d75f3..96f29848 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -140,9 +140,7 @@ def test_resume_round_trips_nested_structure(client: TestClient) -> None: "end_date": "2024-03", "is_current": False, "role": "SE", - "challenge": "性能改善", - "action": "非同期化", - "result": "応答時間短縮", + "description": "性能改善のため非同期化し応答時間を短縮", "team": { "total": "5", "members": [{"role": "SE", "count": 3}], diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index 5d729765..b03cc774 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -26,10 +26,7 @@ def experience_payload() -> dict: "end_date": "2024-03", "is_current": False, "role": "メンバー", - "description": "API開発", - "challenge": "課題", - "action": "行動", - "result": "処理速度を改善", + "description": "課題・行動・成果をまとめた詳細", "team": { "total": "5", "members": [ diff --git a/frontend/e2e/career-dirty-indicator.spec.ts b/frontend/e2e/career-dirty-indicator.spec.ts index bea316bb..3ea29b10 100644 --- a/frontend/e2e/career-dirty-indicator.spec.ts +++ b/frontend/e2e/career-dirty-indicator.spec.ts @@ -104,9 +104,7 @@ test.describe("職務経歴書 未保存マーク", () => { end_date: "2022-03", is_current: false, role: "Eng", - challenge: "", - action: "", - result: "", + description: "", team: { total: "5", members: [] }, technology_stacks: [], phases: [], diff --git a/frontend/src/components/forms/ProjectModal.test.tsx b/frontend/src/components/forms/ProjectModal.test.tsx index fd2c5960..669d38bb 100644 --- a/frontend/src/components/forms/ProjectModal.test.tsx +++ b/frontend/src/components/forms/ProjectModal.test.tsx @@ -17,9 +17,7 @@ const invalidDateProject: CareerProjectForm = { end_date: "2024-01", is_current: false, role: "エンジニア", - challenge: "", - action: "", - result: "", + description: "", team: { total: "", members: [] }, technology_stacks: [], phases: [], @@ -31,9 +29,7 @@ const emptyProject: CareerProjectForm = { end_date: "", is_current: false, role: "", - challenge: "", - action: "", - result: "", + description: "", team: { total: "", members: [] }, technology_stacks: [], phases: [], diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index 7efc00ea..477c3504 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -219,27 +219,11 @@ export function ProjectModal({ updateField("challenge", v)} - rows={2} - labelAdornment={} - /> - - updateField("action", v)} - rows={2} - labelAdornment={} - /> - - updateField("result", v)} - rows={2} - labelAdornment={} + label="詳細" + value={local.description} + onChange={(v) => updateField("description", v)} + rows={6} + labelAdornment={} /> {/* 技術スタック */} diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index d577c7a3..b6f46b0b 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -86,9 +86,7 @@ export const blankCareerProject: CareerProjectForm = { end_date: "", is_current: false, role: "", - challenge: "", - action: "", - result: "", + description: "", team: { total: "", members: [] }, technology_stacks: [{ ...blankCareerTechnologyStack }], phases: [], diff --git a/frontend/src/formTypes.ts b/frontend/src/formTypes.ts index 0dc03374..661c0747 100644 --- a/frontend/src/formTypes.ts +++ b/frontend/src/formTypes.ts @@ -14,6 +14,4 @@ export type CareerProjectFieldKey = | "end_date" | "is_current" | "role" - | "challenge" - | "action" - | "result"; + | "description"; diff --git a/frontend/src/hooks/career/useProjectFormDirty.test.ts b/frontend/src/hooks/career/useProjectFormDirty.test.ts index 49d43c63..ff688ae3 100644 --- a/frontend/src/hooks/career/useProjectFormDirty.test.ts +++ b/frontend/src/hooks/career/useProjectFormDirty.test.ts @@ -13,9 +13,7 @@ function buildProject(overrides: Partial = {}): CareerProject end_date: "2022-03", is_current: false, role: "Eng", - challenge: "課題", - action: "行動", - result: "成果", + description: "課題・行動・成果", team: { total: "5", members: [{ role: "PM", count: "1" }] }, technology_stacks: [{ category: "language", name: "TypeScript" }], phases: ["要件定義", "開発"], diff --git a/frontend/src/hooks/career/useProjectFormDirty.ts b/frontend/src/hooks/career/useProjectFormDirty.ts index b0c88dbf..7c749778 100644 --- a/frontend/src/hooks/career/useProjectFormDirty.ts +++ b/frontend/src/hooks/career/useProjectFormDirty.ts @@ -15,9 +15,7 @@ export type ProjectFormDirty = { end_date: boolean; is_current: boolean; role: boolean; - challenge: boolean; - action: boolean; - result: boolean; + description: boolean; }; /** 体制セクションの dirty(全体人数 or メンバー配列が変化) */ team: boolean; @@ -47,9 +45,7 @@ export function useProjectFormDirty( end_date: local.end_date !== base.end_date, is_current: local.is_current !== base.is_current, role: local.role !== base.role, - challenge: local.challenge !== base.challenge, - action: local.action !== base.action, - result: local.result !== base.result, + description: local.description !== base.description, }; const team = !isDeepEqual(local.team, base.team); diff --git a/frontend/src/hooks/career/useProjectModalForm.test.ts b/frontend/src/hooks/career/useProjectModalForm.test.ts index ab1fffad..3b67487e 100644 --- a/frontend/src/hooks/career/useProjectModalForm.test.ts +++ b/frontend/src/hooks/career/useProjectModalForm.test.ts @@ -10,9 +10,7 @@ const sampleProject: CareerProjectForm = { end_date: "2024-12", is_current: false, role: "Backend", - challenge: "", - action: "", - result: "", + description: "", team: { total: "5", members: [{ role: "SE", count: "3" }] }, technology_stacks: [{ category: "language", name: "Python" }], phases: ["設計"], diff --git a/frontend/src/hooks/career/useProjectModalState.test.ts b/frontend/src/hooks/career/useProjectModalState.test.ts index 2b848591..6d181e09 100644 --- a/frontend/src/hooks/career/useProjectModalState.test.ts +++ b/frontend/src/hooks/career/useProjectModalState.test.ts @@ -9,9 +9,7 @@ const dummyProject: CareerProjectForm = { end_date: "2024-12", is_current: false, role: "エンジニア", - challenge: "", - action: "", - result: "", + description: "", team: { total: "", members: [] }, technology_stacks: [], phases: [], diff --git a/frontend/src/payloadBuilders.test.ts b/frontend/src/payloadBuilders.test.ts index b60fd72f..d05991fa 100644 --- a/frontend/src/payloadBuilders.test.ts +++ b/frontend/src/payloadBuilders.test.ts @@ -17,9 +17,7 @@ const blankProject = (overrides: Partial = {}): CareerProject end_date: "2024-06", is_current: false, role: "", - challenge: "", - action: "", - result: "", + description: "", team: { total: "", members: [] }, technology_stacks: [], phases: [], diff --git a/frontend/src/payloadBuilders.ts b/frontend/src/payloadBuilders.ts index a3f8badf..b6c99fa1 100644 --- a/frontend/src/payloadBuilders.ts +++ b/frontend/src/payloadBuilders.ts @@ -21,9 +21,7 @@ export type CareerProjectForm = { end_date: string; is_current: boolean; role: string; - challenge: string; - action: string; - result: string; + description: string; team: { total: string; members: TeamMemberForm[]; @@ -89,9 +87,7 @@ function buildProject(proj: CareerProjectForm): CareerProject { end_date: proj.is_current ? "" : proj.end_date.trim(), is_current: proj.is_current, role: proj.role.trim(), - challenge: proj.challenge.trim(), - action: proj.action.trim(), - result: proj.result.trim(), + description: proj.description.trim(), team: buildTeam(proj.team), technology_stacks: proj.technology_stacks .map((stack) => ({ @@ -109,7 +105,7 @@ function buildClient(client: CareerClientForm): CareerClient { has_client: client.has_client, projects: client.projects .map(buildProject) - .filter((p) => hasAnyText([p.name, p.challenge, p.action, p.result])), + .filter((p) => hasAnyText([p.name, p.description])), }; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f5774681..74b76f68 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -53,9 +53,8 @@ export type CareerProject = { end_date: string; is_current: boolean; role: string; - challenge: string; - action: string; - result: string; + /** 課題・行動・成果を統合した自由記述欄(見出し「詳細」) */ + description: string; team: ProjectTeam; technology_stacks: CareerTechnologyStack[]; phases: string[]; diff --git a/frontend/tests/payloadBuilders.test.cjs b/frontend/tests/payloadBuilders.test.cjs index a1c556de..5afbdf1d 100644 --- a/frontend/tests/payloadBuilders.test.cjs +++ b/frontend/tests/payloadBuilders.test.cjs @@ -28,9 +28,7 @@ test("buildCareerPayload trims data and keeps only non-empty technology stacks", end_date: "2021-03", is_current: false, role: " メンバー ", - challenge: " 課題テスト ", - action: " 行動テスト ", - result: " パフォーマンス改善 ", + description: " 課題・行動・成果テスト ", team: { total: " 5 ", members: [ @@ -79,6 +77,10 @@ test("buildCareerPayload trims data and keeps only non-empty technology stacks", assert.equal(payload.experiences[0].clients[0].projects.length, 1); assert.equal(payload.experiences[0].clients[0].projects[0].name, "プロジェクトA"); assert.equal(payload.experiences[0].clients[0].projects[0].role, "メンバー"); + assert.equal( + payload.experiences[0].clients[0].projects[0].description, + "課題・行動・成果テスト" + ); assert.deepEqual(payload.experiences[0].clients[0].projects[0].technology_stacks, [ { category: "framework", From 4967105ec63e23dc83235d2b5e5ab95752272a85 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 28 May 2026 22:52:21 +0900 Subject: [PATCH 2/5] resume ui fix --- frontend/src/App.module.css | 6 +- .../CareerExperienceEditor.tsx | 2 + .../forms/CareerResumeForm.module.css | 13 +--- .../src/components/forms/CareerResumeForm.tsx | 17 +---- .../components/forms/ProjectModal.module.css | 74 ++++++++++++++++--- .../src/components/forms/ProjectModal.tsx | 41 ++++++++-- .../forms/ResumePdfTracePanel.module.css | 31 ++++---- .../components/forms/ResumePdfTracePanel.tsx | 20 +---- frontend/src/constants/messages.ts | 3 - .../hooks/career/usePdfPanelLayout.test.ts | 9 +-- .../src/hooks/career/usePdfPanelLayout.ts | 15 +--- frontend/src/styles.css | 13 ++++ 12 files changed, 143 insertions(+), 101 deletions(-) diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index 4fdc30a2..e6ba56fd 100644 --- a/frontend/src/App.module.css +++ b/frontend/src/App.module.css @@ -5,7 +5,7 @@ } .sidebar { - width: 220px; + width: var(--sidebar-width); background: var(--sidebar-bg); color: #fff; display: flex; @@ -73,7 +73,7 @@ .mainContent { flex: 1; - margin-left: 220px; + margin-left: var(--sidebar-width); overflow-y: auto; height: 100vh; background: var(--bg-page); @@ -135,6 +135,6 @@ .mainContent { margin-left: 0; - margin-top: 48px; + margin-top: var(--topbar-height); } } diff --git a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx index 0a3749fa..3e2c6226 100644 --- a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx +++ b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx @@ -163,6 +163,7 @@ export function CareerExperienceEditor({
onUpdateExperienceField(expIndex, "employee_count", e.target.value) @@ -180,6 +181,7 @@ export function CareerExperienceEditor({
onUpdateExperienceField(expIndex, "capital", e.target.value)} placeholder="例: 5" diff --git a/frontend/src/components/forms/CareerResumeForm.module.css b/frontend/src/components/forms/CareerResumeForm.module.css index 9758f87b..fec443a2 100644 --- a/frontend/src/components/forms/CareerResumeForm.module.css +++ b/frontend/src/components/forms/CareerResumeForm.module.css @@ -125,7 +125,7 @@ } .splitter:hover { - background: #b7c5e0; + background: var(--accent); } /* 右: PDF カラム(幅は CSS 変数 --pdf-col-width で可変。中の panel が縦に伸びる)。 @@ -140,17 +140,6 @@ padding-left: 1rem; } -/* 最大化中: フォームとスプリッターを隠し、PDF をフォームに覆い被せて全幅表示する */ -.maximized .formCol, -.maximized .splitter { - display: none; -} - -.maximized .pdfCol { - flex: 1; - width: auto; -} - /* 横幅が足りない時(2 カラムを並べると最小幅を割る幅)は縦積みに切り替える。 閾値 = PDF 最小(280) + フォーム最小(360) + gap/splitter(38) ≒ 680px。 これ未満では左右に並べず、フォームの下に PDF を全幅で積む(要素が切れない)。 */ diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index 19cd081d..0970ee5e 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -35,12 +35,7 @@ export function CareerResumeForm() { const assist = useResumeImportAssist(); const pdfInputRef = useRef(null); const splitRef = useRef(null); - const { - width: pdfWidth, - maximized: pdfMaximized, - startResize, - toggleMaximize, - } = usePdfPanelLayout(splitRef); + const { width: pdfWidth, startResize } = usePdfPanelLayout(splitRef); const { form, setForm, @@ -180,14 +175,14 @@ export function CareerResumeForm() {
- {/* 左=入力フォーム / 右=PDF 原本ビュー。スプリッターで左右リサイズ、最大化で覆い被せる。 + {/* 左=入力フォーム / 右=PDF 原本ビュー。スプリッターで左右リサイズする。 splitWrap はコンテナクエリの基準(= split に割り当てられる実幅)。 横幅が足りない時は split を縦積みに切り替える。PDF カラム幅は CSS 変数で渡し、 縦積み時は CSS 側で全幅に上書きする(inline style だと上書きできないため変数経由)。 */}
{/* 左: 入力フォーム(選択中フィールドは緑枠 = import-assign-form の :focus CSS) */} @@ -257,11 +252,7 @@ export function CareerResumeForm() { {/* 右: PDF 原本ビュー(独立スクロール)。文字を選択して入力欄へ流し込む。 幅は CSS 変数 --pdf-col-width を CSS 側で参照(縦積み時は全幅へ上書き)。 */}
diff --git a/frontend/src/components/forms/ProjectModal.module.css b/frontend/src/components/forms/ProjectModal.module.css index 6e4de69e..57710055 100644 --- a/frontend/src/components/forms/ProjectModal.module.css +++ b/frontend/src/components/forms/ProjectModal.module.css @@ -1,30 +1,38 @@ +/* サイドバーを覆わず(操作可能なまま残し)、その右のコンテンツ領域いっぱいにモーダルを開く。 + overlay 自体を left:サイドバー幅 から始めるので、z-index がサイドバー(100)より上でも + サイドバーは露出したままになる。 */ .overlay { position: fixed; inset: 0; + left: var(--sidebar-width); background: var(--overlay-bg); display: flex; - align-items: center; - justify-content: center; + padding: 1.2rem; z-index: 1000; } +/* モーダルはコンテンツ領域いっぱいに広げる(職務経歴書ページと同等の作業領域を確保)。 */ .modal { background: var(--bg-card); border-radius: 12px; - width: 90vw; - max-width: 800px; - max-height: 85vh; + width: 100%; + height: 100%; display: flex; flex-direction: column; overflow: hidden; } -/* 取り込みパネルを併置する分だけ横幅を広げる */ -.modalWide { - max-width: 1150px; +/* モバイルはサイドバーが上部バーになるため、その下から全幅で開く。 */ +@media (max-width: 768px) { + .overlay { + left: 0; + top: var(--topbar-height); + padding: 0.8rem; + } } -/* ヘッダー下の領域。左=入力フォーム / 右=取り込みブロックの 2 カラム */ +/* ヘッダー下の領域。左=入力フォーム / 右=取り込みブロックの 2 カラム。 + スプリッターのドラッグで左右の比率を変えられる。 */ .bodyWrap { flex: 1; display: flex; @@ -32,9 +40,26 @@ overflow: hidden; } -/* モーダル内に再掲する PDF 原本ビュー(タブ固定・ページ部のみスクロール) */ +/* 入力フォームと PDF カラムの境界。ドラッグで左右比率を変える。 */ +.splitter { + flex-shrink: 0; + align-self: stretch; + width: 6px; + border-radius: 3px; + background: var(--border); + cursor: col-resize; + transition: background 0.15s; +} + +.splitter:hover { + background: var(--accent); +} + +/* モーダル内に再掲する PDF 原本ビュー(タブ固定・ページ部のみスクロール)。 + 幅は CSS 変数 --pdf-col-width で可変(縦積み時は下のメディアクエリで全幅に上書き)。 + inline style ではなく変数経由にすることで、縦積み時の全幅化を CSS 側で上書きできる。 */ .blocksColumn { - width: 320px; + width: var(--pdf-col-width, 320px); flex-shrink: 0; display: flex; flex-direction: column; @@ -44,6 +69,33 @@ padding: 1rem; } +/* 横幅が狭い時は左右 2 カラムをやめ、フォームの下に PDF を全幅で縦積みする。 + モーダルは width:90vw なのでビューポート幅で判定する。 */ +@media (max-width: 700px) { + .bodyWrap { + flex-direction: column; + overflow-y: auto; + } + + /* 縦積み時はモーダル全体スクロールに委ねる(カラム個別スクロールを解除) */ + .body { + overflow-y: visible; + } + + /* 縦積み時はドラッグでの左右リサイズを無効化 */ + .splitter { + display: none; + } + + /* 幅指定を上書きして全幅。区切りは左ボーダーから上ボーダーへ。 */ + .blocksColumn { + width: 100%; + min-height: 60vh; + border-left: none; + border-top: 1px solid var(--border); + } +} + .header { display: flex; justify-content: space-between; diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index 477c3504..0875f0ba 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -1,3 +1,5 @@ +import { useRef, type CSSProperties } from "react"; + import type { CareerProjectForm } from "../../payloadBuilders"; import { careerTechnologyStackCategories, @@ -7,6 +9,7 @@ import { } from "../../constants"; import { useProjectFormDirty } from "../../hooks/career/useProjectFormDirty"; import { useProjectModalForm } from "../../hooks/career/useProjectModalForm"; +import { usePdfPanelLayout } from "../../hooks/career/usePdfPanelLayout"; import type { UseResumeImportAssistReturn } from "../../hooks/career/useResumeImportAssist"; import { Combobox } from "./Combobox"; import { MarkdownTextarea } from "./MarkdownTextarea"; @@ -58,12 +61,19 @@ export function ProjectModal({ /** PDF が選択されている時だけモーダル内に原本ビューを表示する */ const showPdf = !!assist && !!assist.file; + // 入力フォームと PDF カラムの比率をスプリッターのドラッグで変える。 + // PDF カラムは右側なので「幅 = コンテナ右端 - マウス X」。reservedGap はカラム間のスプリッター(6px)。 + const bodyWrapRef = useRef(null); + const { width: pdfWidth, startResize } = usePdfPanelLayout(bodyWrapRef, { + initialWidth: 440, + minWidth: 240, + minFormWidth: 320, + reservedGap: 6, + }); + return (
-
e.stopPropagation()} - > +
e.stopPropagation()}>
{project ? "プロジェクト編集" : "プロジェクト追加"} @@ -84,7 +94,11 @@ export function ProjectModal({
-
+
diff --git a/frontend/src/components/forms/ResumePdfTracePanel.module.css b/frontend/src/components/forms/ResumePdfTracePanel.module.css index 1d7814ab..e280b2e4 100644 --- a/frontend/src/components/forms/ResumePdfTracePanel.module.css +++ b/frontend/src/components/forms/ResumePdfTracePanel.module.css @@ -7,7 +7,7 @@ min-width: 0; } -/* VSCode タブ風ヘッダー(ファイル名 + ズーム + 最大化)。常に上部に固定。 */ +/* VSCode タブ風ヘッダー(ファイル名 + ズーム)。常に上部に固定。 */ .tab { display: flex; align-items: center; @@ -16,15 +16,15 @@ flex-shrink: 0; padding: 0.3rem 0.5rem; margin-bottom: 0.5rem; - background: #f1f3f5; - border: 1px solid #d0d7de; + background: var(--bg-header); + border: 1px solid var(--border); border-radius: 6px; user-select: none; } .tabName { font-size: 0.8rem; - color: #1a1a1a; + color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -39,23 +39,26 @@ .zoomLabel { font-size: 0.72rem; - color: #444; + color: var(--text-muted); min-width: 2.8em; text-align: center; } +/* ズームボタン。グローバルの button { color:#fff } を打ち消すため color を明示する + (未指定だと白背景×白文字で見えなくなる)。 */ .iconButton { font-size: 0.8rem; line-height: 1; padding: 0.15rem 0.45rem; - border: 1px solid #c0c6cc; + border: 1px solid var(--border-input); border-radius: 4px; - background: #fff; + background: var(--bg-input); + color: var(--text-primary); cursor: pointer; } .iconButton:hover { - background: #eef1f4; + background: var(--ghost-hover); } .iconButton:disabled { @@ -65,7 +68,7 @@ .hint { font-size: 0.8rem; - color: #333; + color: var(--text-secondary); margin: 0 0 0.5rem; flex-shrink: 0; } @@ -78,15 +81,15 @@ .empty { font-size: 0.85rem; - color: #666; + color: var(--text-muted); } /* スキャン PDF(テキストレイヤーなし)の警告 */ .warning { font-size: 0.85rem; - color: #b45309; - background: #fff7ed; - border: 1px solid #fed7aa; + color: var(--warning-text); + background: var(--warning-bg); + border: 1px solid var(--warning-border); border-radius: 6px; padding: 0.4rem 0.5rem; margin: 0 0 0.6rem; @@ -106,7 +109,7 @@ /* react-pdf の各ページ(canvas + テキストレイヤー)。選択しやすいよう枠で区切る。 */ .page { - border: 1px solid #e5e5e5; + border: 1px solid var(--border); border-radius: 4px; overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); diff --git a/frontend/src/components/forms/ResumePdfTracePanel.tsx b/frontend/src/components/forms/ResumePdfTracePanel.tsx index 66fa1d43..36af58ac 100644 --- a/frontend/src/components/forms/ResumePdfTracePanel.tsx +++ b/frontend/src/components/forms/ResumePdfTracePanel.tsx @@ -14,10 +14,6 @@ const ZOOM_STEP = 0.25; type Props = { assist: UseResumeImportAssistReturn; - /** 最大化(フォームに覆い被せて全幅表示)中か。タブのラベル切替に使う。 */ - maximized?: boolean; - /** 最大化トグル。渡された時だけタブに最大化ボタン/ダブルクリックを出す(モーダルでは渡さない)。 */ - onToggleMaximize?: () => void; }; /** @@ -25,9 +21,9 @@ type Props = { * * 選択された PDF を原本のまま描画し、ユーザーが文字をドラッグ選択すると、選択中(緑枠)の * 入力欄へ流し込む。「どの項目をどの粒度で取り込むか」は人間が PDF 上の選択で決める。 - * 上部のタブ風ヘッダーでズーム(拡大/縮小)と最大化(フォームに覆い被せる)を操作する。 + * 上部のタブ風ヘッダーでズーム(拡大/縮小)を操作する。 */ -export function ResumePdfTracePanel({ assist, maximized = false, onToggleMaximize }: Props) { +export function ResumePdfTracePanel({ assist }: Props) { const { file, fileName, error, fillSelection, setError } = assist; const [zoom, setZoom] = useState(1); @@ -38,12 +34,7 @@ export function ResumePdfTracePanel({ assist, maximized = false, onToggleMaximiz
{file && (
- + {fileName ?? IMPORT_ASSIST_MESSAGES.TAB_FALLBACK}
@@ -66,11 +57,6 @@ export function ResumePdfTracePanel({ assist, maximized = false, onToggleMaximiz > + - {onToggleMaximize && ( - - )}
)} diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index 22e93fa0..872428ce 100644 --- a/frontend/src/constants/messages.ts +++ b/frontend/src/constants/messages.ts @@ -81,9 +81,6 @@ export const IMPORT_ASSIST_MESSAGES = { RENDER_FAILED: "PDFの表示に失敗しました。別のファイルをお試しください。", NO_TARGET: "先にフォームの入力欄をクリックして、流し込み先を選んでください。", TAB_FALLBACK: "PDF", - MAXIMIZE: "最大化", - RESTORE: "元に戻す", - MAXIMIZE_HINT: "ダブルクリックで最大化/元に戻す", ZOOM_IN: "拡大", ZOOM_OUT: "縮小", } as const; diff --git a/frontend/src/hooks/career/usePdfPanelLayout.test.ts b/frontend/src/hooks/career/usePdfPanelLayout.test.ts index 18198124..7a06a8c9 100644 --- a/frontend/src/hooks/career/usePdfPanelLayout.test.ts +++ b/frontend/src/hooks/career/usePdfPanelLayout.test.ts @@ -35,20 +35,13 @@ describe("usePdfPanelLayout", () => { document.body.style.userSelect = ""; }); - it("初期幅を返し、toggleMaximize で最大化を反転する", () => { + it("初期幅を返す", () => { const ref = makeContainerRef(1000, 900); const { result } = renderHook(() => usePdfPanelLayout(ref, { initialWidth: 360, minWidth: 280, minFormWidth: 360 }), ); expect(result.current.width).toBe(360); - expect(result.current.maximized).toBe(false); - - act(() => result.current.toggleMaximize()); - expect(result.current.maximized).toBe(true); - - act(() => result.current.toggleMaximize()); - expect(result.current.maximized).toBe(false); }); it("ドラッグで幅が変わり、最小/最大でクランプされる", () => { diff --git a/frontend/src/hooks/career/usePdfPanelLayout.ts b/frontend/src/hooks/career/usePdfPanelLayout.ts index 2011f2a6..77ba6be2 100644 --- a/frontend/src/hooks/career/usePdfPanelLayout.ts +++ b/frontend/src/hooks/career/usePdfPanelLayout.ts @@ -2,12 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent, RefObject } from "react"; /** - * PDF 取り込みパネルのレイアウト(左右リサイズ + 最大化)を管理するフック。 + * PDF 取り込みパネルのレイアウト(左右リサイズ)を管理するフック。 * * - リサイズ: フォームとパネルの間のスプリッターをドラッグして PDF カラム幅を変える。 * PDF は右カラムなので「幅 = コンテナ右端 - マウス X」。左にドラッグするほど広がる。 - * - 最大化: VSCode のタブをダブルクリックした時のように、PDF をフォームに覆い被せて全幅表示する - * (maximized=true)。再度トグルで元の 2 カラムに戻す。 * * 幅は最小 PDF 幅とフォームの最小確保幅でクランプする。 */ @@ -29,14 +27,10 @@ type Options = { }; export type UsePdfPanelLayoutReturn = { - /** PDF カラムの現在幅(px)。最大化中は無視される。 */ + /** PDF カラムの現在幅(px) */ width: number; - /** 最大化(フォームに覆い被せて全幅表示)中か */ - maximized: boolean; /** スプリッターの mousedown で呼ぶ。ドラッグ中の幅追従を開始する。 */ startResize: (e: ReactMouseEvent) => void; - /** 最大化⇔元の 2 カラムをトグルする。 */ - toggleMaximize: () => void; }; export function usePdfPanelLayout( @@ -46,7 +40,6 @@ export function usePdfPanelLayout( const { initialWidth = 360, minWidth = 280, minFormWidth = 360, reservedGap = 38 } = options; const [width, setWidth] = useState(initialWidth); - const [maximized, setMaximized] = useState(false); const resizingRef = useRef(false); // mousemove / mouseup は document 全体で拾う(スプリッター外へカーソルが出ても追従するため)。 @@ -81,7 +74,5 @@ export function usePdfPanelLayout( document.body.style.cursor = "col-resize"; }, []); - const toggleMaximize = useCallback(() => setMaximized((prev) => !prev), []); - - return { width, maximized, startResize, toggleMaximize }; + return { width, startResize }; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6aea5446..93527f89 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2,6 +2,10 @@ color-scheme: light; font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif; + /* Layout(サイドバー幅・モバイル上部バー高。AuthenticatedLayout とモーダルのオフセットで共用) */ + --sidebar-width: 220px; + --topbar-height: 48px; + /* Backgrounds */ --bg-page: #f9f6f1; --bg-card: #ffffff; @@ -40,6 +44,11 @@ --error: #a72323; --success: #2e7d4f; + /* Warning (PDF スキャン警告などの注意喚起) */ + --warning-text: #b45309; + --warning-bg: #fff7ed; + --warning-border: #fed7aa; + /* Shadow */ --shadow-card: 0 14px 30px rgba(80, 60, 30, 0.06); @@ -101,6 +110,10 @@ --error: #f87171; --success: #4ade80; + --warning-text: #fbbf24; + --warning-bg: #422006; + --warning-border: #854d0e; + --shadow-card: 0 14px 30px rgba(0, 0, 0, 0.3); --header-btn-hover: rgba(255, 255, 255, 0.06); From 55068d138c2c5e1a3274cfc9531f849f06013771 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 29 May 2026 00:37:10 +0900 Subject: [PATCH 3/5] project date list add --- CONTRIBUTING.md | 1 + .../0038_add_project_periods_table.py | 102 ++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/resume.py | 57 +++++++++- backend/app/repositories/resume.py | 55 ++++++++-- backend/app/schemas/resume.py | 64 ++++++----- .../markdown/generators/resume_generator.py | 19 ++-- .../pdf/generators/resume_generator.py | 32 +++--- backend/tests/test_schemas.py | 49 +++------ docs/adr/0006-tanstack-query.md | 101 +++++++++++++++++ .../components/forms/ProjectModal.test.tsx | 8 +- .../src/components/forms/ProjectModal.tsx | 95 +++++++++------- .../sections/CareerExperienceSection.tsx | 11 +- frontend/src/constants.ts | 9 +- frontend/src/formMappers.ts | 4 + frontend/src/formTypes.ts | 9 +- .../hooks/career/useProjectFormDirty.test.ts | 17 ++- .../src/hooks/career/useProjectFormDirty.ts | 13 +-- .../hooks/career/useProjectModalForm.test.ts | 40 ++++--- .../src/hooks/career/useProjectModalForm.ts | 56 ++++++++-- .../hooks/career/useProjectModalState.test.ts | 4 +- frontend/src/payloadBuilders.test.ts | 31 +++--- frontend/src/payloadBuilders.ts | 61 +++++++---- frontend/src/types.ts | 8 +- frontend/tests/payloadBuilders.test.cjs | 6 +- 25 files changed, 628 insertions(+), 226 deletions(-) create mode 100644 backend/alembic_migrations/versions/0038_add_project_periods_table.py create mode 100644 docs/adr/0006-tanstack-query.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6a34028..5cb5685b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,3 +73,4 @@ docs/adr/XXXX-kebab-case-title.md | [ADR-0003](docs/adr/0003-redux-toolkit-persist.md) | Redux Toolkit + redux-persist の採用 | Accepted | | [ADR-0004](docs/adr/0004-llm-provider-abstraction.md) | LLM プロバイダ抽象化(Ollama/Vertex AI) | Accepted | | [ADR-0005](docs/adr/0005-cloudrun-single-instance.md) | Cloud Run single instance 構成の採用 | Accepted | +| [ADR-0006](docs/adr/0006-tanstack-query.md) | TanStack Query 導入検討 | Proposed | diff --git a/backend/alembic_migrations/versions/0038_add_project_periods_table.py b/backend/alembic_migrations/versions/0038_add_project_periods_table.py new file mode 100644 index 00000000..82fbe750 --- /dev/null +++ b/backend/alembic_migrations/versions/0038_add_project_periods_table.py @@ -0,0 +1,102 @@ +"""resume_project_periods テーブルを追加し、プロジェクトの複数期間に対応する + +ResumeProject の start_date / end_date / is_current を子テーブル +resume_project_periods に正規化して移す。これにより 1 案件で +「2024/01〜2024/12、2025/06〜現在」のような複数の在籍期間を持てるようになる。 + +既存データは 1 行 1 期間として resume_project_periods に移行する。 + +libSQL (SQLite 互換) は ALTER COLUMN / DROP COLUMN を直接サポートしないため +batch_alter_table(テーブル再作成)でカラムを削除する。 + +Revision ID: 0038_add_project_periods_table +Revises: 0037_merge_project_caf_into_description +Create Date: 2026-05-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0038_add_project_periods_table" +down_revision: Union[str, None] = "0037_merge_project_caf_into_description" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "resume_project_periods", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "project_id", + sa.String(36), + sa.ForeignKey("resume_projects.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("sort_order", sa.Integer(), nullable=False, default=0), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=True), + sa.Column("is_current", sa.Boolean(), nullable=False, default=False), + ) + + # 既存プロジェクトの start_date / end_date / is_current を 1 期間として移行する。 + conn = op.get_bind() + rows = conn.execute( + sa.text("SELECT id, start_date, end_date, is_current FROM resume_projects") + ).fetchall() + import uuid as _uuid + for row in rows: + conn.execute( + sa.text( + "INSERT INTO resume_project_periods " + "(id, project_id, sort_order, start_date, end_date, is_current) " + "VALUES (:id, :project_id, 0, :start_date, :end_date, :is_current)" + ), + { + "id": str(_uuid.uuid4()), + "project_id": row[0], + "start_date": row[1], + "end_date": row[2], + "is_current": row[3], + }, + ) + + with op.batch_alter_table("resume_projects") as batch_op: + batch_op.drop_column("start_date") + batch_op.drop_column("end_date") + batch_op.drop_column("is_current") + + +def downgrade() -> None: + with op.batch_alter_table("resume_projects") as batch_op: + batch_op.add_column( + sa.Column("start_date", sa.Date(), nullable=False, server_default="2000-01-01"), + ) + batch_op.add_column( + sa.Column("end_date", sa.Date(), nullable=True), + ) + batch_op.add_column( + sa.Column("is_current", sa.Boolean(), nullable=False, server_default="0"), + ) + + # 各プロジェクトの最初の期間(sort_order=0)を resume_projects に書き戻す。 + conn = op.get_bind() + rows = conn.execute( + sa.text( + "SELECT project_id, start_date, end_date, is_current " + "FROM resume_project_periods WHERE sort_order = 0" + ) + ).fetchall() + for row in rows: + conn.execute( + sa.text( + "UPDATE resume_projects SET start_date=:sd, end_date=:ed, is_current=:ic " + "WHERE id=:pid" + ), + {"sd": row[1], "ed": row[2], "ic": row[3], "pid": row[0]}, + ) + + op.drop_table("resume_project_periods") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d5b30dff..abf4a70d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,7 @@ ResumeClient, ResumeExperience, ResumeProject, + ResumeProjectPeriod, ResumeProjectPhase, ResumeProjectTeamMember, ResumeProjectTechnologyStack, @@ -28,6 +29,7 @@ "ResumeClient", "ResumeExperience", "ResumeProject", + "ResumeProjectPeriod", "ResumeProjectPhase", "ResumeProjectTeamMember", "ResumeProjectTechnologyStack", diff --git a/backend/app/models/resume.py b/backend/app/models/resume.py index 0211fa96..58aa9cd5 100644 --- a/backend/app/models/resume.py +++ b/backend/app/models/resume.py @@ -163,13 +163,15 @@ class ResumeProject(Base): ) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) name: Mapped[str] = mapped_column(String(200), nullable=False, default="") - start_date_value: Mapped[date] = mapped_column("start_date", Date, nullable=False) - end_date_value: Mapped[date | None] = mapped_column("end_date", Date, nullable=True) - is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) role: Mapped[str] = mapped_column(String(200), nullable=False, default="") # 課題・行動・成果を統合した自由記述欄。見出しは「詳細」。 description: Mapped[str] = mapped_column(Text, nullable=False, default="") team_total: Mapped[str] = mapped_column(String(60), nullable=False, default="") + period_rows: Mapped[list["ResumeProjectPeriod"]] = relationship( + back_populates="project", + cascade="all, delete-orphan", + order_by="ResumeProjectPeriod.sort_order", + ) team_member_rows: Mapped[list["ResumeProjectTeamMember"]] = relationship( back_populates="project", cascade="all, delete-orphan", @@ -187,13 +189,34 @@ class ResumeProject(Base): ) client: Mapped["ResumeClient"] = relationship(back_populates="project_rows") + @property + def periods(self) -> list["ResumeProjectPeriod"]: + return list(self.period_rows) + + @property + def is_current(self) -> bool: + return any(p.is_current for p in self.period_rows) + + @property + def start_date_value(self) -> date | None: + """ソート用: 全期間のうち最も新しい開始日を返す。""" + values = [p.start_date_value for p in self.period_rows if p.start_date_value] + return max(values) if values else None + + @property + def end_date_value(self) -> date | None: + """ソート用: いずれかの期間が参画中なら None、それ以外は全期間の最大終了日を返す。""" + if self.is_current: + return None + values = [p.end_date_value for p in self.period_rows if p.end_date_value] + return max(values) if values else None + @property def start_date(self) -> str: return format_year_month(self.start_date_value) or "" @property def end_date(self) -> str: - """DB の end_date が NULL(参画中)の場合は "" を返す(schema 契約と一致)。""" return format_year_month(self.end_date_value) or "" @property @@ -212,6 +235,32 @@ def phases(self) -> list[str]: return [phase.name for phase in self.phase_rows] +class ResumeProjectPeriod(Base): + __tablename__ = "resume_project_periods" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("resume_projects.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + start_date_value: Mapped[date] = mapped_column("start_date", Date, nullable=False) + end_date_value: Mapped[date | None] = mapped_column("end_date", Date, nullable=True) + is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + project: Mapped["ResumeProject"] = relationship(back_populates="period_rows") + + @property + def start_date(self) -> str: + return format_year_month(self.start_date_value) or "" + + @property + def end_date(self) -> str: + """DB の end_date が NULL(参画中)の場合は "" を返す(schema 契約と一致)。""" + return format_year_month(self.end_date_value) or "" + + class ResumeProjectTeamMember(Base): __tablename__ = "resume_project_team_members" diff --git a/backend/app/repositories/resume.py b/backend/app/repositories/resume.py index ebe96b41..068cab8f 100644 --- a/backend/app/repositories/resume.py +++ b/backend/app/repositories/resume.py @@ -6,6 +6,7 @@ ResumeClient, ResumeExperience, ResumeProject, + ResumeProjectPeriod, ResumeProjectPhase, ResumeProjectTeamMember, ResumeProjectTechnologyStack, @@ -22,6 +23,10 @@ class ResumeRepository(SingleUserDocumentRepository): selectinload(Resume.experience_rows) .selectinload(ResumeExperience.client_rows) .selectinload(ResumeClient.project_rows) + .selectinload(ResumeProject.period_rows), + selectinload(Resume.experience_rows) + .selectinload(ResumeExperience.client_rows) + .selectinload(ResumeClient.project_rows) .selectinload(ResumeProject.team_member_rows), selectinload(Resume.experience_rows) .selectinload(ResumeExperience.client_rows) @@ -79,11 +84,8 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum ) def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeClient: - sorted_projects = sort_by_period_desc( - payload.get("projects", []), - start_key="start_date", - end_key="end_date", - ) + projects = list(payload.get("projects", [])) + sorted_projects = sorted(projects, key=self._project_sort_key) return ResumeClient( sort_order=index, name=payload.get("name", ""), @@ -94,19 +96,52 @@ def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeCli ], ) + @staticmethod + def _project_sort_key(proj: dict[str, object]) -> tuple: + """複数期間を持つプロジェクトを sort_by_period_desc と同じ降順ロジックでソートする。""" + from datetime import date as _date + + def _parse(val: object) -> _date | None: + if val is None: + return None + if isinstance(val, _date): + return val + if isinstance(val, str) and val: + if len(val) == 7 and val[4] == "-": + return _date.fromisoformat(f"{val}-01") + return _date.fromisoformat(val) + return None + + periods = proj.get("periods", []) + is_any_current = any(p.get("is_current") for p in periods) + starts = [_parse(p.get("start_date")) for p in periods] + effective_start = max((s for s in starts if s), default=_date.min) + if is_any_current: + return (0, _date.max - effective_start) + ends = [_parse(p.get("end_date")) for p in periods if p.get("end_date")] + effective_end = max(ends, default=_date.min) + return (1, _date.max - effective_end, _date.max - effective_start) + def _build_project_row(self, index: int, payload: dict[str, object]) -> ResumeProject: team = payload.get("team", {}) + periods = list(payload.get("periods", [])) return ResumeProject( sort_order=index, name=payload.get("name", ""), - start_date_value=parse_year_month(payload["start_date"]), - end_date_value=( - parse_year_month(payload["end_date"]) if payload.get("end_date") else None - ), - is_current=payload.get("is_current", False), role=payload.get("role", ""), description=payload.get("description", ""), team_total=team.get("total", ""), + period_rows=[ + ResumeProjectPeriod( + sort_order=period_index, + start_date_value=parse_year_month(period["start_date"]), + end_date_value=( + parse_year_month(period["end_date"]) if period.get("end_date") else None + ), + is_current=period.get("is_current", False), + ) + for period_index, period in enumerate(periods) + ], team_member_rows=[ ResumeProjectTeamMember( sort_order=member_index, diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index 40bbe11f..c7de9007 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -66,40 +66,18 @@ class ProjectTeam(BaseModel): model_config = ConfigDict(from_attributes=True) -class Project(BaseModel): - name: str = Field(max_length=200, default="") +class ProjectPeriod(BaseModel): + """プロジェクトの在籍期間(1 案件に複数持てる)。""" + start_date: str = Field(max_length=30, default="") # 参画中(is_current=True)の場合は "" を渡す契約。 - # DB 上 end_date は NULL で保存され、ResumeProject.end_date プロパティが "" を返す。 end_date: str = Field(default="", max_length=30) is_current: bool = False - role: str = Field(max_length=200, default="") - # 課題・行動・成果を統合した自由記述欄(見出し「詳細」) - description: str = Field(max_length=4500, default="") - team: ProjectTeam = Field(default_factory=ProjectTeam) - technology_stacks: list[TechnologyStackItem] = Field(default_factory=list) - phases: list[str] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) - @model_validator(mode="before") - @classmethod - def _migrate_scale_to_team(cls, data: dict) -> dict: - """旧形式 scale → team に自動変換する後方互換処理。""" - if isinstance(data, dict) and "scale" in data and "team" not in data: - scale = data.pop("scale") - data["team"] = {"total": str(scale) if scale else "", "members": []} - return data - @model_validator(mode="after") - def validate_dates(self) -> "Project": - """開始年月を必須化し、参画中でなければ終了年月も必須化、日付範囲を検証する。 - - 開始年月が空のまま repositories 層に渡ると ``parse_year_month("")`` が - ValueError を投げて 500 になる(DB の start_date は NOT NULL)。 - 手前で 422(日本語メッセージ)として返す。フロントは案件に内容がある行のみ - 送信するため、ここに到達するプロジェクトは開始年月が必須でよい。 - """ + def validate_dates(self) -> "ProjectPeriod": if not self.start_date.strip(): raise ValueError(get_error("validation.start_date_required")) if self.is_current: @@ -112,6 +90,40 @@ def validate_dates(self) -> "Project": return self +class Project(BaseModel): + name: str = Field(max_length=200, default="") + periods: list[ProjectPeriod] = Field(default_factory=list) + role: str = Field(max_length=200, default="") + # 課題・行動・成果を統合した自由記述欄(見出し「詳細」) + description: str = Field(max_length=4500, default="") + team: ProjectTeam = Field(default_factory=ProjectTeam) + technology_stacks: list[TechnologyStackItem] = Field(default_factory=list) + phases: list[str] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + @model_validator(mode="before") + @classmethod + def _migrate_legacy_fields(cls, data: dict) -> dict: + """旧形式の後方互換処理。 + - scale → team + - start_date/end_date/is_current(フラット)→ periods[0] + """ + if not isinstance(data, dict): + return data + if "scale" in data and "team" not in data: + scale = data.pop("scale") + data["team"] = {"total": str(scale) if scale else "", "members": []} + if "start_date" in data and "periods" not in data: + data["periods"] = [ + { + "start_date": data.pop("start_date", ""), + "end_date": data.pop("end_date", ""), + "is_current": data.pop("is_current", False), + } + ] + return data + class Client(BaseModel): """ユーザ(常駐先/クライアント企業)。""" diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py index ee2e23f8..10009412 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -75,12 +75,19 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: if name: lines.append(f"##### {name}") lines.append("") - proj_start = _a(proj, "start_date") - proj_end = _a(proj, "end_date") - proj_is_current = _a(proj, "is_current", False) - if proj_start: - proj_period = format_period(proj_start, proj_end, proj_is_current) - lines.append(field_line("期間", proj_period)) + proj_periods = _a(proj, "periods", []) + if proj_periods: + period_parts = [ + format_period( + _a(p, "start_date"), + _a(p, "end_date", ""), + _a(p, "is_current", False), + ) + for p in proj_periods + if _a(p, "start_date") + ] + if period_parts: + lines.append(field_line("期間", "、".join(period_parts))) role = _a(proj, "role") if role: lines.append(field_line("担当", role)) diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index b4d87e07..091f380e 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -32,12 +32,8 @@ def _md(text: str) -> str: return markdown.markdown(str(text), extensions=["tables"]) -def _format_period( - start: str, - end: str, - is_current: bool, -) -> str: - """期間表示をフォーマットする。在籍中は end を "" で受ける契約。""" +def _format_period(start: str, end: str, is_current: bool) -> str: + """1 期間をフォーマットする。在籍中は end を "" で受ける契約(Experience 用)。""" s = start.replace("-", " 年 ") + " 月" if "-" in start else start if is_current: return f"{s}〜現在" @@ -45,20 +41,32 @@ def _format_period( return f"{s}〜{e}" +def _format_periods(periods: list) -> str: + """複数期間を「、」区切りで連結してフォーマットする(Project 用)。""" + parts: list[str] = [] + for p in periods: + start = _a(p, "start_date") + if not start: + continue + end = _a(p, "end_date", "") + is_current = _a(p, "is_current", False) + parts.append(_format_period(start, end, is_current)) + return "、".join(parts) + + def _build_project_html(project) -> str: """プロジェクト1件分のHTMLを組み立てる""" # ヘッダー(3行構成: 期間/プロジェクト名、役割、工程) name = _a(project, "name") - start = _a(project, "start_date") - end = _a(project, "end_date") - is_current = _a(project, "is_current", False) + periods = _a(project, "periods", []) role = _a(project, "role") phases = _a(project, "phases", []) # 1行目: 期間 / プロジェクト名 line1_parts: list[str] = [] - if start: - line1_parts.append(_format_period(start, end, is_current)) + period_str = _format_periods(periods) + if period_str: + line1_parts.append(period_str) if name: line1_parts.append(_esc(name)) line1 = " / ".join(line1_parts) if line1_parts else "" @@ -80,7 +88,7 @@ def _build_project_html(project) -> str: left_parts: list[str] = [] description = _a(project, "description") if description: - left_parts.append(f"【詳細】{_md(description)}") + left_parts.append(_md(description)) left_content = "".join(left_parts) if left_parts else "-" # 右カラム: 開発環境(技術スタック) diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index b03cc774..6c228d82 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -153,13 +153,11 @@ def test_project_current_without_end_date_is_accepted() -> None: """プロジェクト: 参画中(is_current=True)なら終了年月が空でも OK。""" proj = Project( name="API開発", - start_date="2021-04", - end_date="", - is_current=True, + periods=[{"start_date": "2021-04", "end_date": "", "is_current": True}], technology_stacks=[], ) - assert proj.start_date == "2021-04" - assert proj.end_date == "" + assert proj.periods[0].start_date == "2021-04" + assert proj.periods[0].end_date == "" def test_experience_requires_start_date_with_japanese_message() -> None: @@ -256,60 +254,39 @@ def test_project_end_date_equals_start_date_is_accepted() -> None: """プロジェクト: 終了日 = 開始日は正常に保存されること。""" proj = Project( name="テスト", - start_date="2024-04", - end_date="2024-04", - is_current=False, + periods=[{"start_date": "2024-04", "end_date": "2024-04", "is_current": False}], technology_stacks=[], ) - assert proj.end_date == "2024-04" + assert proj.periods[0].end_date == "2024-04" def test_project_end_date_after_start_date_is_accepted() -> None: """プロジェクト: 終了日 > 開始日は正常に保存されること。""" proj = Project( name="テスト", - start_date="2021-04", - end_date="2024-03", - is_current=False, + periods=[{"start_date": "2021-04", "end_date": "2024-03", "is_current": False}], technology_stacks=[], ) - assert proj.end_date == "2024-03" + assert proj.periods[0].end_date == "2024-03" def test_project_in_progress_end_date_is_normalized_to_empty() -> None: - """プロジェクト: 参画中(is_current=True)は end_date が "" に正規化されること。 - - DB 側は end_date が NULL で保存され、ResumeProject.end_date プロパティは "" を返す。 - Experience.end_date と同じ契約で、schema は str 必須・None 不可。 - """ - # 未指定(デフォルト "") - proj = Project( - name="テスト", - start_date="2021-04", - is_current=True, - technology_stacks=[], - ) - assert proj.end_date == "" - + """プロジェクト: 参画中(is_current=True)の期間は end_date が "" に正規化されること。""" # 値が入っていても is_current=True なら "" に正規化される - proj_overridden = Project( + proj = Project( name="テスト", - start_date="2021-04", - end_date="2024-03", - is_current=True, + periods=[{"start_date": "2021-04", "end_date": "2024-03", "is_current": True}], technology_stacks=[], ) - assert proj_overridden.end_date == "" + assert proj.periods[0].end_date == "" # 空文字列も当然 OK proj_empty = Project( name="テスト", - start_date="2021-04", - end_date="", - is_current=True, + periods=[{"start_date": "2021-04", "end_date": "", "is_current": True}], technology_stacks=[], ) - assert proj_empty.end_date == "" + assert proj_empty.periods[0].end_date == "" def test_project_end_date_none_is_rejected() -> None: diff --git a/docs/adr/0006-tanstack-query.md b/docs/adr/0006-tanstack-query.md new file mode 100644 index 00000000..aedb8e3c --- /dev/null +++ b/docs/adr/0006-tanstack-query.md @@ -0,0 +1,101 @@ +# ADR-0006: TanStack Query 導入検討 + +## ステータス + +Proposed + +> 本 ADR は「サーバ状態管理に TanStack Query を導入するか」の判断材料であり、採用確定ではない。 +> Phase 1 パイロット(後述)の結果を見て `Accepted` への昇格、または `Deprecated`(現状維持)を判断する。 + +## コンテキスト + +DevForge のフロントエンド(React 18 + TypeScript + Vite)には、サーバ状態(API レスポンス)を管理する専用ライブラリが**入っていない**。データ取得は `frontend/src/api/client.ts` の `request()` を起点に、各フックが手書きで取り回している。 + +現状の構成と痛み: + +- **fetch ラッパーは完成度が高い**: `request()` は Cookie 認証・CSRF トークン付与・401 リフレッシュ(重複排除付き、1回のみ再試行)を自前実装済み。ここは温存したい資産。 +- **約35エンドポイント / 約10フック**が、それぞれ `useState` で `loading` / `error` / `success` を手書きしている(`useDocumentForm` / `useNotifications` / `useBlogAccountManager` 等)。同じボイラープレートが各所に散在。 +- **ミューテーション後は手動で全件再取得**している。例: `hooks/blog/useBlogAccountManager.ts` の `loadData()` は、アカウント追加/更新/削除のたびに accounts と articles を**両方**再取得する(articles が変わっていなくても再取得)。 +- **キャッシュは断片的**: `useMasterData` のモジュールレベル ref キャッシュ(有効期限/無効化なし)と、Redux `formCache`(フォーム下書き保持)のみ。サーバ状態の横断キャッシュ・重複排除・stale-while-revalidate・background refetch・`AbortController` による中断はいずれも無し。 +- **ポーリングは手書き**: `useTaskPolling` は `setTimeout` + 指数バックオフ、`useNotifications` は `setInterval` の30秒固定。 + +「リクエスト管理を最適化したい」という要望に対し、サーバ状態管理ライブラリの導入是非を整理する必要が生じた。既存の状態管理判断は [ADR-0003](../0003-redux-toolkit-persist.md) で Redux Toolkit + redux-persist を採用済みのため、本 ADR ではその責務境界も併せて明確化する。 + +## 決定内容 + +サーバ状態管理に **TanStack Query(`@tanstack/react-query` v5)** を採用する案を提示する。導入する場合の方針は以下のとおり。 + +### 基本方針 + +- **`request()` はそのまま `queryFn` として再利用する**。401 リフレッシュ・CSRF・Cookie 認証のロジックは一切変更しない。TanStack Query は `request()` の薄いラッパーとして上に乗せるだけにする。 +- TanStack Query の `retry` は**既存リフレッシュと二重化しないよう調整**する。具体的には 401(`AUTH_REQUIRED`)はリトライ対象から外す(リフレッシュは `request()` 内で完結しているため)。 +- `frontend/src/main.tsx` に `QueryClientProvider` を追加する(既存の `BrowserRouter` / Redux `Provider` と並列にラップ)。 +- **永続化は行わない**: TanStack Query のキャッシュはメモリ上のみとし、`localStorage` への persist プラグインは導入しない(PII 方針との整合。後述)。 + +### 責務境界(Redux との分担) + +| 状態の種類 | 管理場所(提案後) | 備考 | +|---|---|---| +| サーバ GET 結果・一覧・マスタデータ | **TanStack Query** | キャッシュ + `invalidateQueries` による無効化 | +| フォーム編集中の下書き・ページ跨ぎ保持 | Redux `formCache`(現状維持) | クライアント状態であり TanStack の領分外 | +| 認証状態・ユーザー情報 | `App.tsx` の `useState` + `sessionStorage`(現状維持) | [ADR-0003](../0003-redux-toolkit-persist.md) 踏襲 | +| 現在のページ・パラメータ | URL(React Router、現状維持) | — | + +要点は **「サーバ状態 = TanStack Query / クライアント状態 = Redux」** の線引きを徹底すること。TanStack Query は Redux を**置換するものではなく補完**する。 + +### query key 命名規約 + +エンドポイント単位でドメイン配列キーを用いる(例: `["master-data", "qualifications"]` / `["notifications", "unread-count"]` / `["blog", "accounts"]`)。無効化はドメインプレフィックス単位で行えるようにする。 + +### テスト方針 + +各フックテストで `QueryClientProvider` ラップが必要になるため、`renderWithQueryClient` 相当のテストヘルパーを `frontend/src/test/` に用意する(`.claude/rules/frontend/test.md` 準拠)。テストごとに新しい `QueryClient`(`retry: false`)を生成し、テスト間でキャッシュが漏れないようにする。 + +### 段階移行プラン + +採用する場合は以下の順で進める。Phase 1 をパイロットとし、効果を見てから先へ進む。 + +| Phase | 対象 | 内容 | リスク | +|---|---|---|---| +| 0 | 基盤 | 依存追加 / `QueryClientProvider` 設置 / query key 規約 / テストヘルパー | 低 | +| 1 | 読み取り(パイロット) | `useMasterData`(module ref → `useQuery` + `staleTime: Infinity`)、`useNotifications`(30秒 `setInterval` → `refetchInterval`) | 低 | +| 2 | ミューテーション + 無効化 | `useBlogAccountManager`(`loadData()` 全件再取得 → `useQuery` + `useMutation` + `invalidateQueries`) | 中 | +| 3 | ポーリング(任意・要検証) | `useTaskPolling` / `useAsyncTaskPage` を `refetchInterval`(終端ステータスで `false` 返却)で表現できるか検証。指数バックオフ + 終端コールバックは独自実装のため、無理なら現状維持 | 中〜高 | + +### 移行しないもの(重要) + +- **`useDocumentForm`**: 編集中フォーム・dirty 判定(baseline 比較)・ページ跨ぎ保持は**サーバ状態ではなくクライアント状態**であり、TanStack Query の領分外。Redux のまま残す。`loadLatest()` のサーバ取得部分だけは将来 `useQuery` 化の余地があるが、今回スコープ外とする。 +- **`request()` の 401/CSRF ロジック**: 変更しない。 + +## 代替案 + +| 選択肢 | 評価 | +|---|---| +| 現状維持(手書き fetch + `useState`) | 既存資産(401/CSRF)はそのまま活きるが、キャッシュ・重複排除・SWR・無効化を都度手実装する負債が残り続ける | +| SWR | 軽量で導入は容易だが、ミューテーション/無効化フロー・devtools が TanStack Query ほど充実しておらず、Phase 2 以降の要件に弱い | +| RTK Query | 既存 Redux と統合できる利点はあるが、サーバ状態とフォーム下書きを同一 store に混在させる設計になり責務境界が曖昧化する。ボイラープレートも相対的に重く、`request()` の流用も馴染みにくい | + +## トレードオフ・既知のリスク + +1. **バンドルサイズ増**: TanStack Query コアで +約12〜13KB(gz)。devtools は dev ビルド限定にすれば本番には乗らない。 +2. **状態システムの二重化**: TanStack Query(サーバ状態)と Redux(クライアント状態)が併存する。境界を明文化しないと「サーバ最新値をどちらが持つか」が曖昧になる。特に `useDocumentForm` の `baseline`(サーバ最新スナップショット)と編集中 `form` は二重ソースになりやすい。当面は両方とも Redux 側に留め、移行するなら baseline 取得のみ TanStack 化する余地がある旨を残す。 +3. **学習コスト**: query key 設計・`staleTime` / `gcTime`・無効化フローの理解が必要。 +4. **テスト影響**: 各フックテストに `QueryClientProvider` ラッパーが必要。`useNotifications` 等、既存テストの assert 見直しが発生する(`.claude/rules/frontend/test.md`)。 +5. **E2E 影響**: `useNotifications` / `AuthenticatedLayout`(通知ベル)周りは E2E トリガーに該当するため、移行時は `npm run test:e2e` 必須。 +6. **PII 方針**: TanStack Query のキャッシュはメモリ上のみとし `localStorage` に persist しない。[ADR-0003](../0003-redux-toolkit-persist.md) の `formCache` blacklist(PII を localStorage に保存しない方針)と整合させる。persist プラグインは導入しない。 + +## 将来の移行条件 + +- **Accepted への昇格条件**: Phase 1 パイロットが `make ci` green / E2E green を満たし、かつボイラープレート削減・無効化フローの改善が体感できること。 +- **Deprecated(現状維持)への判断**: 上記を満たせない、またはバンドルサイズ増・二重状態管理のコストが効果に見合わない場合は、本 ADR を `Deprecated` にし現状の手書き fetch を継続する。 +- Phase 2 以降に進む場合、`useDocumentForm` の baseline 取得を TanStack Query 化するかを改めて検討する。 + +## 関連リンク + +- [ADR-0003: Redux Toolkit + redux-persist の採用](../0003-redux-toolkit-persist.md) +- [frontend/src/api/client.ts](../../frontend/src/api/client.ts) — fetch ラッパー(401/CSRF) +- [frontend/src/hooks/useMasterData.ts](../../frontend/src/hooks/useMasterData.ts) — module ref キャッシュ(Phase 1 対象) +- [frontend/src/hooks/useNotifications.ts](../../frontend/src/hooks/useNotifications.ts) — 30秒ポーリング(Phase 1 対象) +- [frontend/src/hooks/blog/useBlogAccountManager.ts](../../frontend/src/hooks/blog/useBlogAccountManager.ts) — ミューテーション後の全件再取得(Phase 2 対象) +- [frontend/src/hooks/useDocumentForm.ts](../../frontend/src/hooks/useDocumentForm.ts) — フォーム下書き(移行対象外) +- TanStack Query 公式ドキュメント: https://tanstack.com/query/latest diff --git a/frontend/src/components/forms/ProjectModal.test.tsx b/frontend/src/components/forms/ProjectModal.test.tsx index 669d38bb..35906cf3 100644 --- a/frontend/src/components/forms/ProjectModal.test.tsx +++ b/frontend/src/components/forms/ProjectModal.test.tsx @@ -13,9 +13,7 @@ vi.mock("./ResumePdfTracePanel", () => ({ const invalidDateProject: CareerProjectForm = { name: "テスト", - start_date: "2024-12", - end_date: "2024-01", - is_current: false, + periods: [{ start_date: "2024-12", end_date: "2024-01", is_current: false }], role: "エンジニア", description: "", team: { total: "", members: [] }, @@ -25,9 +23,7 @@ const invalidDateProject: CareerProjectForm = { const emptyProject: CareerProjectForm = { name: "", - start_date: "", - end_date: "", - is_current: false, + periods: [{ start_date: "", end_date: "", is_current: false }], role: "", description: "", team: { total: "", members: [] }, diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index 0875f0ba..1ce7bcc4 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -45,6 +45,9 @@ export function ProjectModal({ local, dateError, updateField, + addPeriod, + removePeriod, + updatePeriodField, updateTechStack, addTechStack, removeTechStack, @@ -115,44 +118,60 @@ export function ProjectModal({ /> -
- - - {!local.is_current && ( - - )} +
+

+ 期間 + +

+ {local.periods.map((period, periodIndex) => ( +
+ + + {!period.is_current && ( + + )} + {local.periods.length > 1 && ( + + )} +
+ ))} +
{dateError && (

{dateError}

diff --git a/frontend/src/components/forms/sections/CareerExperienceSection.tsx b/frontend/src/components/forms/sections/CareerExperienceSection.tsx index 3e8bde5f..e57e1598 100644 --- a/frontend/src/components/forms/sections/CareerExperienceSection.tsx +++ b/frontend/src/components/forms/sections/CareerExperienceSection.tsx @@ -61,12 +61,15 @@ export function CareerExperienceSection({ const { modalTarget, setModalTarget, modalProject, handleProjectSave, closeModal } = useProjectModalState(mutators.getProject, mutators.onProjectSave); - /** プロジェクトの期間サマリーテキストを生成する */ + /** プロジェクトの期間サマリーテキストを生成する(複数期間は「、」区切り) */ const projectSummary = (proj: CareerProjectForm) => { - const period = [proj.start_date, proj.is_current ? "現在" : proj.end_date] + return proj.periods + .map((p) => { + const end = p.is_current ? "現在" : p.end_date; + return [p.start_date, end].filter(Boolean).join(" 〜 "); + }) .filter(Boolean) - .join(" 〜 "); - return period || ""; + .join("、"); }; /** モーダルを開くハンドラ */ diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index b6f46b0b..913e6b53 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -3,6 +3,7 @@ import type { CareerClientForm, CareerExperienceForm, CareerProjectForm, + CareerProjectPeriodForm, TeamMemberForm, } from "./payloadBuilders"; import type { CareerTechnologyStack } from "./types"; @@ -80,11 +81,15 @@ export const blankTeamMember: TeamMemberForm = { count: "", }; -export const blankCareerProject: CareerProjectForm = { - name: "", +export const blankCareerProjectPeriod: CareerProjectPeriodForm = { start_date: "", end_date: "", is_current: false, +}; + +export const blankCareerProject: CareerProjectForm = { + name: "", + periods: [{ ...blankCareerProjectPeriod }], role: "", description: "", team: { total: "", members: [] }, diff --git a/frontend/src/formMappers.ts b/frontend/src/formMappers.ts index df323eeb..d28f85de 100644 --- a/frontend/src/formMappers.ts +++ b/frontend/src/formMappers.ts @@ -2,6 +2,7 @@ import { blankCareerClient, blankCareerExperience, blankCareerProject, + blankCareerProjectPeriod, blankCareerTechnologyStack, blankResumeQualification, } from "./constants"; @@ -35,6 +36,9 @@ export function mapCareerResumeToForm(response: CareerResumeResponse): CareerFor client.projects.length > 0 ? client.projects.map((project) => ({ ...project, + periods: project.periods.length > 0 + ? project.periods + : [{ ...blankCareerProjectPeriod }], team: { total: project.team.total ?? "", members: project.team.members.map((member) => ({ diff --git a/frontend/src/formTypes.ts b/frontend/src/formTypes.ts index 661c0747..a78006f4 100644 --- a/frontend/src/formTypes.ts +++ b/frontend/src/formTypes.ts @@ -8,10 +8,5 @@ export type CareerExperienceFieldKey = | "employee_count" | "capital"; export type CareerClientFieldKey = "name"; -export type CareerProjectFieldKey = - | "name" - | "start_date" - | "end_date" - | "is_current" - | "role" - | "description"; +export type CareerProjectFieldKey = "name" | "role" | "description"; +export type CareerProjectPeriodFieldKey = "start_date" | "end_date" | "is_current"; diff --git a/frontend/src/hooks/career/useProjectFormDirty.test.ts b/frontend/src/hooks/career/useProjectFormDirty.test.ts index ff688ae3..7474f4e2 100644 --- a/frontend/src/hooks/career/useProjectFormDirty.test.ts +++ b/frontend/src/hooks/career/useProjectFormDirty.test.ts @@ -9,9 +9,7 @@ import { useProjectFormDirty } from "./useProjectFormDirty"; function buildProject(overrides: Partial = {}): CareerProjectForm { return { name: "プロジェクトX", - start_date: "2021-04", - end_date: "2022-03", - is_current: false, + periods: [{ start_date: "2021-04", end_date: "2022-03", is_current: false }], role: "Eng", description: "課題・行動・成果", team: { total: "5", members: [{ role: "PM", count: "1" }] }, @@ -70,6 +68,19 @@ describe("useProjectFormDirty", () => { expect(result.current.any).toBe(true); }); + it("期間を追加すると periods が true", () => { + const original = buildProject(); + const local = buildProject({ + periods: [ + ...original.periods, + { start_date: "2025-06", end_date: "", is_current: true }, + ], + }); + const { result } = renderHook(() => useProjectFormDirty(local, original)); + expect(result.current.periods).toBe(true); + expect(result.current.any).toBe(true); + }); + it("新規追加(original=null)の場合は blankCareerProject と比較し、入力すると dirty", () => { const local = buildProject(); // 入力済み const { result } = renderHook(() => useProjectFormDirty(local, null)); diff --git a/frontend/src/hooks/career/useProjectFormDirty.ts b/frontend/src/hooks/career/useProjectFormDirty.ts index 7c749778..42750062 100644 --- a/frontend/src/hooks/career/useProjectFormDirty.ts +++ b/frontend/src/hooks/career/useProjectFormDirty.ts @@ -11,12 +11,11 @@ export type ProjectFormDirty = { /** 単純フィールドの dirty */ fields: { name: boolean; - start_date: boolean; - end_date: boolean; - is_current: boolean; role: boolean; description: boolean; }; + /** 期間セクションの dirty(periods 配列が変化) */ + periods: boolean; /** 体制セクションの dirty(全体人数 or メンバー配列が変化) */ team: boolean; /** 技術スタックセクションの dirty(配列いずれかが変化) */ @@ -41,19 +40,17 @@ export function useProjectFormDirty( const fields = { name: local.name !== base.name, - start_date: local.start_date !== base.start_date, - end_date: local.end_date !== base.end_date, - is_current: local.is_current !== base.is_current, role: local.role !== base.role, description: local.description !== base.description, }; + const periods = !isDeepEqual(local.periods, base.periods); const team = !isDeepEqual(local.team, base.team); const technology_stacks = !isDeepEqual(local.technology_stacks, base.technology_stacks); const phases = !isDeepEqual(local.phases, base.phases); - const any = Object.values(fields).some(Boolean) || team || technology_stacks || phases; + const any = Object.values(fields).some(Boolean) || periods || team || technology_stacks || phases; - return { any, fields, team, technology_stacks, phases }; + return { any, fields, periods, team, technology_stacks, phases }; }, [local, original]); } diff --git a/frontend/src/hooks/career/useProjectModalForm.test.ts b/frontend/src/hooks/career/useProjectModalForm.test.ts index 3b67487e..2fed1315 100644 --- a/frontend/src/hooks/career/useProjectModalForm.test.ts +++ b/frontend/src/hooks/career/useProjectModalForm.test.ts @@ -6,9 +6,7 @@ import type { CareerProjectForm } from "../../payloadBuilders"; const sampleProject: CareerProjectForm = { name: "テスト", - start_date: "2024-01", - end_date: "2024-12", - is_current: false, + periods: [{ start_date: "2024-01", end_date: "2024-12", is_current: false }], role: "Backend", description: "", team: { total: "5", members: [{ role: "SE", count: "3" }] }, @@ -20,7 +18,8 @@ describe("useProjectModalForm", () => { it("project=null で初期化すると空の state が返る", () => { const { result } = renderHook(() => useProjectModalForm(null)); expect(result.current.local.name).toBe(""); - expect(result.current.local.is_current).toBe(false); + expect(result.current.local.periods).toHaveLength(1); + expect(result.current.local.periods[0].is_current).toBe(false); expect(result.current.local.technology_stacks).toHaveLength(1); expect(result.current.local.team.members).toHaveLength(0); }); @@ -29,15 +28,29 @@ describe("useProjectModalForm", () => { const { result } = renderHook(() => useProjectModalForm(sampleProject)); act(() => result.current.updateField("name", "差し替え")); expect(result.current.local.name).toBe("差し替え"); - // 元データは破壊されない expect(sampleProject.name).toBe("テスト"); }); - it("is_current=true に切り替えると end_date が空にリセットされる", () => { + it("期間の is_current=true に切り替えると end_date が空にリセットされる", () => { const { result } = renderHook(() => useProjectModalForm(sampleProject)); - act(() => result.current.updateField("is_current", true)); - expect(result.current.local.is_current).toBe(true); - expect(result.current.local.end_date).toBe(""); + act(() => result.current.updatePeriodField(0, "is_current", true)); + expect(result.current.local.periods[0].is_current).toBe(true); + expect(result.current.local.periods[0].end_date).toBe(""); + }); + + it("期間の追加・削除が動作する", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.addPeriod()); + expect(result.current.local.periods).toHaveLength(2); + act(() => result.current.removePeriod(1)); + expect(result.current.local.periods).toHaveLength(1); + }); + + it("期間を 1 件のみ残して削除すると空の期間に戻る", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.removePeriod(0)); + expect(result.current.local.periods).toHaveLength(1); + expect(result.current.local.periods[0].start_date).toBe(""); }); it("技術スタックのカテゴリを変えると name が空にリセットされる", () => { @@ -89,7 +102,10 @@ describe("useProjectModalForm", () => { it("開始日 > 終了日 のとき dateError が生成される", () => { const { result } = renderHook(() => - useProjectModalForm({ ...sampleProject, start_date: "2024-12", end_date: "2024-01" }), + useProjectModalForm({ + ...sampleProject, + periods: [{ start_date: "2024-12", end_date: "2024-01", is_current: false }], + }), ); expect(result.current.dateError).not.toBeNull(); }); @@ -98,9 +114,7 @@ describe("useProjectModalForm", () => { const { result } = renderHook(() => useProjectModalForm({ ...sampleProject, - start_date: "2024-12", - end_date: "2024-01", - is_current: true, + periods: [{ start_date: "2024-12", end_date: "2024-01", is_current: true }], }), ); expect(result.current.dateError).toBeNull(); diff --git a/frontend/src/hooks/career/useProjectModalForm.ts b/frontend/src/hooks/career/useProjectModalForm.ts index c3725eec..e1e3854a 100644 --- a/frontend/src/hooks/career/useProjectModalForm.ts +++ b/frontend/src/hooks/career/useProjectModalForm.ts @@ -2,13 +2,15 @@ import { useState } from "react"; import { blankCareerProject, + blankCareerProjectPeriod, blankCareerTechnologyStack, blankTeamMember, } from "../../constants"; -import type { CareerProjectFieldKey } from "../../formTypes"; +import type { CareerProjectFieldKey, CareerProjectPeriodFieldKey } from "../../formTypes"; import { - validateDateRange, + validatePeriods, type CareerProjectForm, + type CareerProjectPeriodForm, } from "../../payloadBuilders"; import type { CareerTechnologyStack, CareerTechnologyStackCategory } from "../../types"; @@ -33,14 +35,43 @@ export function initProject(project: CareerProjectForm | null): CareerProjectFor export function useProjectModalForm(project: CareerProjectForm | null) { const [local, setLocal] = useState(() => initProject(project)); - const updateField = (key: CareerProjectFieldKey, value: string | boolean) => { - setLocal((prev) => { - if (key === "is_current") { - const isCurrent = Boolean(value); - return { ...prev, is_current: isCurrent, end_date: isCurrent ? "" : prev.end_date }; - } - return { ...prev, [key]: value }; - }); + const updateField = (key: CareerProjectFieldKey, value: string) => { + setLocal((prev) => ({ ...prev, [key]: value })); + }; + + const addPeriod = () => { + setLocal((prev) => ({ + ...prev, + periods: [...prev.periods, { ...blankCareerProjectPeriod }], + })); + }; + + const removePeriod = (periodIndex: number) => { + setLocal((prev) => ({ + ...prev, + periods: + prev.periods.length === 1 + ? [{ ...blankCareerProjectPeriod }] + : prev.periods.filter((_, i) => i !== periodIndex), + })); + }; + + const updatePeriodField = ( + periodIndex: number, + key: CareerProjectPeriodFieldKey, + value: string | boolean, + ) => { + setLocal((prev) => ({ + ...prev, + periods: prev.periods.map((p, i): CareerProjectPeriodForm => { + if (i !== periodIndex) return p; + if (key === "is_current") { + const isCurrent = Boolean(value); + return { ...p, is_current: isCurrent, end_date: isCurrent ? "" : p.end_date }; + } + return { ...p, [key]: value }; + }), + })); }; const updateTechStack = ( @@ -119,12 +150,15 @@ export function useProjectModalForm(project: CareerProjectForm | null) { }); }; - const dateError = validateDateRange(local.start_date, local.end_date, local.is_current); + const dateError = validatePeriods(local.periods); return { local, dateError, updateField, + addPeriod, + removePeriod, + updatePeriodField, updateTechStack, addTechStack, removeTechStack, diff --git a/frontend/src/hooks/career/useProjectModalState.test.ts b/frontend/src/hooks/career/useProjectModalState.test.ts index 6d181e09..f5b8fd30 100644 --- a/frontend/src/hooks/career/useProjectModalState.test.ts +++ b/frontend/src/hooks/career/useProjectModalState.test.ts @@ -5,9 +5,7 @@ import type { CareerProjectForm } from "../../payloadBuilders"; const dummyProject: CareerProjectForm = { name: "テストプロジェクト", - start_date: "2024-01", - end_date: "2024-12", - is_current: false, + periods: [{ start_date: "2024-01", end_date: "2024-12", is_current: false }], role: "エンジニア", description: "", team: { total: "", members: [] }, diff --git a/frontend/src/payloadBuilders.test.ts b/frontend/src/payloadBuilders.test.ts index d05991fa..d3bc94cd 100644 --- a/frontend/src/payloadBuilders.test.ts +++ b/frontend/src/payloadBuilders.test.ts @@ -7,15 +7,21 @@ import { type CareerExperienceForm, type CareerFormState, type CareerProjectForm, + type CareerProjectPeriodForm, } from "./payloadBuilders"; // ── 共通 fixture ──────────────────────────────────────────────── -const blankProject = (overrides: Partial = {}): CareerProjectForm => ({ - name: "P", +const blankPeriod = (overrides: Partial = {}): CareerProjectPeriodForm => ({ start_date: "2024-01", end_date: "2024-06", is_current: false, + ...overrides, +}); + +const blankProject = (overrides: Partial = {}): CareerProjectForm => ({ + name: "P", + periods: [blankPeriod()], role: "", description: "", team: { total: "", members: [] }, @@ -184,7 +190,7 @@ describe("buildCareerPayload (experiences)", () => { // ── projects / clients / team の境界 ───────────────────────── describe("buildCareerPayload (projects/clients/team)", () => { - it("project.is_current=true なら end_date が空文字に正規化される", () => { + it("project の period.is_current=true なら end_date が空文字に正規化される", () => { const payload = buildCareerPayload( baseState({ experiences: [ @@ -195,8 +201,7 @@ describe("buildCareerPayload (projects/clients/team)", () => { has_client: true, projects: [ blankProject({ - is_current: true, - end_date: "2024-12", + periods: [blankPeriod({ is_current: true, end_date: "2024-12" })], }), ], }, @@ -205,12 +210,12 @@ describe("buildCareerPayload (projects/clients/team)", () => { ], }), ); - const proj = payload.experiences[0].clients[0].projects[0]; - expect(proj.end_date).toBe(""); - expect(proj.is_current).toBe(true); + const period = payload.experiences[0].clients[0].projects[0].periods[0]; + expect(period.end_date).toBe(""); + expect(period.is_current).toBe(true); }); - it("内容のある project で開始年月が空ならエラー", () => { + it("内容のある project で期間の開始年月が空ならエラー", () => { expect(() => buildCareerPayload( baseState({ @@ -220,7 +225,7 @@ describe("buildCareerPayload (projects/clients/team)", () => { { name: "顧客A", has_client: true, - projects: [blankProject({ start_date: "" })], + projects: [blankProject({ periods: [blankPeriod({ start_date: "" })] })], }, ], }), @@ -230,7 +235,7 @@ describe("buildCareerPayload (projects/clients/team)", () => { ).toThrow(/プロジェクトの開始年月/); }); - it("project が is_current=false で終了年月が空ならエラー", () => { + it("project の period が is_current=false で終了年月が空ならエラー", () => { expect(() => buildCareerPayload( baseState({ @@ -240,7 +245,9 @@ describe("buildCareerPayload (projects/clients/team)", () => { { name: "顧客A", has_client: true, - projects: [blankProject({ is_current: false, end_date: "" })], + projects: [ + blankProject({ periods: [blankPeriod({ is_current: false, end_date: "" })] }), + ], }, ], }), diff --git a/frontend/src/payloadBuilders.ts b/frontend/src/payloadBuilders.ts index b6c99fa1..6cd0e000 100644 --- a/frontend/src/payloadBuilders.ts +++ b/frontend/src/payloadBuilders.ts @@ -5,6 +5,7 @@ import type { CareerProject, CareerResumePayload, CareerTechnologyStack, + ProjectPeriod, ProjectTeam, ResumeQualification, TeamMember, @@ -15,11 +16,15 @@ export type TeamMemberForm = { count: string; }; -export type CareerProjectForm = { - name: string; +export type CareerProjectPeriodForm = { start_date: string; end_date: string; is_current: boolean; +}; + +export type CareerProjectForm = { + name: string; + periods: CareerProjectPeriodForm[]; role: string; description: string; team: { @@ -70,6 +75,15 @@ export function validateDateRange( return null; } +/** periods 配列内にエラーがあれば最初のエラーメッセージを返す */ +export function validatePeriods(periods: CareerProjectPeriodForm[]): string | null { + for (const p of periods) { + const err = validateDateRange(p.start_date, p.end_date, p.is_current); + if (err) return err; + } + return null; +} + function buildTeam(team: CareerProjectForm["team"]): ProjectTeam { const members: TeamMember[] = team.members .filter((m) => m.role.trim() && String(m.count).trim()) @@ -80,12 +94,18 @@ function buildTeam(team: CareerProjectForm["team"]): ProjectTeam { }; } +function buildPeriod(p: CareerProjectPeriodForm): ProjectPeriod { + return { + start_date: p.start_date.trim(), + end_date: p.is_current ? "" : p.end_date.trim(), + is_current: p.is_current, + }; +} + function buildProject(proj: CareerProjectForm): CareerProject { return { name: proj.name.trim(), - start_date: proj.start_date.trim(), - end_date: proj.is_current ? "" : proj.end_date.trim(), - is_current: proj.is_current, + periods: proj.periods.map(buildPeriod), role: proj.role.trim(), description: proj.description.trim(), team: buildTeam(proj.team), @@ -154,21 +174,22 @@ export function buildCareerPayload(state: CareerFormState): CareerResumePayload } for (const client of exp.clients) { for (const proj of client.projects) { - // 内容のあるプロジェクト行は開始年月が必須(空のまま送ると backend が 422)。 - // 参画中でなければ終了年月も必須。 - if (!proj.start_date) { - throw new Error(VALIDATION_MESSAGES.PROJECT_START_DATE_REQUIRED); - } - if (!proj.is_current && !proj.end_date) { - throw new Error(VALIDATION_MESSAGES.PROJECT_END_DATE_REQUIRED); - } - if ( - !proj.is_current && - proj.start_date && - proj.end_date && - proj.end_date < proj.start_date - ) { - throw new Error(VALIDATION_MESSAGES.DATE_RANGE_INVALID); + // 内容のあるプロジェクトは periods が 1 件以上あり、各期間の開始年月が必須。 + for (const period of proj.periods) { + if (!period.start_date) { + throw new Error(VALIDATION_MESSAGES.PROJECT_START_DATE_REQUIRED); + } + if (!period.is_current && !period.end_date) { + throw new Error(VALIDATION_MESSAGES.PROJECT_END_DATE_REQUIRED); + } + if ( + !period.is_current && + period.start_date && + period.end_date && + period.end_date < period.start_date + ) { + throw new Error(VALIDATION_MESSAGES.DATE_RANGE_INVALID); + } } } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 74b76f68..a105696b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -47,11 +47,15 @@ export type ProjectTeam = { members: TeamMember[]; }; -export type CareerProject = { - name: string; +export type ProjectPeriod = { start_date: string; end_date: string; is_current: boolean; +}; + +export type CareerProject = { + name: string; + periods: ProjectPeriod[]; role: string; /** 課題・行動・成果を統合した自由記述欄(見出し「詳細」) */ description: string; diff --git a/frontend/tests/payloadBuilders.test.cjs b/frontend/tests/payloadBuilders.test.cjs index 5afbdf1d..ee9b0f4e 100644 --- a/frontend/tests/payloadBuilders.test.cjs +++ b/frontend/tests/payloadBuilders.test.cjs @@ -24,9 +24,9 @@ test("buildCareerPayload trims data and keeps only non-empty technology stacks", projects: [ { name: " プロジェクトA ", - start_date: "2020-04", - end_date: "2021-03", - is_current: false, + periods: [ + { start_date: "2020-04", end_date: "2021-03", is_current: false } + ], role: " メンバー ", description: " 課題・行動・成果テスト ", team: { From f131f2c79982ab186f46ca5cd13846d13a6769cf Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 29 May 2026 01:11:36 +0900 Subject: [PATCH 4/5] coderabbit fix --- ...0037_merge_project_caf_into_description.py | 15 +++++++++++++ .../CareerExperienceEditor.tsx | 13 ++++++++---- .../src/components/forms/ProjectModal.tsx | 11 ++++++++-- .../sections/CareerQualificationsSection.tsx | 9 +++++--- .../src/hooks/career/usePdfPanelLayout.ts | 2 ++ .../hooks/career/useProjectFormDirty.test.ts | 18 ++++++++++++++++ frontend/src/payloadBuilders.test.ts | 21 +++++++++++++++++++ 7 files changed, 80 insertions(+), 9 deletions(-) diff --git a/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py b/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py index 327a603c..deaaa66a 100644 --- a/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py +++ b/backend/alembic_migrations/versions/0037_merge_project_caf_into_description.py @@ -18,6 +18,7 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy import text # revision identifiers, used by Alembic. revision: str = "0037_merge_project_caf_into_description" @@ -27,6 +28,20 @@ def upgrade() -> None: + conn = op.get_bind() + result = conn.execute( + text( + "SELECT COUNT(*) FROM resume_projects" + " WHERE challenge != '' OR action != '' OR result != ''" + ) + ) + count = result.scalar() or 0 + if count > 0: + raise RuntimeError( + f"resume_projects に challenge/action/result のデータが {count} 件残っています。" + "カラム削除前にデータを退避してください。" + ) + with op.batch_alter_table("resume_projects") as batch_op: batch_op.add_column( sa.Column("description", sa.Text(), nullable=False, server_default=""), diff --git a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx index 3e2c6226..a24b41b9 100644 --- a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx +++ b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx @@ -81,8 +81,9 @@ export function CareerExperienceEditor({