From c9726e743c779079cc6e1e1066c99a4ac2b28ac1 Mon Sep 17 00:00:00 2001 From: y-natani Date: Fri, 13 Feb 2026 22:52:34 +0900 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20=E3=82=B9=E3=82=BF=E3=83=83?= =?UTF-8?q?=E3=83=95=E4=B8=80=E8=A6=A7=E3=81=AE=E3=83=9E=E3=83=8D=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=A3=E3=83=BC=E3=83=90=E3=83=83=E3=82=B8=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E4=B8=AD=E5=A4=AE=E5=AF=84=E3=81=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../features/Staff/StaffList/index.tsx | 63 +++++++------------ 1 file changed, 23 insertions(+), 40 deletions(-) diff --git a/src/components/features/Staff/StaffList/index.tsx b/src/components/features/Staff/StaffList/index.tsx index f4ea6fea..d4ae2b71 100644 --- a/src/components/features/Staff/StaffList/index.tsx +++ b/src/components/features/Staff/StaffList/index.tsx @@ -1,18 +1,4 @@ -import { - Badge, - Box, - Button, - Card, - Container, - Flex, - Heading, - HStack, - Icon, - Input, - InputGroup, - Spacer, - Text, -} from "@chakra-ui/react"; +import { Badge, Box, Button, Card, Container, Flex, Heading, Icon, Input, InputGroup, Text } from "@chakra-ui/react"; import { Link } from "@tanstack/react-router"; import { useAtomValue } from "jotai"; import { useState } from "react"; @@ -218,29 +204,9 @@ export const StaffList = ({ shop, staffs, staffSkillsMap }: StaffListProps) => { {/* スタッフ情報 */} - - - {staff.displayName} - - - {/* ロールバッジ(マネージャーのみ表示) */} - {staff.isManager && ( - - マネージャー - - )} - {/* ステータスバッジ */} - {staff.status === "pending" && ( - - 招待中 - - )} - {staff.status === "resigned" && ( - - 退職済み - - )} - + + {staff.displayName} + {/* スキル表示(一人前以上のみ) */} {(() => { const staffSkills = staffSkillsMap[staff._id] || []; @@ -264,8 +230,25 @@ export const StaffList = ({ shop, staffs, staffSkillsMap }: StaffListProps) => { - {/* 矢印アイコン */} - + {/* ロールバッジ・ステータスバッジ + 矢印アイコン */} + + {staff.isManager && ( + + マネージャー + + )} + {staff.status === "pending" && ( + + 招待中 + + )} + {staff.status === "resigned" && ( + + 退職済み + + )} + + From de2c32e6c3f4c211aa57ac57bc79a5dbc8ec41f4 Mon Sep 17 00:00:00 2001 From: y-natani Date: Fri, 13 Feb 2026 23:02:04 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E3=82=B7=E3=83=95=E3=83=88?= =?UTF-8?q?=E5=8B=9F=E9=9B=86CREATE=20=E3=83=90=E3=83=83=E3=82=AF=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=89=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recruitmentsテーブル追加・create mutation作成・フロントエンド接続 Co-Authored-By: Claude Opus 4.6 --- convex/_generated/api.d.ts | 2 + convex/constants.ts | 4 + convex/recruitment/mutations.ts | 83 +++++++++++++++++++ convex/schema.ts | 18 ++++ .../features/Shift/RecruitmentNew/index.tsx | 42 ++++++++-- src/constants/validations.ts | 2 + 6 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 convex/recruitment/mutations.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 085c1deb..ccd4ea17 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -14,6 +14,7 @@ import type * as invite_mutations from "../invite/mutations.js"; import type * as invite_queries from "../invite/queries.js"; import type * as position_mutations from "../position/mutations.js"; import type * as position_queries from "../position/queries.js"; +import type * as recruitment_mutations from "../recruitment/mutations.js"; import type * as requiredStaffing_mutations from "../requiredStaffing/mutations.js"; import type * as requiredStaffing_queries from "../requiredStaffing/queries.js"; import type * as shop_mutations from "../shop/mutations.js"; @@ -37,6 +38,7 @@ declare const fullApi: ApiFromModules<{ "invite/queries": typeof invite_queries; "position/mutations": typeof position_mutations; "position/queries": typeof position_queries; + "recruitment/mutations": typeof recruitment_mutations; "requiredStaffing/mutations": typeof requiredStaffing_mutations; "requiredStaffing/queries": typeof requiredStaffing_queries; "shop/mutations": typeof shop_mutations; diff --git a/convex/constants.ts b/convex/constants.ts index 26fcde9a..87f38527 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -33,3 +33,7 @@ export type StaffRoleType = (typeof STAFF_ROLES)[number]; // 招待関連 export const INVITE_EXPIRY_DAYS = 14; export const INVITE_EXPIRY_MS = INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000; + +// 募集ステータス定義 +export const RECRUITMENT_STATUS = ["open", "closed", "confirmed"] as const; +export type RecruitmentStatusType = (typeof RECRUITMENT_STATUS)[number]; diff --git a/convex/recruitment/mutations.ts b/convex/recruitment/mutations.ts new file mode 100644 index 00000000..fb1a5633 --- /dev/null +++ b/convex/recruitment/mutations.ts @@ -0,0 +1,83 @@ +/** + * 募集ドメイン - ミューテーション(書き込み操作) + * + * 責務: + * - シフト募集の作成 + */ +import { ConvexError, v } from "convex/values"; +import { mutation } from "../_generated/server"; +import { RECRUITMENT_STATUS } from "../constants"; +import { requireShop, requireShopOwnerOrManager } from "../helpers"; + +// 日付形式バリデーション(YYYY-MM-DD形式) +const isValidDateFormat = (date: string) => { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(date)) return false; + const parsed = new Date(date); + return !Number.isNaN(parsed.getTime()); +}; + +// シフト募集作成 +export const create = mutation({ + args: { + shopId: v.id("shops"), + authId: v.string(), + startDate: v.string(), + endDate: v.string(), + deadline: v.string(), + }, + handler: async (ctx, args) => { + // 店舗存在チェック + await requireShop(ctx, args.shopId); + + // 権限チェック(オーナーまたはマネージャーのみ) + await requireShopOwnerOrManager(ctx, args.shopId, args.authId); + + // 日付形式バリデーション + if (!isValidDateFormat(args.startDate)) { + throw new ConvexError({ message: "開始日の形式が不正です", code: "INVALID_START_DATE" }); + } + if (!isValidDateFormat(args.endDate)) { + throw new ConvexError({ message: "終了日の形式が不正です", code: "INVALID_END_DATE" }); + } + if (!isValidDateFormat(args.deadline)) { + throw new ConvexError({ message: "締切日の形式が不正です", code: "INVALID_DEADLINE" }); + } + + // ビジネスルールバリデーション(フロントエンドのzodスキーマと一致) + if (args.startDate > args.endDate) { + throw new ConvexError({ message: "終了日は開始日以降を指定してください", code: "END_BEFORE_START" }); + } + if (args.deadline >= args.startDate) { + throw new ConvexError({ + message: "締切日は開始日より前を指定してください", + code: "DEADLINE_NOT_BEFORE_START", + }); + } + + // アクティブスタッフ数を取得(非削除かつ非退職) + const activeStaffs = await ctx.db + .query("staffs") + .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) + .filter((q) => q.and(q.neq(q.field("isDeleted"), true), q.neq(q.field("status"), "resigned"))) + .collect(); + + const totalStaffCount = activeStaffs.length; + + // 募集作成 + const recruitmentId = await ctx.db.insert("recruitments", { + shopId: args.shopId, + startDate: args.startDate, + endDate: args.endDate, + deadline: args.deadline, + status: RECRUITMENT_STATUS[0], // "open" + appliedCount: 0, + totalStaffCount, + createdBy: args.authId, + createdAt: Date.now(), + isDeleted: false, + }); + + return { success: true, data: { recruitmentId, totalStaffCount } }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 04526c9d..1929098b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -120,6 +120,23 @@ const requiredStaffing = defineTable({ updatedAt: v.number(), }).index("by_shop", ["shopId"]); +// シフト募集テーブル +const recruitments = defineTable({ + shopId: v.id("shops"), + startDate: v.string(), // YYYY-MM-DD + endDate: v.string(), // YYYY-MM-DD + deadline: v.string(), // YYYY-MM-DD + status: v.string(), // "open" | "closed" | "confirmed" + appliedCount: v.number(), // 申請済みスタッフ数(初期値: 0) + totalStaffCount: v.number(), // 作成時のアクティブスタッフ数 + confirmedAt: v.optional(v.number()), + createdBy: v.string(), // authId + createdAt: v.number(), + isDeleted: v.boolean(), +}) + .index("by_shop", ["shopId"]) + .index("by_shop_and_status", ["shopId", "status"]); + const schema = defineSchema({ users, shops, @@ -127,6 +144,7 @@ const schema = defineSchema({ shopPositions, staffSkills, requiredStaffing, + recruitments, }); // テーブル名を型安全にエクスポート(testing.tsで使用) diff --git a/src/components/features/Shift/RecruitmentNew/index.tsx b/src/components/features/Shift/RecruitmentNew/index.tsx index 4391115f..8032c373 100644 --- a/src/components/features/Shift/RecruitmentNew/index.tsx +++ b/src/components/features/Shift/RecruitmentNew/index.tsx @@ -1,10 +1,15 @@ import { Box, Container, Flex, Heading, Icon } from "@chakra-ui/react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate } from "@tanstack/react-router"; +import { useMutation } from "convex/react"; +import { useAtomValue } from "jotai"; import { useForm } from "react-hook-form"; import { LuCalendarPlus } from "react-icons/lu"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; import { Title } from "@/src/components/ui/Title"; import { toaster } from "@/src/components/ui/toaster"; +import { userAtom } from "@/src/stores/user"; import { RecruitmentForm } from "../RecruitmentForm"; import { type RecruitmentFormSchemaType, recruitmentFormSchema } from "../RecruitmentForm/schema"; @@ -14,6 +19,8 @@ type RecruitmentNewProps = { export const RecruitmentNew = ({ shopId }: RecruitmentNewProps) => { const navigate = useNavigate(); + const user = useAtomValue(userAtom); + const createRecruitment = useMutation(api.recruitment.mutations.create); const { register, @@ -24,15 +31,36 @@ export const RecruitmentNew = ({ shopId }: RecruitmentNewProps) => { }); const onSubmit = handleSubmit(async (data) => { - // TODO: useMutation呼び出し - console.log("募集作成:", { shopId, ...data }); + if (!user.authId) { + toaster.create({ + description: "ログインが必要です", + type: "error", + }); + return; + } - toaster.create({ - description: "シフト募集を作成しました", - type: "success", - }); + try { + const result = await createRecruitment({ + shopId: shopId as Id<"shops">, + authId: user.authId, + startDate: data.startDate, + endDate: data.endDate, + deadline: data.deadline, + }); - navigate({ to: "/shops/$shopId/shifts", params: { shopId } }); + if (result.success) { + toaster.create({ + description: "シフト募集を作成しました", + type: "success", + }); + navigate({ to: "/shops/$shopId/shifts", params: { shopId } }); + } + } catch { + toaster.create({ + description: "シフト募集の作成に失敗しました", + type: "error", + }); + } }); return ( diff --git a/src/constants/validations.ts b/src/constants/validations.ts index 0a487fdf..c4533305 100644 --- a/src/constants/validations.ts +++ b/src/constants/validations.ts @@ -4,6 +4,8 @@ export { POSITION_MAX_COUNT, POSITION_NAME_MAX_LENGTH, type PositionType, + RECRUITMENT_STATUS, + type RecruitmentStatusType, SHOP_MAX_LENGTH, SHOP_MIN_LENGTH, SHOP_SUBMIT_FREQUENCY, From ed651791d6f72e3604013744b34968fb22c49dfa Mon Sep 17 00:00:00 2001 From: y-natani Date: Fri, 13 Feb 2026 23:04:38 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=E3=82=B9=E3=82=BF=E3=83=83?= =?UTF-8?q?=E3=83=95=E8=A9=B3=E7=B4=B0=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=AE?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E8=A1=A8=E7=A4=BA=E3=82=92=E8=A7=A3=E6=B6=88?= =?UTF-8?q?=E3=81=97=E3=83=AC=E3=82=A4=E3=82=A2=E3=82=A6=E3=83=88=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title内の重複アバター・名前を削除し、編集ボタンをStaffDetailContentの ヘッダー行(アバター+名前の右端)に移動。スキル表示をチップスタイルに変更。 Co-Authored-By: Claude Opus 4.6 --- .../features/Staff/StaffDetail/index.tsx | 95 ++++------ .../StaffDetailContent/index.stories.tsx | 7 + .../Staff/StaffDetailContent/index.tsx | 168 +++++++++--------- 3 files changed, 118 insertions(+), 152 deletions(-) diff --git a/src/components/features/Staff/StaffDetail/index.tsx b/src/components/features/Staff/StaffDetail/index.tsx index 211bd6a4..a2b78878 100644 --- a/src/components/features/Staff/StaffDetail/index.tsx +++ b/src/components/features/Staff/StaffDetail/index.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Card, Container, Flex, Grid, Icon, Skeleton, SkeletonText, Text, VStack } from "@chakra-ui/react"; +import { Box, Button, Card, Container, Flex, Grid, Icon, Skeleton, SkeletonText, VStack } from "@chakra-ui/react"; import { Link, useNavigate } from "@tanstack/react-router"; import { LuMail, LuPencil, LuUser } from "react-icons/lu"; import type { Id } from "@/convex/_generated/dataModel"; @@ -51,74 +51,41 @@ type StaffDetailProps = { export const StaffDetail = ({ staff, shop, positions, staffSkills }: StaffDetailProps) => { const navigate = useNavigate(); - // アバターのイニシャル生成 - const getInitials = (name: string) => { - return name - .split("") - .slice(0, 2) - .map((char) => char.toUpperCase()) - .join(""); - }; - return ( - {/* ヘッダー */} - - <Button - onClick={() => { - navigate({ - to: "/shops/$shopId/staffs/$staffId/edit", - params: { shopId: shop._id, staffId: staff._id }, - }); - }} - colorPalette="teal" - gap={2} - > - <Icon as={LuPencil} boxSize={4} /> - <Text display={{ base: "none", md: "inline" }}>編集</Text> - </Button> - {staff.status === "pending" && ( - <Button colorPalette="orange" gap={2}> - <Icon as={LuMail} boxSize={4} /> - <Text display={{ base: "none", md: "inline" }}>招待メールを再送</Text> - </Button> - )} - </Flex> - } - > - <Flex align="center" gap={4}> - {/* アバター */} - <Flex - w={{ base: 16, md: 20 }} - h={{ base: 16, md: 20 }} - borderRadius="full" - bgGradient="to-br" - gradientFrom="teal.400" - gradientTo="teal.600" - align="center" - justify="center" - color="white" - flexShrink={0} - > - <Text fontSize={{ base: "2xl", md: "3xl" }} fontWeight="bold"> - {getInitials(staff.displayName)} - </Text> - </Flex> - - <Box> - <Text as="h2" fontSize="xl" fontWeight="bold" color="gray.900" mb={1}> - {staff.displayName} - </Text> - </Box> - </Flex> - + {/* 戻るリンク */} + {null} {/* コンテンツ部分(共通コンポーネント使用) */} - + + + {staff.status === "pending" && ( + + )} + + } + /> ); diff --git a/src/components/features/Staff/StaffDetailContent/index.stories.tsx b/src/components/features/Staff/StaffDetailContent/index.stories.tsx index fa1c6454..701da845 100644 --- a/src/components/features/Staff/StaffDetailContent/index.stories.tsx +++ b/src/components/features/Staff/StaffDetailContent/index.stories.tsx @@ -120,3 +120,10 @@ export const NoHourlyWage: Story = { }, }, }; + +export const NoPositions: Story = { + args: { + positions: [], + staffSkills: [], + }, +}; diff --git a/src/components/features/Staff/StaffDetailContent/index.tsx b/src/components/features/Staff/StaffDetailContent/index.tsx index 21115af8..f164b8ab 100644 --- a/src/components/features/Staff/StaffDetailContent/index.tsx +++ b/src/components/features/Staff/StaffDetailContent/index.tsx @@ -1,4 +1,4 @@ -import { Badge, Box, Card, Flex, Heading, HStack, Icon, Progress, Text, VStack } from "@chakra-ui/react"; +import { Badge, Box, Card, Flex, Heading, HStack, Icon, Text, VStack } from "@chakra-ui/react"; import { LuBriefcase, LuCalendar, LuClock, LuMail, LuStickyNote, LuWallet } from "react-icons/lu"; import type { Id } from "@/convex/_generated/dataModel"; @@ -35,53 +35,38 @@ type StaffDetailContentProps = { staff: StaffType; positions: PositionType[]; staffSkills: StaffSkillType[]; + action?: React.ReactNode; }; -// スキルレベルに応じた進捗値を取得 -const getProgressValue = (level: string): number => { +// スキルレベルに応じたチップスタイルを取得 +const getSkillChipStyle = (level: string) => { switch (level) { - case "未経験": - return 0; - case "研修中": - return 33; - case "一人前": - return 66; case "ベテラン": - return 100; - default: - return 0; - } -}; - -// スキルレベルに応じたバー色を取得 -const getBarColor = (level: string): string => { - switch (level) { - case "未経験": - return "gray.300"; - case "研修中": - return "teal.300"; + return { colorPalette: "purple", variant: "solid" } as const; case "一人前": - return "teal.500"; - case "ベテラン": - return "teal.600"; + return { colorPalette: "teal", variant: "subtle" } as const; + case "研修中": + return { colorPalette: "yellow", variant: "subtle" } as const; default: - return "gray.300"; + return { colorPalette: "gray", variant: "subtle" } as const; } }; -// スキルレベルに応じたBadge色を取得 -const getBadgeColor = (level: string): string => { - return level === "未経験" ? "gray" : "teal"; -}; - -// スキルプログレスバーコンポーネント -type SkillProgressBarProps = { +// スキルチップ一覧コンポーネント +type SkillChipsProps = { positions: PositionType[]; staffSkills: StaffSkillType[]; }; -const SkillProgressBar = ({ positions, staffSkills }: SkillProgressBarProps) => { - // ポジションごとにスキルをマッピング +const SkillChips = ({ positions, staffSkills }: SkillChipsProps) => { + if (positions.length === 0) { + return ( + + スキル未設定 + + ); + } + const skillsToDisplay = positions.map((position) => { const skill = staffSkills.find((s) => s.positionId === position._id); return { @@ -91,29 +76,32 @@ const SkillProgressBar = ({ positions, staffSkills }: SkillProgressBarProps) => }); return ( - - {skillsToDisplay.map((skill) => ( - - - + + {skillsToDisplay.map((skill) => { + const chipStyle = getSkillChipStyle(skill.level); + return ( + + {skill.positionName} - + {skill.level} - - - - - - - - - ))} - + + + ); + })} + ); }; -export const StaffDetailContent = ({ staff, positions, staffSkills }: StaffDetailContentProps) => { +export const StaffDetailContent = ({ staff, positions, staffSkills, action }: StaffDetailContentProps) => { // アバターのイニシャル生成 const getInitials = (name: string) => { return name @@ -147,43 +135,47 @@ export const StaffDetailContent = ({ staff, positions, staffSkills }: StaffDetai return ( - {/* ヘッダー(アバター + 名前 + バッジ) */} - - {/* アバター */} - - - {getInitials(staff.displayName)} - + {/* ヘッダー(アバター + 名前 + バッジ + アクション) */} + + + {/* アバター */} + + + {getInitials(staff.displayName)} + + + + + + + {staff.displayName} + + {statusBadge()} + {staff.isManager && ( + + マネージャー + + )} + + + + 登録日: {new Date(staff.createdAt).toLocaleDateString("ja-JP")} + + - - - - {staff.displayName} - - {statusBadge()} - {staff.isManager && ( - - マネージャー - - )} - - - - 登録日: {new Date(staff.createdAt).toLocaleDateString("ja-JP")} - - + {action} {/* 退職情報(退職済みの場合のみ表示) */} @@ -256,7 +248,7 @@ export const StaffDetailContent = ({ staff, positions, staffSkills }: StaffDetai - + From eaaadc327a02c3952ff69e0258a06ad2f2b6b473 Mon Sep 17 00:00:00 2001 From: y-natani Date: Fri, 13 Feb 2026 23:13:01 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=E3=82=B7=E3=83=95=E3=83=88?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=94=BB=E9=9D=A2=E3=82=92DB=E6=8E=A5?= =?UTF-8?q?=E7=B6=9A=EF=BC=88=E3=83=A2=E3=83=83=E3=82=AF=E3=83=87=E3=83=BC?= =?UTF-8?q?=E3=82=BF=E5=89=8A=E9=99=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recruitment queries作成・ShiftsPageでuseQueryに切り替え Co-Authored-By: Claude Opus 4.6 --- convex/_generated/api.d.ts | 2 + convex/recruitment/queries.ts | 32 ++++++++ .../pages/Shops/ShiftsPage/index.tsx | 80 ++++++------------- 3 files changed, 57 insertions(+), 57 deletions(-) create mode 100644 convex/recruitment/queries.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index ccd4ea17..c8ddcb26 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -15,6 +15,7 @@ import type * as invite_queries from "../invite/queries.js"; import type * as position_mutations from "../position/mutations.js"; import type * as position_queries from "../position/queries.js"; import type * as recruitment_mutations from "../recruitment/mutations.js"; +import type * as recruitment_queries from "../recruitment/queries.js"; import type * as requiredStaffing_mutations from "../requiredStaffing/mutations.js"; import type * as requiredStaffing_queries from "../requiredStaffing/queries.js"; import type * as shop_mutations from "../shop/mutations.js"; @@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{ "position/mutations": typeof position_mutations; "position/queries": typeof position_queries; "recruitment/mutations": typeof recruitment_mutations; + "recruitment/queries": typeof recruitment_queries; "requiredStaffing/mutations": typeof requiredStaffing_mutations; "requiredStaffing/queries": typeof requiredStaffing_queries; "shop/mutations": typeof shop_mutations; diff --git a/convex/recruitment/queries.ts b/convex/recruitment/queries.ts new file mode 100644 index 00000000..1e99f39c --- /dev/null +++ b/convex/recruitment/queries.ts @@ -0,0 +1,32 @@ +/** + * 募集ドメイン - クエリ(読み取り操作) + * + * 責務: + * - 店舗のシフト募集一覧取得 + */ +import { v } from "convex/values"; +import { query } from "../_generated/server"; +import type { RecruitmentStatusType } from "../constants"; + +// 店舗の募集一覧取得 +export const listByShop = query({ + args: { shopId: v.id("shops") }, + handler: async (ctx, args) => { + const recruitments = await ctx.db + .query("recruitments") + .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) + .filter((q) => q.neq(q.field("isDeleted"), true)) + .collect(); + + return recruitments.map((r) => ({ + _id: r._id, + startDate: r.startDate, + endDate: r.endDate, + deadline: r.deadline, + status: r.status as RecruitmentStatusType, + appliedCount: r.appliedCount, + totalStaffCount: r.totalStaffCount, + confirmedAt: r.confirmedAt, + })); + }, +}); diff --git a/src/components/pages/Shops/ShiftsPage/index.tsx b/src/components/pages/Shops/ShiftsPage/index.tsx index 095dc557..1eab7db9 100644 --- a/src/components/pages/Shops/ShiftsPage/index.tsx +++ b/src/components/pages/Shops/ShiftsPage/index.tsx @@ -1,68 +1,34 @@ -import { RecruitmentList } from "@/src/components/features/Shift/RecruitmentList"; +import { useQuery } from "convex/react"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { + RecruitmentList, + RecruitmentListLoading, + RecruitmentListNotFound, +} from "@/src/components/features/Shift/RecruitmentList"; +import { LazyShow } from "@/src/components/ui/LazyShow"; type Props = { shopId: string; }; -// モックデータ(将来的にはuseQueryで取得) -const mockShop = { - _id: "shop_1" as const, - shopName: "サンプル店舗", - openTime: "09:00", - closeTime: "22:00", - timeUnit: 30 as const, - submitFrequency: "1w" as const, - ownerId: "owner_1", - createdAt: Date.now(), -}; - -const mockRecruitments = [ - { - _id: "recruitment_1", - startDate: "2025-12-01", - endDate: "2025-12-07", - deadline: "2025-11-25", - status: "open" as const, - appliedCount: 5, - totalStaffCount: 8, - }, - { - _id: "recruitment_2", - startDate: "2025-12-08", - endDate: "2025-12-14", - deadline: "2025-12-01", - status: "closed" as const, - appliedCount: 8, - totalStaffCount: 8, - }, - { - _id: "recruitment_3", - startDate: "2025-12-15", - endDate: "2025-12-21", - deadline: "2025-12-08", - status: "confirmed" as const, - appliedCount: 8, - totalStaffCount: 8, - confirmedAt: 1733788800000, - }, -]; - export const ShiftsPage = ({ shopId }: Props) => { - // 将来的にはuseQueryでデータ取得 - // const shop = useQuery(api.shop.queries.getById, { shopId: shopId as Id<"shops"> }); - // const recruitments = useQuery(api.shiftRecruitment.queries.listByShop, { shopId: shopId as Id<"shops"> }); + const shop = useQuery(api.shop.queries.getById, { shopId: shopId as Id<"shops"> }); + const recruitments = useQuery(api.recruitment.queries.listByShop, { shopId: shopId as Id<"shops"> }); - // モック実装のため、直接データを渡す - const shop = { ...mockShop, _id: shopId }; - const recruitments = mockRecruitments; + // ローディング + if (shop === undefined || recruitments === undefined) { + return ( + + + + ); + } - // 将来的なローディング・エラー処理 - // if (shop === undefined || recruitments === undefined) { - // return ; - // } - // if (shop === null) { - // return ; - // } + // 店舗が見つからない + if (shop === null) { + return ; + } return ; }; From 0734ad86fc4158129f1a05b25de094977fc4391e Mon Sep 17 00:00:00 2001 From: y-natani Date: Fri, 13 Feb 2026 23:27:12 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=E3=82=B7=E3=83=95=E3=83=88?= =?UTF-8?q?=E5=8B=9F=E9=9B=86=E4=B8=80=E8=A6=A7=E3=82=92=E9=96=8B=E5=A7=8B?= =?UTF-8?q?=E6=97=A5=E3=81=AE=E9=99=8D=E9=A0=86=E3=82=BD=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4=E3=83=BB=E5=8B=9F=E9=9B=86=E8=A9=B3?= =?UTF-8?q?=E7=B4=B0UI=E3=83=AA=E3=83=95=E3=82=A1=E3=82=AF=E3=82=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recruitmentsテーブルにby_shop_and_startDateインデックス追加 - listByShopクエリでバックエンド側降順ソートを実装 - フロントエンドの冗長なソート処理を削除 - RecruitmentDetailのレイアウトをTitle/Card/Animation構成に統一 Co-Authored-By: Claude Opus 4.6 --- convex/recruitment/queries.ts | 3 +- convex/schema.ts | 3 +- .../Shift/RecruitmentDetail/index.tsx | 142 ++++++++++++------ .../features/Shift/RecruitmentList/index.tsx | 13 +- 4 files changed, 105 insertions(+), 56 deletions(-) diff --git a/convex/recruitment/queries.ts b/convex/recruitment/queries.ts index 1e99f39c..2ce8f10a 100644 --- a/convex/recruitment/queries.ts +++ b/convex/recruitment/queries.ts @@ -14,7 +14,8 @@ export const listByShop = query({ handler: async (ctx, args) => { const recruitments = await ctx.db .query("recruitments") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) + .withIndex("by_shop_and_startDate", (q) => q.eq("shopId", args.shopId)) + .order("desc") .filter((q) => q.neq(q.field("isDeleted"), true)) .collect(); diff --git a/convex/schema.ts b/convex/schema.ts index 1929098b..a1b3cc8f 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -135,7 +135,8 @@ const recruitments = defineTable({ isDeleted: v.boolean(), }) .index("by_shop", ["shopId"]) - .index("by_shop_and_status", ["shopId", "status"]); + .index("by_shop_and_status", ["shopId", "status"]) + .index("by_shop_and_startDate", ["shopId", "startDate"]); const schema = defineSchema({ users, diff --git a/src/components/features/Shift/RecruitmentDetail/index.tsx b/src/components/features/Shift/RecruitmentDetail/index.tsx index bb8069b6..b5e16363 100644 --- a/src/components/features/Shift/RecruitmentDetail/index.tsx +++ b/src/components/features/Shift/RecruitmentDetail/index.tsx @@ -1,10 +1,22 @@ -import { Badge, Box, Button, Flex, HStack } from "@chakra-ui/react"; +import { Badge, Box, Button, Card, Container, Flex, Heading, HStack, Icon, Text, VStack } from "@chakra-ui/react"; import { useNavigate } from "@tanstack/react-router"; -import { LuCheck, LuLock } from "react-icons/lu"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import { LuCalendar, LuCheck, LuLock } from "react-icons/lu"; import { ShiftForm } from "@/src/components/features/Shift/ShiftForm"; import type { PositionType, ShiftData, StaffType, TimeRange } from "@/src/components/features/Shift/ShiftForm/types"; +import { Animation } from "@/src/components/templates/Animation"; +import { Title } from "@/src/components/ui/Title"; import { toaster } from "@/src/components/ui/toaster"; +dayjs.locale("ja"); + +const formatDateRange = (startDate: string, endDate: string) => { + const start = dayjs(startDate); + const end = dayjs(endDate); + return `${start.format("M/D(ddd)")} 〜 ${end.format("M/D(ddd)")}`; +}; + type RecruitmentDetailProps = { shopId: string; recruitmentId: string; @@ -47,51 +59,91 @@ export const RecruitmentDetail = ({ }); }; + const dateRangeLabel = dates.length > 0 ? formatDateRange(dates[0], dates[dates.length - 1]) : ""; + return ( - - {/* 提出状況 + アクションボタン */} - + {/* ヘッダー */} + + <Button variant="outline" colorPalette="orange" size="sm" onClick={handleCloseRecruitment}> + <LuLock /> + 募集を締め切る + </Button> + <Button colorPalette="teal" size="sm" onClick={handleGoToConfirm}> + <LuCheck /> + シフト確定へ + </Button> + </HStack> + } > - <HStack gap={3}> - <Badge colorPalette="teal" size="lg"> - 提出済み {submittedCount}名 - </Badge> - <Badge colorPalette="gray" size="lg"> - 未提出 {unsubmittedCount}名 - </Badge> - </HStack> - <HStack gap={2}> - <Button variant="outline" colorPalette="orange" size="sm" onClick={handleCloseRecruitment}> - <LuLock /> - 募集を締め切る - </Button> - <Button colorPalette="teal" size="sm" onClick={handleGoToConfirm}> - <LuCheck /> - シフト確定へ - </Button> - </HStack> - </Flex> + <Flex align="center" gap={3}> + <Flex p={{ base: 2, md: 3 }} bg="teal.50" borderRadius="lg"> + <Icon as={LuCalendar} boxSize={6} color="teal.600" /> + </Flex> + <Box> + <Heading as="h2" size="xl" color="gray.900"> + シフト募集詳細 + </Heading> + {dateRangeLabel && ( + <Text fontSize="sm" color="gray.500"> + {dateRangeLabel} + </Text> + )} + </Box> + </Flex> + + + + {/* 提出状況サマリー */} + + + + + 提出済み {submittedCount}名 + + + 未提出 {unsubmittedCount}名 + + + + + + {/* モバイル用アクションボタン */} + + + + + + - {/* ShiftForm: 一覧モード固定、readOnly、シフト希望順 */} - - + {/* ShiftForm: 一覧モード固定、readOnly、シフト希望順 */} + + + + + + + ); }; diff --git a/src/components/features/Shift/RecruitmentList/index.tsx b/src/components/features/Shift/RecruitmentList/index.tsx index bcaf597e..8c5457e2 100644 --- a/src/components/features/Shift/RecruitmentList/index.tsx +++ b/src/components/features/Shift/RecruitmentList/index.tsx @@ -53,17 +53,12 @@ export const RecruitmentList = ({ shop, recruitments }: RecruitmentListProps) => const navigate = useNavigate(); const [statusFilter, setStatusFilter] = useState("all"); - // ステータスフィルター + // ステータスフィルター(バックエンドで開始日の降順ソート済み) const filteredRecruitments = recruitments.filter((recruitment) => { if (statusFilter === "all") return true; return recruitment.status === statusFilter; }); - // ソート: 開始日の降順(新しい順) - const sortedRecruitments = filteredRecruitments.toSorted((a, b) => { - return dayjs(b.startDate).valueOf() - dayjs(a.startDate).valueOf(); - }); - return ( {/* ヘッダー */} @@ -137,13 +132,13 @@ export const RecruitmentList = ({ shop, recruitments }: RecruitmentListProps) => {/* 募集一覧 */} - {sortedRecruitments.length > 0 ? ( + {filteredRecruitments.length > 0 ? ( <> - {sortedRecruitments.length}件の募集 + {filteredRecruitments.length}件の募集 - {sortedRecruitments.map((recruitment) => { + {filteredRecruitments.map((recruitment) => { const config = STATUS_CONFIG[recruitment.status]; return ( Date: Fri, 13 Feb 2026 23:51:18 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E3=82=B7=E3=83=95=E3=83=88?= =?UTF-8?q?=E5=8B=9F=E9=9B=86=E8=A9=B3=E7=B4=B0=E3=83=AC=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=82=A6=E3=83=88=E6=94=B9=E5=96=84=E3=83=BB=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=83=96=E3=83=AB=E8=83=8C=E6=99=AF=E8=89=B2=E8=A2=AB=E3=82=8A?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitmentDetailにContainer/Title/Animation標準パターンを適用 - ボタンを「締切・編集へ」1つに統合(LuPencilLine + teal) - テーブル構造要素のbg="gray.50"→"white"に変更(ページ背景との境界明確化) - ShiftForm内部のpx={4}を削除しpadding責務を呼び出し側に移動 Co-Authored-By: Claude Opus 4.6 --- .../Shift/RecruitmentDetail/index.tsx | 73 +++++++------------ .../features/Shift/ShiftForm/index.tsx | 4 +- .../pc/DailyView/ShiftGrid/index.tsx | 4 +- .../ShiftForm/pc/DailyView/SummaryRow.tsx | 4 +- .../ShiftForm/pc/DailyView/TimeHeader.tsx | 2 +- .../pc/OverviewView/MonthSummaryCell.tsx | 2 +- .../pc/OverviewView/OverviewHeader.tsx | 4 +- .../pc/OverviewView/SummaryFooterRow.tsx | 8 +- .../Shift/ShiftForm/pc/OverviewView/index.tsx | 2 +- .../pages/Shops/ShiftConfirmPage/index.tsx | 23 +++--- 10 files changed, 54 insertions(+), 72 deletions(-) diff --git a/src/components/features/Shift/RecruitmentDetail/index.tsx b/src/components/features/Shift/RecruitmentDetail/index.tsx index b5e16363..33a8ac52 100644 --- a/src/components/features/Shift/RecruitmentDetail/index.tsx +++ b/src/components/features/Shift/RecruitmentDetail/index.tsx @@ -1,8 +1,8 @@ -import { Badge, Box, Button, Card, Container, Flex, Heading, HStack, Icon, Text, VStack } from "@chakra-ui/react"; +import { Badge, Box, Button, Card, Container, Flex, Heading, HStack, Icon, Text } from "@chakra-ui/react"; import { useNavigate } from "@tanstack/react-router"; import dayjs from "dayjs"; import "dayjs/locale/ja"; -import { LuCalendar, LuCheck, LuLock } from "react-icons/lu"; +import { LuCalendar, LuPencilLine } from "react-icons/lu"; import { ShiftForm } from "@/src/components/features/Shift/ShiftForm"; import type { PositionType, ShiftData, StaffType, TimeRange } from "@/src/components/features/Shift/ShiftForm/types"; import { Animation } from "@/src/components/templates/Animation"; @@ -43,16 +43,13 @@ export const RecruitmentDetail = ({ const submittedCount = staffs.filter((s) => s.isSubmitted).length; const unsubmittedCount = staffs.length - submittedCount; - const handleCloseRecruitment = () => { - // TODO: useMutation呼び出し - console.log("募集を締め切る:", recruitmentId); + const handleCloseAndEdit = () => { + // TODO: 募集締め切りのuseMutation呼び出し + console.log("締切・編集:", recruitmentId); toaster.create({ description: "募集を締め切りました", type: "success", }); - }; - - const handleGoToConfirm = () => { navigate({ to: "/shops/$shopId/shifts/recruitments/$recruitmentId/confirm", params: { shopId, recruitmentId }, @@ -67,16 +64,10 @@ export const RecruitmentDetail = ({ - <Button variant="outline" colorPalette="orange" size="sm" onClick={handleCloseRecruitment}> - <LuLock /> - 募集を締め切る - </Button> - <Button colorPalette="teal" size="sm" onClick={handleGoToConfirm}> - <LuCheck /> - シフト確定へ - </Button> - </HStack> + <Button colorPalette="teal" size="sm" onClick={handleCloseAndEdit} display={{ base: "none", md: "flex" }}> + <LuPencilLine /> + 締切・編集へ + </Button> } > <Flex align="center" gap={3}> @@ -112,37 +103,25 @@ export const RecruitmentDetail = ({ </Card.Root> {/* モバイル用アクションボタン */} - <Box display={{ base: "block", md: "none" }} mb={4}> - <VStack gap={2}> - <Button w="full" variant="outline" colorPalette="orange" onClick={handleCloseRecruitment}> - <LuLock /> - 募集を締め切る - </Button> - <Button w="full" colorPalette="teal" onClick={handleGoToConfirm}> - <LuCheck /> - シフト確定へ - </Button> - </VStack> - </Box> + <Button w="full" colorPalette="teal" onClick={handleCloseAndEdit} display={{ base: "flex", md: "none" }} mb={4}> + <LuPencilLine /> + 締切・編集へ + </Button> {/* ShiftForm: 一覧モード固定、readOnly、シフト希望順 */} - <Card.Root borderWidth={0} shadow="sm" overflow="hidden"> - <Card.Body p={{ base: 0, md: 2 }}> - <ShiftForm - shopId={shopId} - staffs={staffs} - positions={positions} - initialShifts={shifts} - dates={dates} - timeRange={timeRange} - holidays={holidays} - isReadOnly - initialViewMode="overview" - hideViewSwitcher - initialSortMode="request" - /> - </Card.Body> - </Card.Root> + <ShiftForm + shopId={shopId} + staffs={staffs} + positions={positions} + initialShifts={shifts} + dates={dates} + timeRange={timeRange} + holidays={holidays} + isReadOnly + initialViewMode="overview" + hideViewSwitcher + initialSortMode="request" + /> </Animation> </Container> ); diff --git a/src/components/features/Shift/ShiftForm/index.tsx b/src/components/features/Shift/ShiftForm/index.tsx index 28776c32..c197ac52 100644 --- a/src/components/features/Shift/ShiftForm/index.tsx +++ b/src/components/features/Shift/ShiftForm/index.tsx @@ -110,7 +110,7 @@ const ShiftFormInner = ({ {/* 日別ビュー(display:none で常時マウント、UI状態保持) */} <Box display={viewMode === "daily" ? "flex" : "none"} flexDirection="column" flex={1} minHeight={0}> {/* PC */} - <Box display={{ base: "none", lg: "flex" }} flexDirection="column" flex={1} minHeight={0} px={4}> + <Box display={{ base: "none", lg: "flex" }} flexDirection="column" flex={1} minHeight={0}> <DailyView undo={undo} redo={redo} canUndo={canUndo} canRedo={canRedo} /> </Box> {/* SP */} @@ -122,7 +122,7 @@ const ShiftFormInner = ({ {/* 一覧ビュー(display:none で常時マウント) */} <Box display={viewMode === "overview" ? "block" : "none"} flex={1} minHeight={0} overflow="auto"> {/* PC */} - <Box display={{ base: "none", lg: "block" }} px={4}> + <Box display={{ base: "none", lg: "block" }}> <OverviewView /> </Box> {/* SP */} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx index 48bc14f2..74816ce6 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx @@ -197,8 +197,8 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover <Box ref={tableContainerRef} flex={1} minHeight={0} overflowX="auto" overflowY="auto"> <Table.Root size="sm" borderCollapse="separate" borderSpacing={0}> <Table.Header> - <Table.Row bg="gray.50" position="sticky" top={0} zIndex={10} boxShadow="0 2px 4px rgba(0,0,0,0.04)"> - <Table.ColumnHeader w="120px" position="sticky" left={0} bg="gray.50" zIndex={11}> + <Table.Row bg="white" position="sticky" top={0} zIndex={10} boxShadow="0 2px 4px rgba(0,0,0,0.04)"> + <Table.ColumnHeader w="120px" position="sticky" left={0} bg="white" zIndex={11}> <SortMenu sortMode={sortMode} onSortChange={setSortMode} /> </Table.ColumnHeader> <Table.ColumnHeader colSpan={timeSlots.length} p={0} w={`${timeAxisWidth}px`}> diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx index f4da570b..a6e9e519 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx @@ -223,11 +223,11 @@ export const SummaryRow = ({ const positionGradient = buildGradientStyle(counts, required); return ( - <Table.Row key={position.id} bg="gray.50"> + <Table.Row key={position.id} bg="white"> <Table.Cell position="sticky" left={0} - bg="gray.50" + bg="white" zIndex={11} borderRight="1px solid" borderColor="gray.100" diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/TimeHeader.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/TimeHeader.tsx index fa3395bb..856697dc 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/TimeHeader.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/TimeHeader.tsx @@ -18,7 +18,7 @@ export const TimeHeader = ({ timeRange }: TimeHeaderProps) => { const totalWidth = getTimeAxisWidth(timeRange); return ( - <Box position="relative" height="24px" bg="gray.50" width={`${totalWidth}px`} minWidth="100%"> + <Box position="relative" height="24px" bg="white" width={`${totalWidth}px`} minWidth="100%"> {timeLabels.map(({ hour, x }) => ( <Text key={hour} diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/MonthSummaryCell.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/MonthSummaryCell.tsx index 28401422..5151362f 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/MonthSummaryCell.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/MonthSummaryCell.tsx @@ -9,7 +9,7 @@ export const MonthSummaryCell = ({ totalMinutes, alerts }: MonthSummaryCellProps return ( <Table.Cell - bg="gray.50" + bg="white" w={`${MONTH_TOTAL_CELL_WIDTH}px`} minW={`${MONTH_TOTAL_CELL_WIDTH}px`} h={`${ROW_HEIGHT}px`} diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/OverviewHeader.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/OverviewHeader.tsx index 3c1dfd1d..80ed6f39 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/OverviewHeader.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/OverviewHeader.tsx @@ -33,7 +33,7 @@ export const OverviewHeader = ({ dates, months, holidays, sortMode, onSortModeCh <Table.ColumnHeader position="sticky" left={0} - bg="gray.50" + bg="white" zIndex={11} w={`${STAFF_NAME_CELL_WIDTH}px`} minW={`${STAFF_NAME_CELL_WIDTH}px`} @@ -76,7 +76,7 @@ export const OverviewHeader = ({ dates, months, holidays, sortMode, onSortModeCh {months.map((month, index) => ( <Table.ColumnHeader key={month} - bg="gray.50" + bg="white" w={`${MONTH_TOTAL_CELL_WIDTH}px`} minW={`${MONTH_TOTAL_CELL_WIDTH}px`} h={`${ROW_HEIGHT}px`} diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx index f0dcd774..2522de27 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx @@ -66,7 +66,7 @@ export const SummaryFooterRow = ({ shifts, dates, months, requiredStaffing }: Su return ( <Table.Footer> <Table.Row - bg="gray.50" + bg="white" borderTop="2px solid" borderColor="gray.300" position="sticky" @@ -78,7 +78,7 @@ export const SummaryFooterRow = ({ shifts, dates, months, requiredStaffing }: Su <Table.Cell position="sticky" left={0} - bg="gray.50" + bg="white" zIndex={3} w={`${STAFF_NAME_CELL_WIDTH}px`} minW={`${STAFF_NAME_CELL_WIDTH}px`} @@ -162,7 +162,7 @@ export const SummaryFooterRow = ({ shifts, dates, months, requiredStaffing }: Su return ( <Table.Cell key={`summary-${date}`} - bg="gray.50" + bg="white" w={`${DATE_CELL_WIDTH}px`} minW={`${DATE_CELL_WIDTH}px`} h={`${ROW_HEIGHT}px`} @@ -182,7 +182,7 @@ export const SummaryFooterRow = ({ shifts, dates, months, requiredStaffing }: Su {months.map((month) => ( <Table.Cell key={`summary-${month}`} - bg="gray.50" + bg="white" w={`${MONTH_TOTAL_CELL_WIDTH}px`} minW={`${MONTH_TOTAL_CELL_WIDTH}px`} h={`${ROW_HEIGHT}px`} diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx index a94728a6..25355d8c 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx @@ -65,7 +65,7 @@ export const OverviewView = () => { return ( <> - <Box overflow="auto" border="1px solid" borderColor="gray.200" borderRadius="md"> + <Box overflow="auto" border="1px solid" borderColor="gray.200" borderRadius="md" bg="white"> <Table.Root size="sm" variant="outline" stickyHeader> <OverviewHeader dates={dates} diff --git a/src/components/pages/Shops/ShiftConfirmPage/index.tsx b/src/components/pages/Shops/ShiftConfirmPage/index.tsx index 0637a24a..0a350dec 100644 --- a/src/components/pages/Shops/ShiftConfirmPage/index.tsx +++ b/src/components/pages/Shops/ShiftConfirmPage/index.tsx @@ -1,3 +1,4 @@ +import { Box } from "@chakra-ui/react"; import { ShiftForm } from "@/src/components/features/Shift/ShiftForm"; type Props = { @@ -101,15 +102,17 @@ export const ShiftConfirmPage = ({ shopId }: Props) => { // const positions = useQuery(api.position.queries.listByShop, { shopId }); return ( - <ShiftForm - shopId={shopId} - staffs={mockStaffs} - positions={mockPositions} - initialShifts={mockShifts} - dates={mockDates} - timeRange={{ start: 9, end: 22, unit: 30 }} - holidays={[]} - isReadOnly - /> + <Box px={4}> + <ShiftForm + shopId={shopId} + staffs={mockStaffs} + positions={mockPositions} + initialShifts={mockShifts} + dates={mockDates} + timeRange={{ start: 9, end: 22, unit: 30 }} + holidays={[]} + isReadOnly + /> + </Box> ); }; From 36de6c432ebd7e6e719c85bf6400ead6772b4674 Mon Sep 17 00:00:00 2001 From: y-natani <yn1323@gmail.com> Date: Sat, 14 Feb 2026 00:32:15 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=E3=82=B7=E3=83=95=E3=83=88?= =?UTF-8?q?=E5=8B=9F=E9=9B=86=E9=96=8B=E5=A7=8B=E6=99=82=E3=81=AE=E3=83=A1?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E9=80=81=E4=BF=A1=E6=A9=9F=E8=83=BD=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resend + Convex Action で募集作成時にスタッフへメール通知する基盤を構築。 - convex/email/actions.ts: Resend APIによるメール送信internalAction - convex/recruitment/mutations.ts: magicLink生成・メール送信スケジュール追加 - scripts/setupEnv.ts: .envからConvex環境変数を一括設定するスクリプト Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .vscode/tasks.json | 10 ++ convex/email/actions.ts | 89 +++++++++++ convex/recruitment/mutations.ts | 28 +++- ...74\343\203\253\351\200\201\344\277\241.md" | 139 ++++++++++++++++++ package.json | 4 +- pnpm-lock.yaml | 36 +++++ scripts/setupEnv.ts | 50 +++++++ 7 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 convex/email/actions.ts create mode 100644 "doc/plans/2026-02-13_\343\202\267\343\203\225\343\203\210\345\213\237\351\233\206\343\203\241\343\203\274\343\203\253\351\200\201\344\277\241.md" create mode 100644 scripts/setupEnv.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3d6d5fbb..c3f792fe 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -71,6 +71,16 @@ "panel": "new" } }, + { + "label": "Convex環境変数セットアップ", + "type": "shell", + "command": "pnpm convex:env:setup", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, { "label": "difit", "type": "shell", diff --git a/convex/email/actions.ts b/convex/email/actions.ts new file mode 100644 index 00000000..4beaad1d --- /dev/null +++ b/convex/email/actions.ts @@ -0,0 +1,89 @@ +/** + * メール送信ドメイン - アクション(外部API呼び出し) + * + * 責務: + * - Resend APIを使用したメール送信 + * - シフト募集通知メールの送信 + */ +import { v } from "convex/values"; +import { Resend } from "resend"; +import { internalAction } from "../_generated/server"; + +const FROM_EMAIL = "onboarding@resend.dev"; + +// シフト募集通知メール送信 +export const sendRecruitmentNotification = internalAction({ + args: { + shopName: v.string(), + startDate: v.string(), + endDate: v.string(), + deadline: v.string(), + recipients: v.array( + v.object({ + email: v.string(), + magicLinkToken: v.string(), + }), + ), + }, + handler: async (_ctx, args) => { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + console.error("RESEND_API_KEY が設定されていません"); + return; + } + + const resend = new Resend(apiKey); + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + + const subject = `【${args.shopName}】シフト募集のお知らせ(${args.startDate}〜${args.endDate})`; + + for (const recipient of args.recipients) { + try { + const magicLinkUrl = `${appUrl}/shift-submit?token=${recipient.magicLinkToken}`; + + await resend.emails.send({ + from: FROM_EMAIL, + to: recipient.email, + subject, + html: buildEmailHtml({ + shopName: args.shopName, + startDate: args.startDate, + endDate: args.endDate, + deadline: args.deadline, + magicLinkUrl, + }), + }); + } catch (e) { + console.error(`メール送信失敗: ${recipient.email}`, e); + } + } + }, +}); + +// メールHTML組み立て +const buildEmailHtml = (params: { + shopName: string; + startDate: string; + endDate: string; + deadline: string; + magicLinkUrl: string; +}) => { + return ` +<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;"> + <h2>${params.shopName} のシフト募集が開始されました</h2> + <table style="margin: 20px 0; border-collapse: collapse;"> + <tr> + <td style="padding: 8px 16px 8px 0; font-weight: bold;">募集期間</td> + <td style="padding: 8px 0;">${params.startDate} 〜 ${params.endDate}</td> + </tr> + <tr> + <td style="padding: 8px 16px 8px 0; font-weight: bold;">申請締切</td> + <td style="padding: 8px 0;">${params.deadline}</td> + </tr> + </table> + <p>以下のリンクからシフトを申請してください:</p> + <a href="${params.magicLinkUrl}" style="display: inline-block; background: #0d9488; color: #fff; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin: 12px 0;">シフトを申請する</a> + <p style="color: #666; font-size: 14px; margin-top: 24px;">※ このリンクはあなた専用です。他の方と共有しないでください。</p> +</div> +`.trim(); +}; diff --git a/convex/recruitment/mutations.ts b/convex/recruitment/mutations.ts index fb1a5633..55a65e5b 100644 --- a/convex/recruitment/mutations.ts +++ b/convex/recruitment/mutations.ts @@ -5,9 +5,10 @@ * - シフト募集の作成 */ import { ConvexError, v } from "convex/values"; +import { internal } from "../_generated/api"; import { mutation } from "../_generated/server"; import { RECRUITMENT_STATUS } from "../constants"; -import { requireShop, requireShopOwnerOrManager } from "../helpers"; +import { generateToken, requireShop, requireShopOwnerOrManager } from "../helpers"; // 日付形式バリデーション(YYYY-MM-DD形式) const isValidDateFormat = (date: string) => { @@ -64,6 +65,19 @@ export const create = mutation({ const totalStaffCount = activeStaffs.length; + // 各スタッフにマジックリンクトークンを生成・更新 + const deadlineEnd = new Date(`${args.deadline}T23:59:59`).getTime(); + const recipients: { email: string; magicLinkToken: string }[] = []; + + for (const staff of activeStaffs) { + const token = generateToken(); + await ctx.db.patch(staff._id, { + magicLinkToken: token, + magicLinkExpiresAt: deadlineEnd, + }); + recipients.push({ email: staff.email, magicLinkToken: token }); + } + // 募集作成 const recruitmentId = await ctx.db.insert("recruitments", { shopId: args.shopId, @@ -78,6 +92,18 @@ export const create = mutation({ isDeleted: false, }); + // 店舗情報を取得してメール送信をスケジュール + const shop = await requireShop(ctx, args.shopId); + if (recipients.length > 0) { + await ctx.scheduler.runAfter(0, internal.email.actions.sendRecruitmentNotification, { + shopName: shop.shopName, + startDate: args.startDate, + endDate: args.endDate, + deadline: args.deadline, + recipients, + }); + } + return { success: true, data: { recruitmentId, totalStaffCount } }; }, }); diff --git "a/doc/plans/2026-02-13_\343\202\267\343\203\225\343\203\210\345\213\237\351\233\206\343\203\241\343\203\274\343\203\253\351\200\201\344\277\241.md" "b/doc/plans/2026-02-13_\343\202\267\343\203\225\343\203\210\345\213\237\351\233\206\343\203\241\343\203\274\343\203\253\351\200\201\344\277\241.md" new file mode 100644 index 00000000..4c7bdf5c --- /dev/null +++ "b/doc/plans/2026-02-13_\343\202\267\343\203\225\343\203\210\345\213\237\351\233\206\343\203\241\343\203\274\343\203\253\351\200\201\344\277\241.md" @@ -0,0 +1,139 @@ +# シフト募集開始時のメール送信機能 + +## Context + +シフト募集(recruitment)を作成した際、所属スタッフ全員にメールで通知したい。 +現在はメール送信基盤が一切存在しないため、Resend + Convex Action で新規構築する。 + +スタッフテーブルには既に `email`, `magicLinkToken`, `magicLinkExpiresAt` フィールドがあり、 +シフト申請用のマジックリンク発行と組み合わせてメール送信を行う。 + +**決定事項:** +- マジックリンク有効期限 → 募集の deadline(申請締切日)に連動 +- 送信元 → `onboarding@resend.dev`(開発用。本番前にカスタムドメイン設定) + +--- + +## 実装計画 + +### Step 1: Resend パッケージ追加 + 環境変数設定 + +- `pnpm add resend` +- Convex 環境変数に `RESEND_API_KEY` を設定 + - `npx convex env set RESEND_API_KEY <key>` + +### Step 2: メール送信 Action の作成 + +**新規ファイル:** `convex/email/actions.ts` + +- `internalAction` として定義(クライアントから直接呼べないようにする) +- 引数: + +```ts +{ + shopName: string, + startDate: string, + endDate: string, + deadline: string, + recipients: Array<{ email: string, magicLinkToken: string }> +} +``` + +- 各スタッフに個別メール送信(マジックリンクがスタッフごとに異なるため) +- 送信失敗時はコンソールログ出力(リトライは後日検討) + +**メール内容:** + +``` +件名: 【{shopName}】シフト募集のお知らせ({startDate}〜{endDate}) + +本文: +{shopName} のシフト募集が開始されました。 + +■ 募集期間: {startDate} 〜 {endDate} +■ 申請締切: {deadline} + +以下のリンクからシフトを申請してください: +{APP_URL}/shift-submit?token={magicLinkToken} + +※ このリンクはあなた専用です。他の方と共有しないでください。 +``` + +### Step 3: 募集作成 mutation の拡張 + +**修正ファイル:** `convex/recruitment/mutations.ts` + +既存の `create` mutation に以下を追加: + +1. 各アクティブスタッフにマジックリンクトークンを生成 +2. `staffs` テーブルの `magicLinkToken`, `magicLinkExpiresAt` を更新 + - 有効期限 = deadline の日の 23:59:59(締切日いっぱいまで有効) +3. `ctx.scheduler.runAfter(0, internal.email.actions.sendRecruitmentNotification, {...})` + +``` +募集作成 mutation(拡張後フロー) + → 店舗・権限チェック(既存) + → 日付バリデーション(既存) + → アクティブスタッフ取得(既存) + → ★ 各スタッフに magicLinkToken 生成・DB更新 + → 募集レコード作成(既存) + → ★ scheduler.runAfter(0, sendRecruitmentNotification) + → return(既存) +``` + +### Step 4: Convex internal エクスポート設定 + +Convex の `internal` API を使うために、必要に応じて自動生成を確認。 +`convex/_generated/api.d.ts` に `internal.email.actions` が含まれることを確認。 + +--- + +## 修正対象ファイル + +| ファイル | 変更内容 | +|---------|---------| +| `package.json` | `resend` パッケージ追加 | +| `convex/email/actions.ts` | **新規** メール送信 internalAction | +| `convex/recruitment/mutations.ts` | magicLink生成 + scheduler.runAfter 追加 | +| `convex/constants.ts` | `APP_URL` 定数追加(環境変数から取得も検討) | + +--- + +## 再利用する既存コード + +- `convex/helpers.ts:8` `generateToken()` - マジックリンクトークン生成 +- `convex/helpers.ts:137` `requireShop()` - 店舗情報取得(店舗名をメール本文に使用) +- `convex/recruitment/mutations.ts:59-63` アクティブスタッフ取得ロジック + +--- + +## 検証方法 + +1. `pnpm convex:dev` でConvex開発サーバー起動 +2. `npx convex env set RESEND_API_KEY re_xxxxx` でAPIキー設定 +3. フロントエンドからシフト募集を作成 +4. Convex ダッシュボードの Logs/Functions でAction実行を確認 +5. Resend ダッシュボードでメール送信履歴を確認 +6. 受信メールの内容・マジックリンクの動作確認 +7. `pnpm type-check` で型エラーがないことを確認 + +--- + +## 注意事項 + +- Resend 無料枠: 月100通(開発段階では十分) +- Convex Action は mutation のトランザクション外で非同期実行 → 募集作成自体は高速に完了 +- `staffs.magicLinkToken` / `magicLinkExpiresAt` は既存フィールド → schema 変更不要 +- メール送信失敗しても募集作成はロールバックしない(非同期のため) + +--- + +## 8. 現在の進捗 + +- [x] 技術調査・方針決定 +- [x] Step 1: Resend パッケージ追加 +- [x] Step 2: メール送信 Action の作成 (`convex/email/actions.ts`) +- [x] Step 3: 募集作成 mutation の拡張 (`convex/recruitment/mutations.ts`) +- [x] Step 4: 型チェック・lint・フォーマット 通過 +- [ ] 環境変数設定: `npx convex env set RESEND_API_KEY <key>` + `APP_URL` +- [ ] 動作確認(実際にメール送信テスト) diff --git a/package.json b/package.json index ed919ab8..f0d2f671 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "convex:dev": "npx convex dev", "convex:clear": "npx convex run testing:clearAllTables", "convex:import": "tsx convex-seeds/scripts/import.ts", - "convex:export": "tsx convex-seeds/scripts/export.ts" + "convex:export": "tsx convex-seeds/scripts/export.ts", + "convex:env:setup": "tsx scripts/setupEnv.ts" }, "dependencies": { "@chakra-ui/react": "3.30.0", @@ -45,6 +46,7 @@ "react-dom": "19.2.3", "react-hook-form": "7.69.0", "react-icons": "5.5.0", + "resend": "^6.9.2", "zod": "4.2.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d52e274c..389747dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: react-icons: specifier: 5.5.0 version: 5.5.0(react@19.2.3) + resend: + specifier: ^6.9.2 + version: 6.9.2 zod: specifier: 4.2.1 version: 4.2.1 @@ -2913,6 +2916,9 @@ packages: resolution: {integrity: sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + postal-mime@2.7.3: + resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -3028,6 +3034,15 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resend@6.9.2: + resolution: {integrity: sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3258,6 +3273,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.84.1: + resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} + swr@2.3.4: resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==} peerDependencies: @@ -3431,6 +3449,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'} @@ -6692,6 +6714,8 @@ snapshots: dependencies: irregular-plurals: 3.5.0 + postal-mime@2.7.3: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -6817,6 +6841,11 @@ snapshots: require-from-string@2.0.2: {} + resend@6.9.2: + dependencies: + postal-mime: 2.7.3 + svix: 1.84.1 + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -7081,6 +7110,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.84.1: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + swr@2.3.4(react@19.2.3): dependencies: dequal: 2.0.3 @@ -7241,6 +7275,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + uvu@0.5.6: dependencies: dequal: 2.0.3 diff --git a/scripts/setupEnv.ts b/scripts/setupEnv.ts new file mode 100644 index 00000000..29b0649b --- /dev/null +++ b/scripts/setupEnv.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env tsx + +/** + * .env から Convex 環境変数を一括設定するスクリプト + * + * 対象変数: + * - RESEND_API_KEY + * - APP_URL + * - CLERK_JWT_ISSUER_DOMAIN + */ +import { execFileSync } from "node:child_process"; +import { config } from "dotenv"; + +const CONVEX_ENV_KEYS = ["RESEND_API_KEY", "APP_URL", "CLERK_JWT_ISSUER_DOMAIN"] as const; + +const main = () => { + config(); // .env を読み込み + + console.log("=========================================="); + console.log("Convex 環境変数を .env から設定します..."); + console.log("==========================================\n"); + + let successCount = 0; + + for (const key of CONVEX_ENV_KEYS) { + const value = process.env[key]; + + if (!value) { + console.log(`⏭️ ${key}: .env に未設定のためスキップ`); + continue; + } + + try { + execFileSync("npx", ["convex", "env", "set", key, value], { + stdio: "pipe", + cwd: process.cwd(), + }); + console.log(`✅ ${key}: 設定完了`); + successCount++; + } catch (e) { + console.error(`❌ ${key}: 設定失敗`, e instanceof Error ? e.message : e); + } + } + + console.log(`\n==========================================`); + console.log(`完了: ${successCount}/${CONVEX_ENV_KEYS.length} 件設定しました`); + console.log("=========================================="); +}; + +main(); From 51649d5fc61fc8ef164b72e6082d28484cf779fd Mon Sep 17 00:00:00 2001 From: y-natani <yn1323@gmail.com> Date: Sat, 14 Feb 2026 10:26:50 +0900 Subject: [PATCH 08/10] feat: implement shift submit page flow and UX copy --- convex/_generated/api.d.ts | 6 + convex/schema.ts | 20 ++ convex/shiftRequest/mutations.ts | 100 +++++++++ convex/shiftRequest/queries.ts | 117 ++++++++++ .../features/ShiftSubmit/ConfirmView.tsx | 65 ++++++ .../features/ShiftSubmit/DayCard.tsx | 176 +++++++++++++++ .../features/ShiftSubmit/EntryForm.tsx | 85 ++++++++ .../features/ShiftSubmit/SubmittedView.tsx | 76 +++++++ .../features/ShiftSubmit/dayStyle.ts | 35 +++ src/components/features/ShiftSubmit/index.tsx | 205 ++++++++++++++++++ src/components/pages/ShiftSubmit/index.tsx | 106 +++++++++ src/routeTree.gen.ts | 21 ++ src/routes/shift-submit.tsx | 18 ++ 13 files changed, 1030 insertions(+) create mode 100644 convex/shiftRequest/mutations.ts create mode 100644 convex/shiftRequest/queries.ts create mode 100644 src/components/features/ShiftSubmit/ConfirmView.tsx create mode 100644 src/components/features/ShiftSubmit/DayCard.tsx create mode 100644 src/components/features/ShiftSubmit/EntryForm.tsx create mode 100644 src/components/features/ShiftSubmit/SubmittedView.tsx create mode 100644 src/components/features/ShiftSubmit/dayStyle.ts create mode 100644 src/components/features/ShiftSubmit/index.tsx create mode 100644 src/components/pages/ShiftSubmit/index.tsx create mode 100644 src/routes/shift-submit.tsx diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index c8ddcb26..aef82ec3 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as constants from "../constants.js"; +import type * as email_actions from "../email/actions.js"; import type * as helpers from "../helpers.js"; import type * as invite_mutations from "../invite/mutations.js"; import type * as invite_queries from "../invite/queries.js"; @@ -18,6 +19,8 @@ import type * as recruitment_mutations from "../recruitment/mutations.js"; import type * as recruitment_queries from "../recruitment/queries.js"; import type * as requiredStaffing_mutations from "../requiredStaffing/mutations.js"; import type * as requiredStaffing_queries from "../requiredStaffing/queries.js"; +import type * as shiftRequest_mutations from "../shiftRequest/mutations.js"; +import type * as shiftRequest_queries from "../shiftRequest/queries.js"; import type * as shop_mutations from "../shop/mutations.js"; import type * as shop_queries from "../shop/queries.js"; import type * as staffSkill_mutations from "../staffSkill/mutations.js"; @@ -34,6 +37,7 @@ import type { declare const fullApi: ApiFromModules<{ constants: typeof constants; + "email/actions": typeof email_actions; helpers: typeof helpers; "invite/mutations": typeof invite_mutations; "invite/queries": typeof invite_queries; @@ -43,6 +47,8 @@ declare const fullApi: ApiFromModules<{ "recruitment/queries": typeof recruitment_queries; "requiredStaffing/mutations": typeof requiredStaffing_mutations; "requiredStaffing/queries": typeof requiredStaffing_queries; + "shiftRequest/mutations": typeof shiftRequest_mutations; + "shiftRequest/queries": typeof shiftRequest_queries; "shop/mutations": typeof shop_mutations; "shop/queries": typeof shop_queries; "staffSkill/mutations": typeof staffSkill_mutations; diff --git a/convex/schema.ts b/convex/schema.ts index a1b3cc8f..6505dfaf 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -120,6 +120,25 @@ const requiredStaffing = defineTable({ updatedAt: v.number(), }).index("by_shop", ["shopId"]); +// シフト提出テーブル(スタッフがマジックリンクから提出) +const shiftRequests = defineTable({ + recruitmentId: v.id("recruitments"), + staffId: v.id("staffs"), + entries: v.array( + v.object({ + date: v.string(), // "YYYY-MM-DD" + isAvailable: v.boolean(), + startTime: v.optional(v.string()), // "09:00"(isAvailable=true時) + endTime: v.optional(v.string()), // "17:00"(isAvailable=true時) + }), + ), + submittedAt: v.number(), + updatedAt: v.optional(v.number()), +}) + .index("by_recruitment", ["recruitmentId"]) + .index("by_staff", ["staffId"]) + .index("by_recruitment_and_staff", ["recruitmentId", "staffId"]); + // シフト募集テーブル const recruitments = defineTable({ shopId: v.id("shops"), @@ -145,6 +164,7 @@ const schema = defineSchema({ shopPositions, staffSkills, requiredStaffing, + shiftRequests, recruitments, }); diff --git a/convex/shiftRequest/mutations.ts b/convex/shiftRequest/mutations.ts new file mode 100644 index 00000000..e8b7cadb --- /dev/null +++ b/convex/shiftRequest/mutations.ts @@ -0,0 +1,100 @@ +/** + * シフト提出ドメイン - ミューテーション(書き込み操作) + * + * 責務: + * - シフト希望の提出(新規/更新) + */ +import { ConvexError, v } from "convex/values"; +import { mutation } from "../_generated/server"; +import { getStaffByMagicLinkToken, isValidTimeFormat } from "../helpers"; + +// シフト希望提出 +export const submit = mutation({ + args: { + token: v.string(), + entries: v.array( + v.object({ + date: v.string(), + isAvailable: v.boolean(), + startTime: v.optional(v.string()), + endTime: v.optional(v.string()), + }), + ), + }, + handler: async (ctx, args) => { + // トークンからスタッフを取得 + const staff = await getStaffByMagicLinkToken(ctx, args.token); + if (!staff) { + throw new ConvexError({ message: "無効なトークンです", code: "INVALID_TOKEN" }); + } + + // トークン有効期限チェック + if (staff.magicLinkExpiresAt && staff.magicLinkExpiresAt < Date.now()) { + throw new ConvexError({ message: "トークンの有効期限が切れています", code: "TOKEN_EXPIRED" }); + } + + // オープン中の募集を取得 + const recruitment = await ctx.db + .query("recruitments") + .withIndex("by_shop_and_status", (q) => q.eq("shopId", staff.shopId).eq("status", "open")) + .filter((q) => q.neq(q.field("isDeleted"), true)) + .first(); + + if (!recruitment) { + throw new ConvexError({ message: "募集が見つかりません", code: "NO_OPEN_RECRUITMENT" }); + } + + // エントリーのバリデーション + for (const entry of args.entries) { + if (entry.isAvailable) { + if (!entry.startTime || !entry.endTime) { + throw new ConvexError({ + message: `${entry.date}: 出勤可能な場合は開始・終了時刻が必要です`, + code: "MISSING_TIME", + }); + } + if (!isValidTimeFormat(entry.startTime) || !isValidTimeFormat(entry.endTime)) { + throw new ConvexError({ + message: `${entry.date}: 時刻の形式が不正です`, + code: "INVALID_TIME_FORMAT", + }); + } + if (entry.startTime >= entry.endTime) { + throw new ConvexError({ + message: `${entry.date}: 終了時刻は開始時刻より後にしてください`, + code: "INVALID_TIME_RANGE", + }); + } + } + } + + // 既存の提出データを確認 + const existingRequest = await ctx.db + .query("shiftRequests") + .withIndex("by_recruitment_and_staff", (q) => q.eq("recruitmentId", recruitment._id).eq("staffId", staff._id)) + .first(); + + if (existingRequest) { + // 更新 + await ctx.db.patch(existingRequest._id, { + entries: args.entries, + updatedAt: Date.now(), + }); + } else { + // 新規作成 + await ctx.db.insert("shiftRequests", { + recruitmentId: recruitment._id, + staffId: staff._id, + entries: args.entries, + submittedAt: Date.now(), + }); + + // 初回提出時のみ appliedCount をインクリメント + await ctx.db.patch(recruitment._id, { + appliedCount: recruitment.appliedCount + 1, + }); + } + + return { success: true }; + }, +}); diff --git a/convex/shiftRequest/queries.ts b/convex/shiftRequest/queries.ts new file mode 100644 index 00000000..93b1e307 --- /dev/null +++ b/convex/shiftRequest/queries.ts @@ -0,0 +1,117 @@ +/** + * シフト提出ドメイン - クエリ(読み取り操作) + * + * 責務: + * - マジックリンクからの提出ページデータ取得 + */ +import { v } from "convex/values"; +import { query } from "../_generated/server"; +import { getStaffByMagicLinkToken } from "../helpers"; + +// 提出ページデータ取得(マジックリンクトークンで認証) +export const getSubmitPageData = query({ + args: { token: v.string() }, + handler: async (ctx, args) => { + // トークンからスタッフを取得 + const staff = await getStaffByMagicLinkToken(ctx, args.token); + if (!staff) { + return { error: "INVALID_TOKEN" as const }; + } + + // トークン有効期限チェック + if (staff.magicLinkExpiresAt && staff.magicLinkExpiresAt < Date.now()) { + return { error: "TOKEN_EXPIRED" as const }; + } + + // 店舗情報取得 + const shop = await ctx.db.get(staff.shopId); + if (!shop || shop.isDeleted) { + return { error: "SHOP_NOT_FOUND" as const }; + } + + // オープン中の募集を取得 + const recruitment = await ctx.db + .query("recruitments") + .withIndex("by_shop_and_status", (q) => q.eq("shopId", staff.shopId).eq("status", "open")) + .filter((q) => q.neq(q.field("isDeleted"), true)) + .first(); + + if (!recruitment) { + return { error: "NO_OPEN_RECRUITMENT" as const }; + } + + // 今回の募集への既存提出データ + const existingRequest = await ctx.db + .query("shiftRequests") + .withIndex("by_recruitment_and_staff", (q) => q.eq("recruitmentId", recruitment._id).eq("staffId", staff._id)) + .first(); + + // 前回の提出データ(今回の募集以外で最新のもの) + const allPastRequests = await ctx.db + .query("shiftRequests") + .withIndex("by_staff", (q) => q.eq("staffId", staff._id)) + .order("desc") + .collect(); + + const previousRequest = allPastRequests.find((r) => r.recruitmentId !== recruitment._id) ?? null; + + // よく使う時間パターン上位3つを算出 + const frequentTimePatterns = calcFrequentTimePatterns(allPastRequests); + + return { + error: null, + staff: { + _id: staff._id, + displayName: staff.displayName, + }, + shop: { + shopName: shop.shopName, + timeUnit: shop.timeUnit, + openTime: shop.openTime, + closeTime: shop.closeTime, + }, + recruitment: { + _id: recruitment._id, + startDate: recruitment.startDate, + endDate: recruitment.endDate, + deadline: recruitment.deadline, + }, + existingRequest: existingRequest + ? { + entries: existingRequest.entries, + submittedAt: existingRequest.submittedAt, + updatedAt: existingRequest.updatedAt, + } + : null, + previousRequest: previousRequest + ? { + entries: previousRequest.entries, + } + : null, + frequentTimePatterns, + }; + }, +}); + +// 過去の全提出データから頻出の {startTime, endTime} ペアを上位3件抽出 +const calcFrequentTimePatterns = ( + requests: { entries: { isAvailable: boolean; startTime?: string; endTime?: string }[] }[], +) => { + const countMap = new Map<string, { startTime: string; endTime: string; count: number }>(); + + for (const req of requests) { + for (const entry of req.entries) { + if (entry.isAvailable && entry.startTime && entry.endTime) { + const key = `${entry.startTime}-${entry.endTime}`; + const existing = countMap.get(key); + if (existing) { + existing.count++; + } else { + countMap.set(key, { startTime: entry.startTime, endTime: entry.endTime, count: 1 }); + } + } + } + } + + return [...countMap.values()].sort((a, b) => b.count - a.count).slice(0, 3); +}; diff --git a/src/components/features/ShiftSubmit/ConfirmView.tsx b/src/components/features/ShiftSubmit/ConfirmView.tsx new file mode 100644 index 00000000..0223a033 --- /dev/null +++ b/src/components/features/ShiftSubmit/ConfirmView.tsx @@ -0,0 +1,65 @@ +import { Box, Button, HStack, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import { getDayStyle } from "./dayStyle"; +import type { ShiftEntry } from "./index"; + +dayjs.locale("ja"); + +type ConfirmViewProps = { + entries: ShiftEntry[]; + onSubmit: () => Promise<void>; + onBack: () => void; +}; + +export const ConfirmView = ({ entries, onSubmit, onBack }: ConfirmViewProps) => { + const availableCount = entries.filter((e) => e.isAvailable === true).length; + const totalDays = entries.length; + + return ( + <VStack gap={4} align="stretch"> + <Box bg="white" borderRadius="md" shadow="xs" p={4}> + <Text fontWeight="bold" fontSize="md" mb={3}> + 入力内容の確認 + </Text> + + <VStack gap={1} align="stretch"> + {entries.map((entry) => { + const d = dayjs(entry.date); + const dayStyle = getDayStyle(entry.date); + return ( + <HStack key={entry.date} gap={3} py={1} borderBottom="1px solid" borderColor="gray.100"> + <Text fontSize="sm" fontWeight="medium" color={dayStyle.textColor} minW="70px"> + {d.format("M/D(ddd)")} + </Text> + {entry.isAvailable ? ( + <Text fontSize="sm" color="gray.700"> + {entry.startTime}〜{entry.endTime} + </Text> + ) : ( + <Text fontSize="sm" color="gray.500"> + - + </Text> + )} + </HStack> + ); + })} + </VStack> + + <HStack gap={1} mt={3} justifyContent="center"> + <Text fontSize="sm" color="teal.600" fontWeight="medium"> + 出勤可能: {availableCount}日 / {totalDays}日中 + </Text> + </HStack> + </Box> + + <Button colorPalette="teal" size="lg" w="full" onClick={onSubmit}> + この内容で提出する + </Button> + + <Button variant="ghost" size="sm" onClick={onBack}> + 入力画面に戻る + </Button> + </VStack> + ); +}; diff --git a/src/components/features/ShiftSubmit/DayCard.tsx b/src/components/features/ShiftSubmit/DayCard.tsx new file mode 100644 index 00000000..1c384738 --- /dev/null +++ b/src/components/features/ShiftSubmit/DayCard.tsx @@ -0,0 +1,176 @@ +import { Box, Button, Flex, HStack, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import { useState } from "react"; +import "dayjs/locale/ja"; +import { Select } from "@/src/components/ui/Select"; +import { getDayStyle } from "./dayStyle"; +import type { ShiftEntry, TimePattern } from "./index"; + +dayjs.locale("ja"); + +type DayCardProps = { + entry: ShiftEntry; + frequentTimePatterns: TimePattern[]; + shop: { timeUnit: number; openTime: string; closeTime: string }; + onUpdate: (update: Partial<ShiftEntry>) => void; +}; + +// 店舗の営業時間とtimeUnitから時間選択肢を生成 +const generateTimeOptions = (openTime: string, closeTime: string, timeUnit: number) => { + const options: { value: string; label: string }[] = []; + const [openH, openM] = openTime.split(":").map(Number); + const [closeH, closeM] = closeTime.split(":").map(Number); + const startMinutes = openH * 60 + openM; + const endMinutes = closeH * 60 + closeM; + + for (let m = startMinutes; m <= endMinutes; m += timeUnit) { + const h = Math.floor(m / 60); + const min = m % 60; + const value = `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`; + options.push({ value, label: value }); + } + return options; +}; + +export const DayCard = ({ entry, frequentTimePatterns, shop, onUpdate }: DayCardProps) => { + const [showCustomTime, setShowCustomTime] = useState(false); + const d = dayjs(entry.date); + const dayOfWeek = d.format("ddd"); + const dayStyle = getDayStyle(entry.date); + + const timeOptions = generateTimeOptions(shop.openTime, shop.closeTime, shop.timeUnit); + + // 現在選択中のパターンがチップと一致するか + const selectedPatternKey = entry.startTime && entry.endTime ? `${entry.startTime}-${entry.endTime}` : null; + + const handleSelectAvailable = () => { + onUpdate({ isAvailable: true }); + }; + + const handleSelectUnavailable = () => { + onUpdate({ isAvailable: false, startTime: undefined, endTime: undefined }); + setShowCustomTime(false); + }; + + const handleSelectPattern = (startTime: string, endTime: string) => { + onUpdate({ isAvailable: true, startTime, endTime }); + setShowCustomTime(false); + }; + + if (!entry.isAvailable) { + return ( + <Box + bg="gray.100" + borderRadius="md" + p={3} + border="1px solid" + borderColor="gray.200" + cursor="pointer" + role="button" + tabIndex={0} + aria-label={`${d.format("M/D")}を出勤可能にする`} + onClick={handleSelectAvailable} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectAvailable(); + } + }} + > + <VStack align="stretch" gap={2}> + <HStack justify="space-between"> + <Text fontWeight="bold" fontSize="sm" color="gray.700"> + {d.format("M/D")} ({dayOfWeek}) + </Text> + <Text fontSize="xs" color="gray.500"> + 未選択(お休み) + </Text> + </HStack> + <Text fontSize="sm" color="gray.600"> + タップして出勤可能にする + </Text> + </VStack> + </Box> + ); + } + + return ( + <Box bg="white" borderRadius="md" shadow="xs" p={3} borderLeft="4px solid" borderLeftColor={dayStyle.borderColor}> + <VStack gap={2} align="stretch"> + <HStack justify="space-between"> + <Text fontWeight="bold" fontSize="sm" color={dayStyle.textColor}> + {d.format("M/D")} ({dayOfWeek}) + </Text> + <Text fontSize="xs" color={dayStyle.textColor}> + 出勤可能 + </Text> + </HStack> + + {frequentTimePatterns.length > 0 && ( + <VStack align="stretch" gap={1}> + <Text fontSize="xs" color="gray.500"> + よく使う時間 + </Text> + <Flex gap={2} flexWrap="wrap"> + {frequentTimePatterns.map((p) => { + const key = `${p.startTime}-${p.endTime}`; + const isSelected = selectedPatternKey === key; + return ( + <Button + key={key} + size="xs" + variant={isSelected ? "solid" : "outline"} + colorPalette={isSelected ? dayStyle.palette : "gray"} + onClick={() => handleSelectPattern(p.startTime, p.endTime)} + > + {p.startTime}-{p.endTime} + </Button> + ); + })} + <Button + size="xs" + variant={showCustomTime ? "subtle" : "outline"} + colorPalette="gray" + onClick={() => setShowCustomTime(!showCustomTime)} + > + 時間を指定 {showCustomTime ? "▲" : "▼"} + </Button> + </Flex> + </VStack> + )} + + {entry.startTime && entry.endTime && !showCustomTime && ( + <Text fontSize="sm" color={dayStyle.textColor} fontWeight="medium"> + 選択中: {entry.startTime}〜{entry.endTime} + </Text> + )} + + {(showCustomTime || frequentTimePatterns.length === 0) && ( + <HStack gap={2} align="center"> + <Select + items={timeOptions} + value={entry.startTime ?? ""} + onChange={(v) => onUpdate({ startTime: v })} + placeholder="開始" + size="sm" + /> + <Text fontSize="sm" color="gray.500"> + 〜 + </Text> + <Select + items={timeOptions} + value={entry.endTime ?? ""} + onChange={(v) => onUpdate({ endTime: v })} + placeholder="終了" + size="sm" + /> + </HStack> + )} + + <Button variant="ghost" size="xs" colorPalette="gray" alignSelf="flex-end" onClick={handleSelectUnavailable}> + この日の入力を取り消す + </Button> + </VStack> + </Box> + ); +}; diff --git a/src/components/features/ShiftSubmit/EntryForm.tsx b/src/components/features/ShiftSubmit/EntryForm.tsx new file mode 100644 index 00000000..bf1676a7 --- /dev/null +++ b/src/components/features/ShiftSubmit/EntryForm.tsx @@ -0,0 +1,85 @@ +import { Box, Button, Icon, Text, VStack } from "@chakra-ui/react"; +import { LuHistory } from "react-icons/lu"; +import { DayCard } from "./DayCard"; +import type { ShiftEntry, TimePattern } from "./index"; + +type EntryFormProps = { + entries: ShiftEntry[]; + totalDays: number; + previousRequest: { + entries: { date: string; isAvailable: boolean; startTime?: string; endTime?: string }[]; + } | null; + frequentTimePatterns: TimePattern[]; + shop: { timeUnit: number; openTime: string; closeTime: string }; + onUpdateEntry: (date: string, update: Partial<ShiftEntry>) => void; + onApplyPrevious: () => void; + onConfirm: () => void; +}; + +export const EntryForm = ({ + entries, + totalDays, + previousRequest, + frequentTimePatterns, + shop, + onUpdateEntry, + onApplyPrevious, + onConfirm, +}: EntryFormProps) => { + const availableCount = entries.filter((e) => e.isAvailable).length; + // 出勤可能なのに時間未設定のエントリーがないかチェック + const hasIncompleteAvailable = entries.some((e) => e.isAvailable && (!e.startTime || !e.endTime)); + + return ( + <VStack gap={4} align="stretch"> + {/* 前回と同じボタン */} + {previousRequest && ( + <VStack gap={1} align="stretch"> + <Button variant="outline" colorPalette="teal" onClick={onApplyPrevious} w="full"> + <Icon as={LuHistory} mr={2} /> + 前回の提出内容を反映する + </Button> + <Text fontSize="xs" color="gray.500" textAlign="center"> + 曜日ごとに前回の時間帯を反映します + </Text> + </VStack> + )} + + {/* ガイドテキスト */} + <VStack gap={1}> + <Text fontSize="sm" color="gray.700" textAlign="center" fontWeight="medium"> + 出勤できる日だけ選んでください + </Text> + <Text fontSize="xs" color="gray.500" textAlign="center"> + 選ばない日は「お休み」として扱われます + </Text> + </VStack> + + {/* 日ごとのカード */} + {entries.map((entry) => ( + <DayCard + key={entry.date} + entry={entry} + frequentTimePatterns={frequentTimePatterns} + shop={shop} + onUpdate={(update) => onUpdateEntry(entry.date, update)} + /> + ))} + + {/* 出勤可能日数 + 確認ボタン */} + <Box pt={2}> + <Text fontSize="sm" color="gray.600" textAlign="center" mb={2}> + 出勤可能: {availableCount}日 / {totalDays}日中 + </Text> + {hasIncompleteAvailable && ( + <Text fontSize="sm" color="red.500" textAlign="center" mb={2}> + 出勤可能にした日は、開始時刻と終了時刻を入力してください + </Text> + )} + <Button colorPalette="teal" size="lg" w="full" disabled={hasIncompleteAvailable} onClick={onConfirm}> + 入力内容を確認する + </Button> + </Box> + </VStack> + ); +}; diff --git a/src/components/features/ShiftSubmit/SubmittedView.tsx b/src/components/features/ShiftSubmit/SubmittedView.tsx new file mode 100644 index 00000000..adc39d2b --- /dev/null +++ b/src/components/features/ShiftSubmit/SubmittedView.tsx @@ -0,0 +1,76 @@ +import { Box, Button, Center, HStack, Icon, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import { LuCircleCheck } from "react-icons/lu"; +import { getDayStyle } from "./dayStyle"; +import type { ShiftEntry } from "./index"; + +dayjs.locale("ja"); + +type SubmittedViewProps = { + entries: ShiftEntry[]; + submittedAt: number | null; + deadline: string; + onEdit: () => void; +}; + +export const SubmittedView = ({ entries, submittedAt, deadline, onEdit }: SubmittedViewProps) => { + const isBeforeDeadline = dayjs().isBefore(dayjs(`${deadline}T23:59:59`)); + + return ( + <VStack gap={4} align="stretch"> + <Box bg="white" borderRadius="md" shadow="xs" p={4}> + <VStack gap={3} align="center" mb={4}> + <Center bg="green.100" p={3} borderRadius="full"> + <Icon as={LuCircleCheck} boxSize={6} color="green.600" /> + </Center> + <Text fontWeight="bold" fontSize="md"> + シフト希望を提出しました + </Text> + <Text fontSize="sm" color="gray.600"> + 提出内容は締切まで修正できます + </Text> + {submittedAt && ( + <Text fontSize="sm" color="gray.500"> + 提出日時: {dayjs(submittedAt).format("M/D HH:mm")} + </Text> + )} + </VStack> + + <VStack gap={1} align="stretch"> + {entries.map((entry) => { + const d = dayjs(entry.date); + const dayStyle = getDayStyle(entry.date); + return ( + <HStack key={entry.date} gap={3} py={1} borderBottom="1px solid" borderColor="gray.100"> + <Text fontSize="sm" fontWeight="medium" color={dayStyle.textColor} minW="70px"> + {d.format("M/D(ddd)")} + </Text> + {entry.isAvailable ? ( + <Text fontSize="sm" color="gray.700"> + {entry.startTime}〜{entry.endTime} + </Text> + ) : ( + <Text fontSize="sm" color="gray.500"> + - + </Text> + )} + </HStack> + ); + })} + </VStack> + </Box> + + {isBeforeDeadline && ( + <VStack gap={1}> + <Button variant="outline" colorPalette="teal" size="md" w="full" onClick={onEdit}> + 提出内容を修正する + </Button> + <Text fontSize="xs" color="gray.500" textAlign="center"> + 締切({dayjs(deadline).format("M/D")})まで修正可能 + </Text> + </VStack> + )} + </VStack> + ); +}; diff --git a/src/components/features/ShiftSubmit/dayStyle.ts b/src/components/features/ShiftSubmit/dayStyle.ts new file mode 100644 index 00000000..642a1686 --- /dev/null +++ b/src/components/features/ShiftSubmit/dayStyle.ts @@ -0,0 +1,35 @@ +import dayjs from "dayjs"; + +export type DayPalette = "red" | "blue" | "teal"; + +type DayStyle = { + palette: DayPalette; + borderColor: string; + textColor: string; +}; + +export const getDayStyle = (date: string): DayStyle => { + const day = dayjs(date).day(); + + if (day === 0) { + return { + palette: "red", + borderColor: "red.300", + textColor: "red.600", + }; + } + + if (day === 6) { + return { + palette: "blue", + borderColor: "blue.300", + textColor: "blue.600", + }; + } + + return { + palette: "teal", + borderColor: "teal.300", + textColor: "teal.600", + }; +}; diff --git a/src/components/features/ShiftSubmit/index.tsx b/src/components/features/ShiftSubmit/index.tsx new file mode 100644 index 00000000..87a5b80f --- /dev/null +++ b/src/components/features/ShiftSubmit/index.tsx @@ -0,0 +1,205 @@ +import { Box, Center, Heading, Icon, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import { useState } from "react"; +import "dayjs/locale/ja"; +import { useMutation } from "convex/react"; +import { LuCalendarDays } from "react-icons/lu"; +import { api } from "@/convex/_generated/api"; +import { toaster } from "@/src/components/ui/toaster"; +import { ConfirmView } from "./ConfirmView"; +import { EntryForm } from "./EntryForm"; +import { SubmittedView } from "./SubmittedView"; + +dayjs.locale("ja"); + +export type ShiftEntry = { + date: string; + isAvailable: boolean; + startTime?: string; + endTime?: string; +}; + +export type TimePattern = { + startTime: string; + endTime: string; + count: number; +}; + +type ShiftSubmitProps = { + token: string; + staff: { _id: string; displayName: string }; + shop: { shopName: string; timeUnit: number; openTime: string; closeTime: string }; + recruitment: { _id: string; startDate: string; endDate: string; deadline: string }; + existingRequest: { + entries: { date: string; isAvailable: boolean; startTime?: string; endTime?: string }[]; + submittedAt: number; + updatedAt?: number; + } | null; + previousRequest: { + entries: { date: string; isAvailable: boolean; startTime?: string; endTime?: string }[]; + } | null; + frequentTimePatterns: TimePattern[]; +}; + +type ViewState = "form" | "confirm" | "submitted"; + +// 募集期間の全日付を生成 +const generateDates = (startDate: string, endDate: string) => { + const dates: string[] = []; + let current = dayjs(startDate); + const end = dayjs(endDate); + while (current.isBefore(end) || current.isSame(end, "day")) { + dates.push(current.format("YYYY-MM-DD")); + current = current.add(1, "day"); + } + return dates; +}; + +// 初期エントリーを生成(既存提出があればそれを使用、なければ全日不可) +const createInitialEntries = (dates: string[], existingRequest: ShiftSubmitProps["existingRequest"]): ShiftEntry[] => { + if (existingRequest) { + const entryMap = new Map(existingRequest.entries.map((e) => [e.date, e])); + return dates.map((date) => { + const existing = entryMap.get(date); + if (existing) { + return { date, isAvailable: existing.isAvailable, startTime: existing.startTime, endTime: existing.endTime }; + } + return { date, isAvailable: false }; + }); + } + return dates.map((date) => ({ date, isAvailable: false })); +}; + +export const ShiftSubmit = ({ + token, + staff, + shop, + recruitment, + existingRequest, + previousRequest, + frequentTimePatterns, +}: ShiftSubmitProps) => { + const dates = generateDates(recruitment.startDate, recruitment.endDate); + const [view, setView] = useState<ViewState>(existingRequest ? "submitted" : "form"); + const [entries, setEntries] = useState<ShiftEntry[]>(() => createInitialEntries(dates, existingRequest)); + const [submittedAt, setSubmittedAt] = useState<number | null>( + existingRequest?.updatedAt ?? existingRequest?.submittedAt ?? null, + ); + + const submitMutation = useMutation(api.shiftRequest.mutations.submit); + + const handleUpdateEntry = (date: string, update: Partial<ShiftEntry>) => { + setEntries((prev) => prev.map((e) => (e.date === date ? { ...e, ...update } : e))); + }; + + const handleApplyPrevious = () => { + if (!previousRequest) return; + const prevByDayOfWeek = new Map<number, (typeof previousRequest.entries)[number]>(); + for (const entry of previousRequest.entries) { + prevByDayOfWeek.set(dayjs(entry.date).day(), entry); + } + setEntries((prev) => + prev.map((e) => { + const prevEntry = prevByDayOfWeek.get(dayjs(e.date).day()); + if (prevEntry) { + return { + date: e.date, + isAvailable: prevEntry.isAvailable, + startTime: prevEntry.startTime, + endTime: prevEntry.endTime, + }; + } + return e; + }), + ); + }; + + const handleSubmit = async () => { + const submitEntries = entries.map((e) => ({ + date: e.date, + isAvailable: e.isAvailable, + ...(e.isAvailable && e.startTime ? { startTime: e.startTime } : {}), + ...(e.isAvailable && e.endTime ? { endTime: e.endTime } : {}), + })); + + try { + await submitMutation({ token, entries: submitEntries }); + setSubmittedAt(Date.now()); + setView("submitted"); + toaster.success({ title: "シフト希望を提出しました" }); + } catch (error) { + toaster.error({ + title: "提出に失敗しました", + description: error instanceof Error ? error.message : "エラーが発生しました", + }); + } + }; + + const handleEdit = () => { + setView("form"); + }; + + return ( + <Center minH="100vh" bg="gray.50" p={4}> + <Box maxW="md" w="full"> + {/* ヘッダー */} + <VStack gap={3} mb={6} align="center"> + <Center bg="teal.100" p={3} borderRadius="full"> + <Icon as={LuCalendarDays} boxSize={6} color="teal.600" /> + </Center> + <VStack gap={1}> + <Heading size="md">{shop.shopName}</Heading> + <Text fontSize="lg" fontWeight="bold"> + シフト希望提出 + </Text> + </VStack> + <Box bg="white" p={3} borderRadius="md" w="full" shadow="xs"> + <VStack gap={1} align="stretch" fontSize="sm"> + <Text> + <Text as="span" fontWeight="bold"> + 募集期間: + </Text>{" "} + {dayjs(recruitment.startDate).format("M/D(ddd)")} 〜 {dayjs(recruitment.endDate).format("M/D(ddd)")} + </Text> + <Text> + <Text as="span" fontWeight="bold"> + 締切: + </Text>{" "} + {dayjs(recruitment.deadline).format("M/D(ddd)")} + </Text> + <Text> + <Text as="span" fontWeight="bold"> + {staff.displayName} + </Text>{" "} + さん + </Text> + </VStack> + </Box> + </VStack> + + {/* ビュー切り替え */} + {view === "form" && ( + <EntryForm + entries={entries} + totalDays={dates.length} + previousRequest={previousRequest} + frequentTimePatterns={frequentTimePatterns} + shop={shop} + onUpdateEntry={handleUpdateEntry} + onApplyPrevious={handleApplyPrevious} + onConfirm={() => setView("confirm")} + /> + )} + {view === "confirm" && <ConfirmView entries={entries} onSubmit={handleSubmit} onBack={() => setView("form")} />} + {view === "submitted" && ( + <SubmittedView + entries={entries} + submittedAt={submittedAt} + deadline={recruitment.deadline} + onEdit={handleEdit} + /> + )} + </Box> + </Center> + ); +}; diff --git a/src/components/pages/ShiftSubmit/index.tsx b/src/components/pages/ShiftSubmit/index.tsx new file mode 100644 index 00000000..9ecc8639 --- /dev/null +++ b/src/components/pages/ShiftSubmit/index.tsx @@ -0,0 +1,106 @@ +import { Center, Heading, Icon, Spinner, Text, VStack } from "@chakra-ui/react"; +import { useQuery } from "convex/react"; +import { LuCalendarClock, LuCircleX, LuClock } from "react-icons/lu"; +import { api } from "@/convex/_generated/api"; +import { ShiftSubmit } from "@/src/components/features/ShiftSubmit"; + +type ShiftSubmitPageProps = { + token: string; +}; + +export const ShiftSubmitPage = ({ token }: ShiftSubmitPageProps) => { + const data = useQuery(api.shiftRequest.queries.getSubmitPageData, token ? { token } : "skip"); + + // トークンがない場合 + if (!token) { + return ( + <ErrorState + icon={LuCircleX} + iconColor="red.500" + title="リンクが確認できませんでした" + description="URLを確認して、再度お試しください。" + /> + ); + } + + // ローディング + if (data === undefined) { + return ( + <Center minH="100vh" bg="gray.50"> + <Spinner size="xl" /> + </Center> + ); + } + + // エラー分岐 + if (data.error) { + const errorConfig = { + INVALID_TOKEN: { + icon: LuCircleX, + color: "red.500", + title: "リンクが確認できませんでした", + desc: "URLを確認して、再度お試しください。", + }, + TOKEN_EXPIRED: { + icon: LuClock, + color: "orange.500", + title: "リンクの有効期限が切れています", + desc: "受付期間が終了したため、このリンクは使えません。", + }, + SHOP_NOT_FOUND: { + icon: LuCircleX, + color: "red.500", + title: "店舗情報を取得できませんでした", + desc: "時間をおいて再度お試しください。", + }, + NO_OPEN_RECRUITMENT: { + icon: LuCalendarClock, + color: "orange.500", + title: "現在は募集を受け付けていません", + desc: "現在この店舗で受付中の募集はありません。", + }, + } as const; + + const config = errorConfig[data.error]; + return <ErrorState icon={config.icon} iconColor={config.color} title={config.title} description={config.desc} />; + } + + return ( + <ShiftSubmit + token={token} + staff={data.staff} + shop={data.shop} + recruitment={data.recruitment} + existingRequest={data.existingRequest} + previousRequest={data.previousRequest} + frequentTimePatterns={data.frequentTimePatterns} + /> + ); +}; + +type ErrorStateProps = { + icon: React.ElementType; + iconColor: string; + title: string; + description: string; +}; + +const ErrorState = ({ icon, iconColor, title, description }: ErrorStateProps) => ( + <Center minH="100vh" bg="gray.50" p={4}> + <VStack gap={6}> + <Center> + <Center bg="gray.100" p={4} borderRadius="full"> + <Icon as={icon} boxSize={8} color={iconColor} /> + </Center> + </Center> + <VStack gap={2}> + <Heading size="lg" textAlign="center"> + {title} + </Heading> + <Text color="gray.600" textAlign="center"> + {description} + </Text> + </VStack> + </VStack> + </Center> +); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1f858f1b..0b82691e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as ShiftSubmitRouteImport } from './routes/shift-submit' import { Route as InviteRouteImport } from './routes/invite' import { Route as UnregisteredRouteImport } from './routes/_unregistered' import { Route as AuthRouteImport } from './routes/_auth' @@ -33,6 +34,11 @@ import { Route as AuthShopsShopIdShiftsRecruitmentsNewRouteImport } from './rout import { Route as AuthShopsShopIdShiftsRecruitmentsRecruitmentIdIndexRouteImport } from './routes/_auth/shops/$shopId/shifts/recruitments/$recruitmentId/index' import { Route as AuthShopsShopIdShiftsRecruitmentsRecruitmentIdConfirmRouteImport } from './routes/_auth/shops/$shopId/shifts/recruitments/$recruitmentId/confirm' +const ShiftSubmitRoute = ShiftSubmitRouteImport.update({ + id: '/shift-submit', + path: '/shift-submit', + getParentRoute: () => rootRouteImport, +} as any) const InviteRoute = InviteRouteImport.update({ id: '/invite', path: '/invite', @@ -162,6 +168,7 @@ const AuthShopsShopIdShiftsRecruitmentsRecruitmentIdConfirmRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/invite': typeof InviteRoute + '/shift-submit': typeof ShiftSubmitRoute '/mypage': typeof AuthMypageRoute '/shifts': typeof AuthShiftsRoute '/welcome': typeof UnregisteredWelcomeRoute @@ -185,6 +192,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/invite': typeof InviteRoute + '/shift-submit': typeof ShiftSubmitRoute '/mypage': typeof AuthMypageRoute '/shifts': typeof AuthShiftsRoute '/welcome': typeof UnregisteredWelcomeRoute @@ -211,6 +219,7 @@ export interface FileRoutesById { '/_auth': typeof AuthRouteWithChildren '/_unregistered': typeof UnregisteredRouteWithChildren '/invite': typeof InviteRoute + '/shift-submit': typeof ShiftSubmitRoute '/_auth/mypage': typeof AuthMypageRoute '/_auth/shifts': typeof AuthShiftsRoute '/_unregistered/welcome': typeof UnregisteredWelcomeRoute @@ -236,6 +245,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/invite' + | '/shift-submit' | '/mypage' | '/shifts' | '/welcome' @@ -259,6 +269,7 @@ export interface FileRouteTypes { to: | '/' | '/invite' + | '/shift-submit' | '/mypage' | '/shifts' | '/welcome' @@ -284,6 +295,7 @@ export interface FileRouteTypes { | '/_auth' | '/_unregistered' | '/invite' + | '/shift-submit' | '/_auth/mypage' | '/_auth/shifts' | '/_unregistered/welcome' @@ -310,10 +322,18 @@ export interface RootRouteChildren { AuthRoute: typeof AuthRouteWithChildren UnregisteredRoute: typeof UnregisteredRouteWithChildren InviteRoute: typeof InviteRoute + ShiftSubmitRoute: typeof ShiftSubmitRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/shift-submit': { + id: '/shift-submit' + path: '/shift-submit' + fullPath: '/shift-submit' + preLoaderRoute: typeof ShiftSubmitRouteImport + parentRoute: typeof rootRouteImport + } '/invite': { id: '/invite' path: '/invite' @@ -543,6 +563,7 @@ const rootRouteChildren: RootRouteChildren = { AuthRoute: AuthRouteWithChildren, UnregisteredRoute: UnregisteredRouteWithChildren, InviteRoute: InviteRoute, + ShiftSubmitRoute: ShiftSubmitRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/shift-submit.tsx b/src/routes/shift-submit.tsx new file mode 100644 index 00000000..4ba6d807 --- /dev/null +++ b/src/routes/shift-submit.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ShiftSubmitPage } from "@/src/components/pages/ShiftSubmit"; + +type ShiftSubmitSearch = { + token?: string; +}; + +export const Route = createFileRoute("/shift-submit")({ + validateSearch: (search: Record<string, unknown>): ShiftSubmitSearch => ({ + token: typeof search.token === "string" ? search.token : undefined, + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { token } = Route.useSearch(); + return <ShiftSubmitPage token={token ?? ""} />; +} From 455439b7bbff14cda23722e2208aec6169295296 Mon Sep 17 00:00:00 2001 From: y-natani <yn1323@gmail.com> Date: Sat, 14 Feb 2026 11:00:40 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20ShiftSubmit=E9=96=A2=E9=80=A3?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=AEStorybook=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .../ShiftSubmit/ConfirmView.stories.tsx | 34 ++++++++ .../features/ShiftSubmit/DayCard.stories.tsx | 73 +++++++++++++++++ .../ShiftSubmit/SubmittedView.stories.tsx | 43 ++++++++++ .../features/ShiftSubmit/index.stories.tsx | 82 +++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 src/components/features/ShiftSubmit/ConfirmView.stories.tsx create mode 100644 src/components/features/ShiftSubmit/DayCard.stories.tsx create mode 100644 src/components/features/ShiftSubmit/SubmittedView.stories.tsx create mode 100644 src/components/features/ShiftSubmit/index.stories.tsx diff --git a/src/components/features/ShiftSubmit/ConfirmView.stories.tsx b/src/components/features/ShiftSubmit/ConfirmView.stories.tsx new file mode 100644 index 00000000..b3c46820 --- /dev/null +++ b/src/components/features/ShiftSubmit/ConfirmView.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "@storybook/test"; +import { ConfirmView } from "./ConfirmView"; + +const meta = { + title: "features/ShiftSubmit/ConfirmView", + component: ConfirmView, + parameters: { + layout: "centered", + }, + args: { + onBack: fn(), + }, +} satisfies Meta<typeof ConfirmView>; + +export default meta; +type Story = StoryObj<typeof meta>; + +const mockEntries = [ + { date: "2026-03-02", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-03-03", isAvailable: false }, + { date: "2026-03-04", isAvailable: true, startTime: "10:00", endTime: "18:00" }, + { date: "2026-03-05", isAvailable: false }, + { date: "2026-03-06", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-03-07", isAvailable: true, startTime: "13:00", endTime: "22:00" }, + { date: "2026-03-08", isAvailable: false }, +]; + +export const Basic: Story = { + args: { + entries: mockEntries, + onSubmit: fn(() => new Promise((resolve) => setTimeout(resolve, 1000))), + }, +}; diff --git a/src/components/features/ShiftSubmit/DayCard.stories.tsx b/src/components/features/ShiftSubmit/DayCard.stories.tsx new file mode 100644 index 00000000..9c97697b --- /dev/null +++ b/src/components/features/ShiftSubmit/DayCard.stories.tsx @@ -0,0 +1,73 @@ +import { Box } from "@chakra-ui/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "@storybook/test"; +import { DayCard } from "./DayCard"; + +const meta = { + title: "features/ShiftSubmit/DayCard", + component: DayCard, + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( + <Box maxW="400px" w="full"> + <Story /> + </Box> + ), + ], + args: { + onUpdate: fn(), + }, +} satisfies Meta<typeof DayCard>; + +export default meta; +type Story = StoryObj<typeof meta>; + +const mockShop = { timeUnit: 30, openTime: "09:00", closeTime: "22:00" }; + +const mockFrequentTimePatterns = [ + { startTime: "09:00", endTime: "17:00", count: 5 }, + { startTime: "10:00", endTime: "18:00", count: 3 }, + { startTime: "13:00", endTime: "22:00", count: 2 }, +]; + +export const Unselected: Story = { + args: { + entry: { date: "2026-03-03", isAvailable: false }, + frequentTimePatterns: mockFrequentTimePatterns, + shop: mockShop, + }, +}; + +export const Selected: Story = { + args: { + entry: { date: "2026-03-04", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + frequentTimePatterns: mockFrequentTimePatterns, + shop: mockShop, + }, +}; + +export const Saturday: Story = { + args: { + entry: { date: "2026-03-07", isAvailable: true, startTime: "10:00", endTime: "18:00" }, + frequentTimePatterns: mockFrequentTimePatterns, + shop: mockShop, + }, +}; + +export const Sunday: Story = { + args: { + entry: { date: "2026-03-08", isAvailable: true, startTime: "13:00", endTime: "22:00" }, + frequentTimePatterns: mockFrequentTimePatterns, + shop: mockShop, + }, +}; + +export const NoPatterns: Story = { + args: { + entry: { date: "2026-03-02", isAvailable: true }, + frequentTimePatterns: [], + shop: mockShop, + }, +}; diff --git a/src/components/features/ShiftSubmit/SubmittedView.stories.tsx b/src/components/features/ShiftSubmit/SubmittedView.stories.tsx new file mode 100644 index 00000000..df98362c --- /dev/null +++ b/src/components/features/ShiftSubmit/SubmittedView.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "@storybook/test"; +import { SubmittedView } from "./SubmittedView"; + +const meta = { + title: "features/ShiftSubmit/SubmittedView", + component: SubmittedView, + parameters: { + layout: "centered", + }, + args: { + onEdit: fn(), + }, +} satisfies Meta<typeof SubmittedView>; + +export default meta; +type Story = StoryObj<typeof meta>; + +const mockEntries = [ + { date: "2026-03-02", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-03-03", isAvailable: false }, + { date: "2026-03-04", isAvailable: true, startTime: "10:00", endTime: "18:00" }, + { date: "2026-03-05", isAvailable: false }, + { date: "2026-03-06", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-03-07", isAvailable: true, startTime: "13:00", endTime: "22:00" }, + { date: "2026-03-08", isAvailable: false }, +]; + +export const Basic: Story = { + args: { + entries: mockEntries, + submittedAt: Date.now(), + deadline: "2026-12-31", + }, +}; + +export const AfterDeadline: Story = { + args: { + entries: mockEntries, + submittedAt: Date.now() - 7 * 24 * 60 * 60 * 1000, + deadline: "2026-01-01", + }, +}; diff --git a/src/components/features/ShiftSubmit/index.stories.tsx b/src/components/features/ShiftSubmit/index.stories.tsx new file mode 100644 index 00000000..8e0731ec --- /dev/null +++ b/src/components/features/ShiftSubmit/index.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ShiftSubmit } from "."; + +const meta = { + title: "features/ShiftSubmit", + component: ShiftSubmit, + parameters: { + layout: "fullscreen", + }, +} satisfies Meta<typeof ShiftSubmit>; + +export default meta; +type Story = StoryObj<typeof meta>; + +const mockShop = { + shopName: "カフェ テスト店", + timeUnit: 30, + openTime: "09:00", + closeTime: "22:00", +}; + +const mockStaff = { _id: "staff_1", displayName: "田中太郎" }; + +const mockRecruitment = { + _id: "recruitment_1", + startDate: "2026-03-02", + endDate: "2026-03-08", + deadline: "2026-02-25", +}; + +const mockFrequentTimePatterns = [ + { startTime: "09:00", endTime: "17:00", count: 5 }, + { startTime: "10:00", endTime: "18:00", count: 3 }, + { startTime: "13:00", endTime: "22:00", count: 2 }, +]; + +const mockPreviousRequest = { + entries: [ + { date: "2026-02-23", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-02-24", isAvailable: false }, + { date: "2026-02-25", isAvailable: true, startTime: "10:00", endTime: "18:00" }, + { date: "2026-02-26", isAvailable: false }, + { date: "2026-02-27", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-02-28", isAvailable: true, startTime: "13:00", endTime: "22:00" }, + { date: "2026-03-01", isAvailable: false }, + ], +}; + +export const Basic: Story = { + args: { + token: "mock-token", + staff: mockStaff, + shop: mockShop, + recruitment: mockRecruitment, + existingRequest: null, + previousRequest: mockPreviousRequest, + frequentTimePatterns: mockFrequentTimePatterns, + }, +}; + +export const Submitted: Story = { + args: { + token: "mock-token", + staff: mockStaff, + shop: mockShop, + recruitment: mockRecruitment, + existingRequest: { + entries: [ + { date: "2026-03-02", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-03-03", isAvailable: false }, + { date: "2026-03-04", isAvailable: true, startTime: "10:00", endTime: "18:00" }, + { date: "2026-03-05", isAvailable: false }, + { date: "2026-03-06", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-03-07", isAvailable: true, startTime: "13:00", endTime: "22:00" }, + { date: "2026-03-08", isAvailable: false }, + ], + submittedAt: Date.now(), + }, + previousRequest: null, + frequentTimePatterns: mockFrequentTimePatterns, + }, +}; From 4955fd1d913cb09605962eb118ca85311ed5775c Mon Sep 17 00:00:00 2001 From: y-natani <yn1323@gmail.com> Date: Sat, 14 Feb 2026 11:10:03 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20Storybook=E3=81=8B=E3=82=89?= =?UTF-8?q?=E6=9C=AA=E3=82=A4=E3=83=B3=E3=82=B9=E3=83=88=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=81=AE@storybook/test=E4=BE=9D=E5=AD=98=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/components/features/ShiftSubmit/ConfirmView.stories.tsx | 5 ++--- src/components/features/ShiftSubmit/DayCard.stories.tsx | 3 +-- .../features/ShiftSubmit/SubmittedView.stories.tsx | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/features/ShiftSubmit/ConfirmView.stories.tsx b/src/components/features/ShiftSubmit/ConfirmView.stories.tsx index b3c46820..02364fdb 100644 --- a/src/components/features/ShiftSubmit/ConfirmView.stories.tsx +++ b/src/components/features/ShiftSubmit/ConfirmView.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "@storybook/test"; import { ConfirmView } from "./ConfirmView"; const meta = { @@ -9,7 +8,7 @@ const meta = { layout: "centered", }, args: { - onBack: fn(), + onBack: () => {}, }, } satisfies Meta<typeof ConfirmView>; @@ -29,6 +28,6 @@ const mockEntries = [ export const Basic: Story = { args: { entries: mockEntries, - onSubmit: fn(() => new Promise((resolve) => setTimeout(resolve, 1000))), + onSubmit: () => new Promise((resolve) => setTimeout(resolve, 1000)), }, }; diff --git a/src/components/features/ShiftSubmit/DayCard.stories.tsx b/src/components/features/ShiftSubmit/DayCard.stories.tsx index 9c97697b..f18f46d6 100644 --- a/src/components/features/ShiftSubmit/DayCard.stories.tsx +++ b/src/components/features/ShiftSubmit/DayCard.stories.tsx @@ -1,6 +1,5 @@ import { Box } from "@chakra-ui/react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "@storybook/test"; import { DayCard } from "./DayCard"; const meta = { @@ -17,7 +16,7 @@ const meta = { ), ], args: { - onUpdate: fn(), + onUpdate: () => {}, }, } satisfies Meta<typeof DayCard>; diff --git a/src/components/features/ShiftSubmit/SubmittedView.stories.tsx b/src/components/features/ShiftSubmit/SubmittedView.stories.tsx index df98362c..8da6775b 100644 --- a/src/components/features/ShiftSubmit/SubmittedView.stories.tsx +++ b/src/components/features/ShiftSubmit/SubmittedView.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "@storybook/test"; import { SubmittedView } from "./SubmittedView"; const meta = { @@ -9,7 +8,7 @@ const meta = { layout: "centered", }, args: { - onEdit: fn(), + onEdit: () => {}, }, } satisfies Meta<typeof SubmittedView>;