-
-
- {!local.is_current && (
-
- )}
+
{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: {