Skip to content

Commit 025d9d6

Browse files
committed
fix: シフトボード一覧(PC)を月曜始まりの固定週グリッドに
募集開始曜日に合わせて週が区切られていたため、カレンダー感覚と 合わず視認性が悪かった。常に月曜〜日曜の 7 列で揃うように変更し、 期間外セルは薄色+クリック無効で、日別ビューへ遷移しないようにした。 - dateUtils に buildWeeklyGrid / getWeekStartDate / WeekStart を追加 (デフォルトは月曜起算、Props で日曜起算も選べる) - PC OverviewView を weekly grid ベースに刷新 - 期間外ヘッダーは opacity 0.35・cursor default、ボディは常に '—' - 週ラベルと (N 日) は期間内日付のみを参照 - MidWeekStart / SundayStart の Story と dateUtils のテストを追加
1 parent 5c470f3 commit 025d9d6

File tree

5 files changed

+284
-89
lines changed

5 files changed

+284
-89
lines changed

src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ export const mockDates = [
2626
"2026-01-27",
2727
];
2828

29+
// 水曜開始 2 週間(月曜起算で先頭 月火 / 末尾 火〜日 が期間外)
30+
export const mockDatesMidWeekStart = [
31+
"2026-01-21",
32+
"2026-01-22",
33+
"2026-01-23",
34+
"2026-01-24",
35+
"2026-01-25",
36+
"2026-01-26",
37+
"2026-01-27",
38+
"2026-01-28",
39+
"2026-01-29",
40+
"2026-01-30",
41+
"2026-01-31",
42+
"2026-02-01",
43+
"2026-02-02",
44+
"2026-02-03",
45+
];
46+
2947
export const mockTimeRange: TimeRange = { start: 9, end: 22, unit: 30 };
3048

3149
export const mockHolidays = ["2026-02-11"];

src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
2-
import { JotaiStoryWrapper, mockHolidays } from "../../__mocks__/storyData";
2+
import { JotaiStoryWrapper, mockDatesMidWeekStart, mockHolidays } from "../../__mocks__/storyData";
33
import { OverviewView } from ".";
44

55
const meta = {
@@ -43,3 +43,21 @@ export const WithHolidays: Story = {
4343
</JotaiStoryWrapper>
4444
),
4545
};
46+
47+
// 水曜開始 2 週間。月曜起算で先頭の月火と末尾の火〜日が期間外セルになる
48+
export const MidWeekStart: Story = {
49+
render: () => (
50+
<JotaiStoryWrapper overrides={{ initialViewMode: "overview", dates: mockDatesMidWeekStart }}>
51+
<OverviewView />
52+
</JotaiStoryWrapper>
53+
),
54+
};
55+
56+
// 日曜起算で同じ期間を表示するケース
57+
export const SundayStart: Story = {
58+
render: () => (
59+
<JotaiStoryWrapper overrides={{ initialViewMode: "overview", dates: mockDatesMidWeekStart }}>
60+
<OverviewView weekStart="sun" />
61+
</JotaiStoryWrapper>
62+
),
63+
};

src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx

Lines changed: 101 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,25 @@ import { useCallback, useMemo, useState } from "react";
55
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
66
import { selectedDateAtom, shiftConfigAtom, shiftsAtom, viewModeAtom } from "../../stores";
77
import type { ShiftData, StaffType } from "../../types";
8-
import { getWeekdayLabel } from "../../utils/dateUtils";
8+
import { buildWeeklyGrid, getWeekdayLabel, type WeekStart } from "../../utils/dateUtils";
99
import { timeToMinutes } from "../../utils/timeConversion";
1010

1111
type DateInfo = {
1212
iso: string;
1313
label: string;
1414
wk: string;
15-
weekIdx: number;
15+
inRange: boolean;
1616
};
1717

18-
const buildDateInfos = (dates: string[]): DateInfo[] =>
19-
dates.map((iso, i) => {
20-
const d = dayjs(iso);
21-
return {
22-
iso,
23-
label: `${d.month() + 1}/${d.date()}`,
24-
wk: getWeekdayLabel(iso),
25-
weekIdx: Math.floor(i / 7),
26-
};
27-
});
18+
const toDateInfo = (cell: { iso: string; inRange: boolean }): DateInfo => {
19+
const d = dayjs(cell.iso);
20+
return {
21+
iso: cell.iso,
22+
label: `${d.month() + 1}/${d.date()}`,
23+
wk: getWeekdayLabel(cell.iso),
24+
inRange: cell.inRange,
25+
};
26+
};
2827

2928
const dayColor = (iso: string, holidays: string[]): string => {
3029
const day = dayjs(iso).day();
@@ -45,21 +44,27 @@ const shiftHours = (range: [string, string] | null): number => {
4544
return minutes / 60;
4645
};
4746

48-
export const OverviewView = () => {
47+
type OverviewViewProps = {
48+
weekStart?: WeekStart;
49+
};
50+
51+
export const OverviewView = ({ weekStart = "mon" }: OverviewViewProps) => {
4952
const config = useAtomValue(shiftConfigAtom);
5053
const shifts = useAtomValue(shiftsAtom);
5154
const setSelectedDate = useSetAtom(selectedDateAtom);
5255
const setViewMode = useSetAtom(viewModeAtom);
5356
const { dates, holidays, isReadOnly, staffs } = config;
5457

55-
const dateInfos = useMemo(() => buildDateInfos(dates), [dates]);
56-
const weekCount = Math.max(1, Math.ceil(dateInfos.length / 7));
58+
const weeks = useMemo<DateInfo[][]>(
59+
() => buildWeeklyGrid(dates, weekStart).map((week) => week.map(toDateInfo)),
60+
[dates, weekStart],
61+
);
5762

5863
const initialOpen = useMemo(() => {
5964
const o: Record<number, boolean> = {};
60-
for (let i = 0; i < weekCount; i++) o[i] = true;
65+
for (let i = 0; i < weeks.length; i++) o[i] = true;
6166
return o;
62-
}, [weekCount]);
67+
}, [weeks.length]);
6368
const [open, setOpen] = useState(initialOpen);
6469

6570
const lookup = useMemo(() => {
@@ -83,13 +88,12 @@ export const OverviewView = () => {
8388
return (
8489
<Box bg="gray.50" h="100%" overflow="auto" px={5} py={5}>
8590
<Stack gap={3}>
86-
{Array.from({ length: weekCount }).map((_, wi) => {
87-
const wkDates = dateInfos.filter((d) => d.weekIdx === wi);
91+
{weeks.map((wkDates, wi) => {
8892
if (wkDates.length === 0) return null;
8993
const isOpen = !!open[wi];
9094
return (
9195
<WeekCard
92-
key={wi}
96+
key={wkDates[0].iso}
9397
wkDates={wkDates}
9498
staffs={staffs}
9599
lookup={lookup}
@@ -117,59 +121,64 @@ type WeekCardProps = {
117121
isReadOnly: boolean;
118122
};
119123

120-
const WeekCard = ({ wkDates, staffs, lookup, holidays, isOpen, onToggle, onDateClick, isReadOnly }: WeekCardProps) => (
121-
<Box
122-
bg="white"
123-
borderRadius="xl"
124-
borderWidth="1px"
125-
borderColor={isOpen ? "teal.200" : "gray.200"}
126-
overflow="hidden"
127-
boxShadow="0 1px 2px rgba(0,0,0,0.03)"
128-
transition="all 120ms"
129-
>
130-
<Flex
131-
align="center"
132-
gap={3}
133-
px={5}
134-
py={3}
135-
bg={isOpen ? "teal.50" : "white"}
136-
cursor="pointer"
137-
onClick={onToggle}
138-
borderBottomWidth={isOpen ? "1px" : "0"}
139-
borderColor="teal.200"
124+
const WeekCard = ({ wkDates, staffs, lookup, holidays, isOpen, onToggle, onDateClick, isReadOnly }: WeekCardProps) => {
125+
const inRangeDates = wkDates.filter((d) => d.inRange);
126+
const rangeLabel =
127+
inRangeDates.length > 0 ? `${inRangeDates[0].label}${inRangeDates[inRangeDates.length - 1].label}` : "";
128+
return (
129+
<Box
130+
bg="white"
131+
borderRadius="xl"
132+
borderWidth="1px"
133+
borderColor={isOpen ? "teal.200" : "gray.200"}
134+
overflow="hidden"
135+
boxShadow="0 1px 2px rgba(0,0,0,0.03)"
136+
transition="all 120ms"
140137
>
141138
<Flex
142-
w="28px"
143-
h="28px"
144-
borderRadius="md"
145-
bg={isOpen ? "teal.600" : "gray.100"}
146-
color={isOpen ? "white" : "gray.500"}
147139
align="center"
148-
justify="center"
149-
flexShrink={0}
140+
gap={3}
141+
px={5}
142+
py={3}
143+
bg={isOpen ? "teal.50" : "white"}
144+
cursor="pointer"
145+
onClick={onToggle}
146+
borderBottomWidth={isOpen ? "1px" : "0"}
147+
borderColor="teal.200"
150148
>
151-
{isOpen ? <LuChevronDown size={16} /> : <LuChevronRight size={16} />}
149+
<Flex
150+
w="28px"
151+
h="28px"
152+
borderRadius="md"
153+
bg={isOpen ? "teal.600" : "gray.100"}
154+
color={isOpen ? "white" : "gray.500"}
155+
align="center"
156+
justify="center"
157+
flexShrink={0}
158+
>
159+
{isOpen ? <LuChevronDown size={16} /> : <LuChevronRight size={16} />}
160+
</Flex>
161+
<Box fontSize="15px" fontWeight={700} color="gray.800" style={{ fontVariantNumeric: "tabular-nums" }}>
162+
{rangeLabel}
163+
</Box>
164+
<Box fontSize="12px" color="gray.500">
165+
({inRangeDates.length}日)
166+
</Box>
152167
</Flex>
153-
<Box fontSize="15px" fontWeight={700} color="gray.800" style={{ fontVariantNumeric: "tabular-nums" }}>
154-
{wkDates[0].label}{wkDates[wkDates.length - 1].label}
155-
</Box>
156-
<Box fontSize="12px" color="gray.500">
157-
({wkDates.length}日)
158-
</Box>
159-
</Flex>
160168

161-
{isOpen && (
162-
<WeekTable
163-
staffs={staffs}
164-
wkDates={wkDates}
165-
lookup={lookup}
166-
holidays={holidays}
167-
onDateClick={onDateClick}
168-
isReadOnly={isReadOnly}
169-
/>
170-
)}
171-
</Box>
172-
);
169+
{isOpen && (
170+
<WeekTable
171+
staffs={staffs}
172+
wkDates={wkDates}
173+
lookup={lookup}
174+
holidays={holidays}
175+
onDateClick={onDateClick}
176+
isReadOnly={isReadOnly}
177+
/>
178+
)}
179+
</Box>
180+
);
181+
};
173182

174183
type WeekTableProps = {
175184
staffs: StaffType[];
@@ -204,26 +213,30 @@ const WeekTable = ({ staffs, wkDates, lookup, holidays, onDateClick, isReadOnly
204213
>
205214
スタッフ
206215
</Box>
207-
{wkDates.map((d) => (
208-
<Box
209-
as="th"
210-
key={d.iso}
211-
onClick={isReadOnly ? undefined : () => onDateClick(d.iso)}
212-
style={{
213-
padding: "10px 4px",
214-
fontWeight: 600,
215-
textAlign: "center",
216-
cursor: isReadOnly ? "default" : "pointer",
217-
}}
218-
>
219-
<Box fontSize="12px" color="gray.700" fontWeight={600} style={{ fontVariantNumeric: "tabular-nums" }}>
220-
{d.label}
221-
</Box>
222-
<Box fontSize="10px" fontWeight={600} mt="2px" style={{ color: dayColor(d.iso, holidays) }}>
223-
{d.wk}
216+
{wkDates.map((d) => {
217+
const isClickable = !isReadOnly && d.inRange;
218+
return (
219+
<Box
220+
as="th"
221+
key={d.iso}
222+
onClick={isClickable ? () => onDateClick(d.iso) : undefined}
223+
style={{
224+
padding: "10px 4px",
225+
fontWeight: 600,
226+
textAlign: "center",
227+
cursor: isClickable ? "pointer" : "default",
228+
opacity: d.inRange ? 1 : 0.35,
229+
}}
230+
>
231+
<Box fontSize="12px" color="gray.700" fontWeight={600} style={{ fontVariantNumeric: "tabular-nums" }}>
232+
{d.label}
233+
</Box>
234+
<Box fontSize="10px" fontWeight={600} mt="2px" style={{ color: dayColor(d.iso, holidays) }}>
235+
{d.wk}
236+
</Box>
224237
</Box>
225-
</Box>
226-
))}
238+
);
239+
})}
227240
<Box
228241
as="th"
229242
style={{
@@ -257,7 +270,7 @@ const WeekTable = ({ staffs, wkDates, lookup, holidays, onDateClick, isReadOnly
257270
</Flex>
258271
</Box>
259272
{wkDates.map((d) => {
260-
const asn = lookup.get(`${s.id}-${d.iso}`) ?? null;
273+
const asn = d.inRange ? (lookup.get(`${s.id}-${d.iso}`) ?? null) : null;
261274
if (asn) total += shiftHours(asn);
262275
return (
263276
<Box as="td" key={d.iso} style={{ padding: "8px 4px", textAlign: "center", verticalAlign: "middle" }}>
@@ -272,7 +285,7 @@ const WeekTable = ({ staffs, wkDates, lookup, holidays, onDateClick, isReadOnly
272285
{asn[0]}{asn[1]}
273286
</Box>
274287
) : (
275-
<Box as="span" color="gray.300" fontSize="12px">
288+
<Box as="span" color={d.inRange ? "gray.300" : "gray.200"} fontSize="12px">
276289
277290
</Box>
278291
)}

0 commit comments

Comments
 (0)