diff --git a/src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx b/src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx
index 70724abb..4f38936c 100644
--- a/src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx
+++ b/src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx
@@ -26,6 +26,24 @@ export const mockDates = [
"2026-01-27",
];
+// 水曜開始 2 週間(月曜起算で先頭 月火 / 末尾 火〜日 が期間外)
+export const mockDatesMidWeekStart = [
+ "2026-01-21",
+ "2026-01-22",
+ "2026-01-23",
+ "2026-01-24",
+ "2026-01-25",
+ "2026-01-26",
+ "2026-01-27",
+ "2026-01-28",
+ "2026-01-29",
+ "2026-01-30",
+ "2026-01-31",
+ "2026-02-01",
+ "2026-02-02",
+ "2026-02-03",
+];
+
export const mockTimeRange: TimeRange = { start: 9, end: 22, unit: 30 };
export const mockHolidays = ["2026-02-11"];
diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx
index ac52eb6a..b9bd607a 100644
--- a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx
+++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { JotaiStoryWrapper, mockHolidays } from "../../__mocks__/storyData";
+import { JotaiStoryWrapper, mockDatesMidWeekStart, mockHolidays } from "../../__mocks__/storyData";
import { OverviewView } from ".";
const meta = {
@@ -43,3 +43,21 @@ export const WithHolidays: Story = {
),
};
+
+// 水曜開始 2 週間。月曜起算で先頭の月火と末尾の火〜日が期間外セルになる
+export const MidWeekStart: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+// 日曜起算で同じ期間を表示するケース
+export const SundayStart: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx
index 17d9159a..4d671edc 100644
--- a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx
+++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx
@@ -5,26 +5,25 @@ import { useCallback, useMemo, useState } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { selectedDateAtom, shiftConfigAtom, shiftsAtom, viewModeAtom } from "../../stores";
import type { ShiftData, StaffType } from "../../types";
-import { getWeekdayLabel } from "../../utils/dateUtils";
+import { buildWeeklyGrid, getWeekdayLabel, type WeekStart } from "../../utils/dateUtils";
import { timeToMinutes } from "../../utils/timeConversion";
type DateInfo = {
iso: string;
label: string;
wk: string;
- weekIdx: number;
+ inRange: boolean;
};
-const buildDateInfos = (dates: string[]): DateInfo[] =>
- dates.map((iso, i) => {
- const d = dayjs(iso);
- return {
- iso,
- label: `${d.month() + 1}/${d.date()}`,
- wk: getWeekdayLabel(iso),
- weekIdx: Math.floor(i / 7),
- };
- });
+const toDateInfo = (cell: { iso: string; inRange: boolean }): DateInfo => {
+ const d = dayjs(cell.iso);
+ return {
+ iso: cell.iso,
+ label: `${d.month() + 1}/${d.date()}`,
+ wk: getWeekdayLabel(cell.iso),
+ inRange: cell.inRange,
+ };
+};
const dayColor = (iso: string, holidays: string[]): string => {
const day = dayjs(iso).day();
@@ -45,22 +44,23 @@ const shiftHours = (range: [string, string] | null): number => {
return minutes / 60;
};
-export const OverviewView = () => {
+type OverviewViewProps = {
+ weekStart?: WeekStart;
+};
+
+export const OverviewView = ({ weekStart = "mon" }: OverviewViewProps) => {
const config = useAtomValue(shiftConfigAtom);
const shifts = useAtomValue(shiftsAtom);
const setSelectedDate = useSetAtom(selectedDateAtom);
const setViewMode = useSetAtom(viewModeAtom);
const { dates, holidays, isReadOnly, staffs } = config;
- const dateInfos = useMemo(() => buildDateInfos(dates), [dates]);
- const weekCount = Math.max(1, Math.ceil(dateInfos.length / 7));
+ const weeks = useMemo(
+ () => buildWeeklyGrid(dates, weekStart).map((week) => week.map(toDateInfo)),
+ [dates, weekStart],
+ );
- const initialOpen = useMemo(() => {
- const o: Record = {};
- for (let i = 0; i < weekCount; i++) o[i] = true;
- return o;
- }, [weekCount]);
- const [open, setOpen] = useState(initialOpen);
+ const [open, setOpen] = useState>({});
const lookup = useMemo(() => {
const map = new Map();
@@ -83,13 +83,12 @@ export const OverviewView = () => {
return (
- {Array.from({ length: weekCount }).map((_, wi) => {
- const wkDates = dateInfos.filter((d) => d.weekIdx === wi);
+ {weeks.map((wkDates, wi) => {
if (wkDates.length === 0) return null;
- const isOpen = !!open[wi];
+ const isOpen = open[wi] !== false;
return (
(
-
- {
+ const inRangeDates = wkDates.filter((d) => d.inRange);
+ const rangeLabel =
+ inRangeDates.length > 0 ? `${inRangeDates[0].label} – ${inRangeDates[inRangeDates.length - 1].label}` : "";
+ return (
+
- {isOpen ? : }
+
+ {isOpen ? : }
+
+
+ {rangeLabel}
+
+
+ ({inRangeDates.length}日)
+
-
- {wkDates[0].label} – {wkDates[wkDates.length - 1].label}
-
-
- ({wkDates.length}日)
-
-
- {isOpen && (
-
- )}
-
-);
+ {isOpen && (
+
+ )}
+
+ );
+};
type WeekTableProps = {
staffs: StaffType[];
@@ -204,26 +208,30 @@ const WeekTable = ({ staffs, wkDates, lookup, holidays, onDateClick, isReadOnly
>
スタッフ
- {wkDates.map((d) => (
- onDateClick(d.iso)}
- style={{
- padding: "10px 4px",
- fontWeight: 600,
- textAlign: "center",
- cursor: isReadOnly ? "default" : "pointer",
- }}
- >
-
- {d.label}
-
-
- {d.wk}
+ {wkDates.map((d) => {
+ const isClickable = !isReadOnly && d.inRange;
+ return (
+ onDateClick(d.iso) : undefined}
+ style={{
+ padding: "10px 4px",
+ fontWeight: 600,
+ textAlign: "center",
+ cursor: isClickable ? "pointer" : "default",
+ opacity: d.inRange ? 1 : 0.35,
+ }}
+ >
+
+ {d.label}
+
+
+ {d.wk}
+
-
- ))}
+ );
+ })}
{wkDates.map((d) => {
- const asn = lookup.get(`${s.id}-${d.iso}`) ?? null;
+ const asn = d.inRange ? (lookup.get(`${s.id}-${d.iso}`) ?? null) : null;
if (asn) total += shiftHours(asn);
return (
@@ -272,7 +280,7 @@ const WeekTable = ({ staffs, wkDates, lookup, holidays, onDateClick, isReadOnly
{asn[0]}–{asn[1]}
) : (
-
+
—
)}
diff --git a/src/components/features/Shift/ShiftForm/utils/dateUtils.test.ts b/src/components/features/Shift/ShiftForm/utils/dateUtils.test.ts
new file mode 100644
index 00000000..e9c13060
--- /dev/null
+++ b/src/components/features/Shift/ShiftForm/utils/dateUtils.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, test } from "vitest";
+import { buildWeeklyGrid, getWeekStartDate } from "./dateUtils";
+
+describe("getWeekStartDate", () => {
+ test("月曜起算(デフォルト)で水曜日の週開始日は直前の月曜日", () => {
+ // 2026-01-21 は水曜
+ expect(getWeekStartDate("2026-01-21")).toBe("2026-01-19");
+ });
+
+ test("月曜起算で月曜日の週開始日は当日", () => {
+ expect(getWeekStartDate("2026-01-19", "mon")).toBe("2026-01-19");
+ });
+
+ test("月曜起算で日曜日の週開始日は直前の月曜日", () => {
+ // 2026-01-25 は日曜
+ expect(getWeekStartDate("2026-01-25", "mon")).toBe("2026-01-19");
+ });
+
+ test("日曜起算で水曜日の週開始日は直前の日曜日", () => {
+ expect(getWeekStartDate("2026-01-21", "sun")).toBe("2026-01-18");
+ });
+
+ test("日曜起算で日曜日の週開始日は当日", () => {
+ expect(getWeekStartDate("2026-01-25", "sun")).toBe("2026-01-25");
+ });
+});
+
+describe("buildWeeklyGrid", () => {
+ test("月曜開始 7 日は 1 週・全て期間内", () => {
+ const dates = ["2026-01-19", "2026-01-20", "2026-01-21", "2026-01-22", "2026-01-23", "2026-01-24", "2026-01-25"];
+ const grid = buildWeeklyGrid(dates);
+ expect(grid).toHaveLength(1);
+ expect(grid[0]).toHaveLength(7);
+ expect(grid[0].every((cell) => cell.inRange)).toBe(true);
+ expect(grid[0][0].iso).toBe("2026-01-19");
+ expect(grid[0][6].iso).toBe("2026-01-25");
+ });
+
+ test("水曜開始 14 日は 3 週・先頭月火と末尾火〜日が期間外(月曜起算)", () => {
+ const dates = Array.from({ length: 14 }, (_, i) => new Date(Date.UTC(2026, 0, 21 + i)).toISOString().slice(0, 10));
+ const grid = buildWeeklyGrid(dates);
+ expect(grid).toHaveLength(3);
+
+ // 週1: 月火が期間外、水〜日が期間内
+ expect(grid[0][0]).toEqual({ iso: "2026-01-19", inRange: false });
+ expect(grid[0][1]).toEqual({ iso: "2026-01-20", inRange: false });
+ expect(grid[0][2]).toEqual({ iso: "2026-01-21", inRange: true });
+ expect(grid[0][6]).toEqual({ iso: "2026-01-25", inRange: true });
+
+ // 週2: 全て期間内
+ expect(grid[1].every((c) => c.inRange)).toBe(true);
+ expect(grid[1][0].iso).toBe("2026-01-26");
+
+ // 週3: 月火が期間内、水〜日が期間外
+ expect(grid[2][0]).toEqual({ iso: "2026-02-02", inRange: true });
+ expect(grid[2][1]).toEqual({ iso: "2026-02-03", inRange: true });
+ expect(grid[2][2]).toEqual({ iso: "2026-02-04", inRange: false });
+ expect(grid[2][6]).toEqual({ iso: "2026-02-08", inRange: false });
+ });
+
+ test("日曜開始 7 日は月曜起算で 2 週・先頭月〜土が期間外、末尾日のみ期間内", () => {
+ // 2026-01-25 は日曜、2026-01-31 は土曜
+ const dates = ["2026-01-25", "2026-01-26", "2026-01-27", "2026-01-28", "2026-01-29", "2026-01-30", "2026-01-31"];
+ const grid = buildWeeklyGrid(dates);
+ expect(grid).toHaveLength(2);
+
+ // 週1: 月〜土が期間外、日のみ期間内
+ expect(grid[0].slice(0, 6).every((c) => !c.inRange)).toBe(true);
+ expect(grid[0][6]).toEqual({ iso: "2026-01-25", inRange: true });
+
+ // 週2: 月〜土が期間内、日が期間外
+ expect(grid[1].slice(0, 6).every((c) => c.inRange)).toBe(true);
+ expect(grid[1][6]).toEqual({ iso: "2026-02-01", inRange: false });
+ });
+
+ test("金曜開始 3 日は 1 週内・前後が期間外(月曜起算)", () => {
+ // 2026-01-23 金、24 土、25 日
+ const dates = ["2026-01-23", "2026-01-24", "2026-01-25"];
+ const grid = buildWeeklyGrid(dates);
+ expect(grid).toHaveLength(1);
+ expect(grid[0].map((c) => c.inRange)).toEqual([false, false, false, false, true, true, true]);
+ expect(grid[0][0].iso).toBe("2026-01-19");
+ expect(grid[0][6].iso).toBe("2026-01-25");
+ });
+
+ test("日曜起算で日曜開始 7 日は 1 週・全て期間内", () => {
+ const dates = ["2026-01-25", "2026-01-26", "2026-01-27", "2026-01-28", "2026-01-29", "2026-01-30", "2026-01-31"];
+ const grid = buildWeeklyGrid(dates, "sun");
+ expect(grid).toHaveLength(1);
+ expect(grid[0][0].iso).toBe("2026-01-25");
+ expect(grid[0][6].iso).toBe("2026-01-31");
+ expect(grid[0].every((c) => c.inRange)).toBe(true);
+ });
+
+ test("日曜起算で水曜開始 14 日は先頭日〜火が期間外", () => {
+ const dates = Array.from({ length: 14 }, (_, i) => new Date(Date.UTC(2026, 0, 21 + i)).toISOString().slice(0, 10));
+ const grid = buildWeeklyGrid(dates, "sun");
+ expect(grid).toHaveLength(3);
+ // 週1 は 2026-01-18 (日) 開始
+ expect(grid[0][0]).toEqual({ iso: "2026-01-18", inRange: false });
+ expect(grid[0][1]).toEqual({ iso: "2026-01-19", inRange: false });
+ expect(grid[0][2]).toEqual({ iso: "2026-01-20", inRange: false });
+ expect(grid[0][3]).toEqual({ iso: "2026-01-21", inRange: true });
+ });
+
+ test("空配列を渡すと空配列を返す", () => {
+ expect(buildWeeklyGrid([])).toEqual([]);
+ });
+});
diff --git a/src/components/features/Shift/ShiftForm/utils/dateUtils.ts b/src/components/features/Shift/ShiftForm/utils/dateUtils.ts
index 667bfaff..fa5e9380 100644
--- a/src/components/features/Shift/ShiftForm/utils/dateUtils.ts
+++ b/src/components/features/Shift/ShiftForm/utils/dateUtils.ts
@@ -78,3 +78,40 @@ export const getMonthKey = (date: string): string => {
export const formatDateTime = (date: Date): string => {
return dayjs(date).format("YYYY/M/D HH:mm");
};
+
+// 週開始曜日
+export type WeekStart = "mon" | "sun";
+
+// 週開始曜日から見たオフセット(0=週頭)を返す
+const weekdayOffset = (date: string, weekStart: WeekStart): number => {
+ const day = dayjs(date).day(); // 0=日, 1=月, ..., 6=土
+ return weekStart === "mon" ? (day + 6) % 7 : day;
+};
+
+// 指定日を含む週の「週開始日」ISO を返す
+export const getWeekStartDate = (date: string, weekStart: WeekStart = "mon"): string => {
+ return dayjs(date).subtract(weekdayOffset(date, weekStart), "day").format("YYYY-MM-DD");
+};
+
+// 期間 dates を 7 × N の週グリッドに変換
+// 返値: 各要素は { iso: "YYYY-MM-DD", inRange: boolean }。期間外セルも日付を持つ
+export const buildWeeklyGrid = (
+ dates: string[],
+ weekStart: WeekStart = "mon",
+): Array> => {
+ if (dates.length === 0) return [];
+ const inRangeSet = new Set(dates);
+ const gridStart = dayjs(getWeekStartDate(dates[0], weekStart));
+ const lastDate = dates[dates.length - 1];
+ const gridEnd = dayjs(getWeekStartDate(lastDate, weekStart)).add(6, "day");
+ const totalDays = gridEnd.diff(gridStart, "day") + 1;
+ const weeks: Array> = [];
+ for (let i = 0; i < totalDays; i += 7) {
+ const week = Array.from({ length: 7 }, (_, j) => {
+ const iso = gridStart.add(i + j, "day").format("YYYY-MM-DD");
+ return { iso, inRange: inRangeSet.has(iso) };
+ });
+ weeks.push(week);
+ }
+ return weeks;
+};