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({ {/* グローバル CSS で label { display: grid } のため、テキストと DirtyDot を span で 束ねないと別々の行になる。span で 1 グリッド行に束ねることでラベル右側に並べる。 */} - + 会社名 + 必須 - + 事業内容 + 必須 - + 開始 + 必須 {!exp.is_current && ( - + 離職年月 + 必須 onUpdateExperienceField(expIndex, "employee_count", e.target.value) diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index 1ce7bcc4..e25ab167 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -15,6 +15,7 @@ import { Combobox } from "./Combobox"; import { MarkdownTextarea } from "./MarkdownTextarea"; import { ResumePdfTracePanel } from "./ResumePdfTracePanel"; import { DirtyDot } from "../ui/DirtyDot"; +import shared from "../../styles/shared.module.css"; import styles from "./ProjectModal.module.css"; type ProjectModalProps = { @@ -126,7 +127,10 @@ export function ProjectModal({ {local.periods.map((period, periodIndex) => ( - 開始 + + 開始 + 必須 + {!period.is_current && ( - 終了 + + 終了 + 必須 + - - 資格名 ※プルダウンにないものはテキストで入力できます。 + + 資格名 + 必須 + ※プルダウンにないものはテキストで入力できます。 - + 取得日 + 必須 { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; }; }, [containerRef, minWidth, minFormWidth, reservedGap]); diff --git a/frontend/src/hooks/career/useProjectFormDirty.test.ts b/frontend/src/hooks/career/useProjectFormDirty.test.ts index 7474f4e2..e57b8e1f 100644 --- a/frontend/src/hooks/career/useProjectFormDirty.test.ts +++ b/frontend/src/hooks/career/useProjectFormDirty.test.ts @@ -94,4 +94,22 @@ describe("useProjectFormDirty", () => { const { result } = renderHook(() => useProjectFormDirty(local, null)); expect(result.current.any).toBe(false); }); + + it("description を変更すると fields.description と any が true", () => { + const original = buildProject(); + const local = buildProject({ description: "変更後の詳細" }); + const { result } = renderHook(() => useProjectFormDirty(local, original)); + expect(result.current.fields.description).toBe(true); + expect(result.current.any).toBe(true); + expect(result.current.fields.name).toBe(false); + expect(result.current.team).toBe(false); + }); + + it("description を元の値に戻すと fields.description と any が false", () => { + const original = buildProject(); + const local = buildProject({ description: original.description }); + const { result } = renderHook(() => useProjectFormDirty(local, original)); + expect(result.current.fields.description).toBe(false); + expect(result.current.any).toBe(false); + }); }); diff --git a/frontend/src/payloadBuilders.test.ts b/frontend/src/payloadBuilders.test.ts index d3bc94cd..8f1698c6 100644 --- a/frontend/src/payloadBuilders.test.ts +++ b/frontend/src/payloadBuilders.test.ts @@ -235,6 +235,26 @@ describe("buildCareerPayload (projects/clients/team)", () => { ).toThrow(/プロジェクトの開始年月/); }); + it("内容のある project に period が 1 件も無いならエラー", () => { + expect(() => + buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + clients: [ + { + name: "顧客A", + has_client: true, + projects: [blankProject({ name: "P", description: "詳細", periods: [] })], + }, + ], + }), + ], + }), + ), + ).toThrow(/プロジェクトの開始年月/); + }); + it("project の period が is_current=false で終了年月が空ならエラー", () => { expect(() => buildCareerPayload( @@ -381,6 +401,27 @@ describe("buildCareerPayload (projects/clients/team)", () => { { category: "db", name: "PostgreSQL" }, ]); }); + + it("project の name が空で description に内容があればプロジェクトはペイロードに含まれる", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + clients: [ + { + name: "C", + has_client: true, + projects: [blankProject({ name: "", description: "開発の詳細" })], + }, + ], + }), + ], + }), + ); + const project = payload.experiences[0].clients[0].projects[0]; + expect(project.name).toBe(""); + expect(project.description).toBe("開発の詳細"); + }); }); // ── qualifications の境界 ──────────────────────────────────── diff --git a/frontend/src/payloadBuilders.ts b/frontend/src/payloadBuilders.ts index 6cd0e000..98389e57 100644 --- a/frontend/src/payloadBuilders.ts +++ b/frontend/src/payloadBuilders.ts @@ -175,6 +175,9 @@ export function buildCareerPayload(state: CareerFormState): CareerResumePayload for (const client of exp.clients) { for (const proj of client.projects) { // 内容のあるプロジェクトは periods が 1 件以上あり、各期間の開始年月が必須。 + if (proj.periods.length === 0) { + throw new Error(VALIDATION_MESSAGES.PROJECT_START_DATE_REQUIRED); + } for (const period of proj.periods) { if (!period.start_date) { throw new Error(VALIDATION_MESSAGES.PROJECT_START_DATE_REQUIRED);