From c9726e743c779079cc6e1e1066c99a4ac2b28ac1 Mon Sep 17 00:00:00 2001 From: y-natani Date: Fri, 13 Feb 2026 22:52:34 +0900 Subject: [PATCH 01/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] 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/14] =?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/14] =?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>; From aef9544b768c8ac8b262b1089136c1a2fe987c76 Mon Sep 17 00:00:00 2001 From: y-natani <yn1323@gmail.com> Date: Sun, 1 Mar 2026 21:54:48 +0900 Subject: [PATCH 11/14] add: pen file --- design.pen | 3891 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3891 insertions(+) create mode 100644 design.pen diff --git a/design.pen b/design.pen new file mode 100644 index 00000000..fd20dc50 --- /dev/null +++ b/design.pen @@ -0,0 +1,3891 @@ +{ + "version": "2.8", + "children": [ + { + "type": "frame", + "id": "Qn2LK", + "x": 0, + "y": 0, + "name": "Design System - Chakra UI v3", + "width": 1600, + "fill": "$--gray-50", + "layout": "vertical", + "gap": 64, + "padding": 48, + "children": [ + { + "type": "frame", + "id": "ZUKQf", + "name": "Title", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "fuCDM", + "name": "titleText", + "fill": "$--gray-900", + "content": "Chakra UI v3 Design System", + "fontFamily": "Inter", + "fontSize": 36, + "fontWeight": "700" + }, + { + "type": "text", + "id": "nBHpt", + "name": "subtitleText", + "fill": "$--gray-500", + "content": "YPS Crispy Carnival — シフト管理アプリケーション", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + }, + { + "type": "rectangle", + "id": "713rg", + "name": "divider1", + "fill": "$--border", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "sbd8w", + "name": "Colors", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "text", + "id": "FE5yn", + "name": "colorTitle", + "fill": "$--gray-900", + "content": "Colors", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "vWcms", + "name": "Primary Colors (Teal)", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "RbkP6", + "name": "primaryLabel", + "fill": "$--gray-700", + "content": "Primary (Teal)", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "FP0m8", + "name": "primarySwatches", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "glFo2", + "name": "sw1", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": "$--radius-m", + "id": "dXemU", + "name": "sw1color", + "fill": "$--teal-50", + "width": "fill_container", + "height": 64 + }, + { + "type": "text", + "id": "ABGuF", + "name": "sw1label", + "fill": "$--gray-600", + "content": "Teal 50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iYI1b", + "name": "sw1hex", + "fill": "$--gray-400", + "content": "#E6FFFA", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Y4ED4", + "name": "sw2", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": "$--radius-m", + "id": "wNc44", + "name": "sw2c", + "fill": "$--teal-100", + "width": "fill_container", + "height": 64 + }, + { + "type": "text", + "id": "20o7c", + "name": "sw2l", + "fill": "$--gray-600", + "content": "Teal 100", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Zo56l", + "name": "sw2h", + "fill": "$--gray-400", + "content": "#B2F5EA", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "35HtK", + "name": "sw3", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": "$--radius-m", + "id": "4neA4", + "name": "sw3c", + "fill": "$--teal-500", + "width": "fill_container", + "height": 64 + }, + { + "type": "text", + "id": "mnkBn", + "name": "sw3l", + "fill": "$--gray-600", + "content": "Teal 500", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WpADb", + "name": "sw3h", + "fill": "$--gray-400", + "content": "#319795", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "iFHU7", + "name": "sw4", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": "$--radius-m", + "id": "MqlkA", + "name": "sw4c", + "fill": "$--teal-600", + "width": "fill_container", + "height": 64 + }, + { + "type": "text", + "id": "WWAQ7", + "name": "sw4l", + "fill": "$--gray-600", + "content": "Teal 600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wVKc6", + "name": "sw4h", + "fill": "$--gray-400", + "content": "#2C7A7B", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "85V1j", + "name": "sw5", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": "$--radius-m", + "id": "WA4UA", + "name": "sw5c", + "fill": "$--teal-700", + "width": "fill_container", + "height": 64 + }, + { + "type": "text", + "id": "Zz4TW", + "name": "sw5l", + "fill": "$--gray-600", + "content": "Teal 700", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "EXaZ6", + "name": "sw5h", + "fill": "$--gray-400", + "content": "#285E61", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "6IUX1", + "name": "Gray Scale", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "vnVXq", + "name": "grayLabel", + "fill": "$--gray-700", + "content": "Gray Scale", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "XnoHp", + "name": "graySwatches", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "yYqjm", + "name": "g50", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "40ZZf", + "fill": "$--gray-50", + "width": 120, + "height": 64, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + } + }, + { + "type": "text", + "id": "tR8eA", + "fill": "$--gray-600", + "content": "Gray 50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "1BrvN", + "fill": "$--gray-400", + "content": "#F7FAFC", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MJde8", + "name": "g100", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "yf9jr", + "fill": "$--gray-100", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "IEvQH", + "fill": "$--gray-600", + "content": "Gray 100", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "rptX6", + "fill": "$--gray-400", + "content": "#EDF2F7", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "6P7IQ", + "name": "g200", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "jiQ5e", + "fill": "$--gray-200", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "eXCXj", + "fill": "$--gray-600", + "content": "Gray 200", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "LavIK", + "fill": "$--gray-400", + "content": "#E2E8F0", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "9V60W", + "name": "g400", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "UhR3P", + "fill": "$--gray-400", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "zScLh", + "fill": "$--gray-600", + "content": "Gray 400", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "gjF85", + "fill": "$--gray-400", + "content": "#A0AEC0", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MmUvq", + "name": "g600", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "zNblG", + "fill": "$--gray-600", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "xIWas", + "fill": "$--gray-600", + "content": "Gray 600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "8TMuv", + "fill": "$--gray-400", + "content": "#4A5568", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EL6rj", + "name": "g700", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "Ob6g1", + "fill": "$--gray-700", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "YAE0o", + "fill": "$--gray-600", + "content": "Gray 700", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IeptQ", + "fill": "$--gray-400", + "content": "#2D3748", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xidht", + "name": "g900", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "VCqdO", + "fill": "$--gray-900", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "ulfzs", + "fill": "$--gray-600", + "content": "Gray 900", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "DYkzY", + "fill": "$--gray-400", + "content": "#171923", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "TUtzT", + "name": "Status Colors", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "S7iS5", + "name": "statusLabel", + "fill": "$--gray-700", + "content": "Status Colors", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "XH73r", + "name": "statusSwatches", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "0CMg2", + "name": "ss1", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "AVJCF", + "fill": "$--color-success", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "Ee278", + "fill": "$--gray-600", + "content": "Success", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "af2ER", + "fill": "$--gray-400", + "content": "#38A169", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "x4udD", + "name": "ss2", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "zo8Xu", + "fill": "$--color-warning", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "X1yfr", + "fill": "$--gray-600", + "content": "Warning", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "d4MOS", + "fill": "$--gray-400", + "content": "#DD6B20", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PKT2r", + "name": "ss3", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "zpXhe", + "fill": "$--color-error", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "FRbMs", + "fill": "$--gray-600", + "content": "Error", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "muh5M", + "fill": "$--gray-400", + "content": "#E53E3E", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "UFBgf", + "name": "ss4", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "IFntd", + "fill": "$--color-info", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "UNbXL", + "fill": "$--gray-600", + "content": "Info", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "swYYd", + "fill": "$--gray-400", + "content": "#3182CE", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "pIcjm", + "name": "ss5", + "width": 120, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "OdMDz", + "fill": "$--destructive", + "width": 120, + "height": 64 + }, + { + "type": "text", + "id": "eeVAG", + "fill": "$--gray-600", + "content": "Destructive", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "dNYtw", + "fill": "$--gray-400", + "content": "#E53E3E", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "SSKfD", + "name": "divider2", + "fill": "$--border", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "uevFR", + "name": "Typography", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "text", + "id": "ZI7Uu", + "name": "typoTitle", + "fill": "$--gray-900", + "content": "Typography", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "6QMfe", + "name": "typoSamples", + "width": "fill_container", + "fill": "$--card", + "cornerRadius": "$--radius-m", + "layout": "vertical", + "gap": 16, + "padding": 32, + "children": [ + { + "type": "frame", + "id": "KtI9g", + "name": "t2xl", + "width": "fill_container", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "3eGAl", + "name": "t2xlLabel", + "fill": "$--gray-400", + "textGrowth": "fixed-width", + "width": 200, + "content": "2xl / 24px / Bold", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "5aisZ", + "name": "t2xlSample", + "fill": "$--gray-900", + "content": "ページタイトル Page Title", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "J2d50", + "name": "txl", + "width": "fill_container", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "baiTS", + "name": "txlLabel", + "fill": "$--gray-400", + "textGrowth": "fixed-width", + "width": 200, + "content": "xl / 20px / Bold", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iwwXG", + "name": "txlSample", + "fill": "$--gray-900", + "content": "セクション見出し Section Heading", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "u8Cgm", + "name": "tlg", + "width": "fill_container", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IAYiy", + "name": "tlgLabel", + "fill": "$--gray-400", + "textGrowth": "fixed-width", + "width": 200, + "content": "lg / 18px / Semibold", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "S16VQ", + "name": "tlgSample", + "fill": "$--gray-900", + "content": "カードタイトル Card Title", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "KS9xM", + "name": "tmd", + "width": "fill_container", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "gX6qm", + "name": "tmdLabel", + "fill": "$--gray-400", + "textGrowth": "fixed-width", + "width": 200, + "content": "md / 16px / Normal", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "td1x2", + "name": "tmdSample", + "fill": "$--gray-700", + "content": "本文テキスト Body text for descriptions and content.", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "vSAc5", + "name": "tsm", + "width": "fill_container", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JsFEO", + "name": "tsmLabel", + "fill": "$--gray-400", + "textGrowth": "fixed-width", + "width": 200, + "content": "sm / 14px / Normal", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "GvlpG", + "name": "tsmSample", + "fill": "$--gray-600", + "content": "ラベル・補足説明 Labels and helper text", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QPI8E", + "name": "txs", + "width": "fill_container", + "gap": 24, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "8tZL8", + "name": "txsLabel", + "fill": "$--gray-400", + "textGrowth": "fixed-width", + "width": 200, + "content": "xs / 12px / Normal", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "oPveO", + "name": "txsSample", + "fill": "$--gray-500", + "content": "キャプション・注釈 Caption and annotations", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "jRSL4", + "name": "divider3", + "fill": "$--border", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "Z78uH", + "name": "Components", + "width": "fill_container", + "layout": "vertical", + "gap": 32, + "children": [ + { + "type": "text", + "id": "UPvzu", + "name": "compTitle", + "fill": "$--gray-900", + "content": "Components", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "wVyOF", + "name": "Buttons", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "so6oz", + "name": "btnTitle", + "fill": "$--gray-700", + "content": "Button", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "6Utr4", + "name": "btnRow", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qAHH3", + "name": "Button/Solid", + "reusable": true, + "height": 40, + "fill": "$--primary", + "cornerRadius": "$--radius-m", + "gap": 8, + "padding": [ + 0, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "0pyXh", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--primary-foreground" + }, + { + "type": "text", + "id": "44KzN", + "fill": "$--primary-foreground", + "content": "ボタン", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "wZllY", + "name": "Button/Outline", + "reusable": true, + "height": 40, + "fill": "$--background", + "cornerRadius": "$--radius-m", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "gap": 8, + "padding": [ + 0, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "W3DQt", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + }, + { + "type": "text", + "id": "wCWtb", + "fill": "$--gray-700", + "content": "ボタン", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "Xuu4S", + "name": "Button/Ghost", + "reusable": true, + "height": 40, + "cornerRadius": "$--radius-m", + "gap": 8, + "padding": [ + 0, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Srzui", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--gray-600" + }, + { + "type": "text", + "id": "2dqAH", + "fill": "$--gray-600", + "content": "ボタン", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "HLeuw", + "name": "Button/Destructive", + "reusable": true, + "height": 40, + "fill": "$--destructive", + "cornerRadius": "$--radius-m", + "gap": 8, + "padding": [ + 0, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QXU1x", + "width": 16, + "height": 16, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$--destructive-foreground" + }, + { + "type": "text", + "id": "LNams", + "fill": "$--destructive-foreground", + "content": "削除", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "k706m", + "name": "Inputs", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "55vUf", + "name": "inputTitle", + "fill": "$--gray-700", + "content": "Input", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "2Wq9T", + "name": "inputRow", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "NJ4vQ", + "name": "Input/Default", + "reusable": true, + "width": 280, + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "1zWU0", + "fill": "$--gray-700", + "content": "ラベル", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "VDHSM", + "width": "fill_container", + "height": 40, + "fill": "$--background", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--input-border" + }, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "6mDjD", + "fill": "$--muted-foreground", + "content": "入力してください", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "mJGXQ", + "fill": "$--gray-400", + "content": "補足説明テキスト", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "4lBjO", + "name": "Input/Error", + "reusable": true, + "width": 280, + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "aqFPw", + "fill": "$--gray-700", + "content": "ラベル", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "D3tmc", + "width": "fill_container", + "height": 40, + "fill": "$--color-error-subtle", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--color-error" + }, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zHfwB", + "fill": "$--gray-900", + "content": "不正な値", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "qsqUd", + "fill": "$--color-error", + "content": "必須項目です", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IrLWW", + "name": "Input/Filled", + "reusable": true, + "width": 280, + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "TD8CN", + "fill": "$--gray-700", + "content": "ラベル", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "7EQqr", + "width": "fill_container", + "height": 40, + "fill": "$--background", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--primary" + }, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Ms7oB", + "fill": "$--gray-900", + "content": "入力済みの値", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wegAR", + "name": "Badges", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "v3JMe", + "name": "badgeTitle", + "fill": "$--gray-700", + "content": "Badge", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "dgAFH", + "name": "badgeRow", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "AZjIJ", + "name": "Badge/Primary", + "reusable": true, + "height": 24, + "fill": "$--primary-subtle", + "cornerRadius": "$--radius-pill", + "padding": [ + 0, + 10 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UA7OJ", + "fill": "$--primary", + "content": "アクティブ", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "nfVl6", + "name": "Badge/Success", + "reusable": true, + "height": 24, + "fill": "$--color-success-subtle", + "cornerRadius": "$--radius-pill", + "padding": [ + 0, + 10 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mnAwX", + "fill": "$--color-success", + "content": "成功", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "9thE2", + "name": "Badge/Warning", + "reusable": true, + "height": 24, + "fill": "$--color-warning-subtle", + "cornerRadius": "$--radius-pill", + "padding": [ + 0, + 10 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wSEwg", + "fill": "$--color-warning", + "content": "保留", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "f4JWw", + "name": "Badge/Error", + "reusable": true, + "height": 24, + "fill": "$--color-error-subtle", + "cornerRadius": "$--radius-pill", + "padding": [ + 0, + 10 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "6tDKo", + "fill": "$--color-error", + "content": "エラー", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "Z1h3L", + "name": "Badge/Info", + "reusable": true, + "height": 24, + "fill": "$--color-info-subtle", + "cornerRadius": "$--radius-pill", + "padding": [ + 0, + 10 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "irGZk", + "fill": "$--color-info", + "content": "情報", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "fqon2", + "name": "Badge/Neutral", + "reusable": true, + "height": 24, + "fill": "$--gray-100", + "cornerRadius": "$--radius-pill", + "padding": [ + 0, + 10 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nw87a", + "fill": "$--gray-600", + "content": "下書き", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pUy46", + "name": "Cards", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "qDexl", + "name": "cardTitle", + "fill": "$--gray-700", + "content": "Card", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "sKpE0", + "name": "cardRow", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "VZsbc", + "name": "Card/Basic", + "reusable": true, + "width": 360, + "fill": "$--card", + "cornerRadius": "$--radius-m", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000D", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "FXizp", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": 24, + "children": [ + { + "type": "text", + "id": "nRvpJ", + "fill": "$--gray-900", + "content": "カードタイトル", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "pEcYA", + "fill": "$--gray-500", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "カードの説明テキストがここに入ります。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "gSe9R", + "name": "Card/WithActions", + "reusable": true, + "width": 360, + "fill": "$--card", + "cornerRadius": "$--radius-m", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000D", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "rjXi8", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": 24, + "children": [ + { + "type": "text", + "id": "SYTuu", + "fill": "$--gray-900", + "content": "カードタイトル", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "mq0TE", + "fill": "$--gray-500", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "カードの説明テキストがここに入ります。アクションボタン付き。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "nNTu0", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 16, + 24 + ], + "justifyContent": "end", + "children": [ + { + "id": "VjuTL", + "type": "ref", + "ref": "wZllY", + "name": "cancelBtn", + "descendants": { + "W3DQt": { + "enabled": false + }, + "wCWtb": { + "content": "キャンセル" + } + } + }, + { + "id": "AUKMn", + "type": "ref", + "ref": "qAHH3", + "name": "submitBtn", + "descendants": { + "0pyXh": { + "enabled": false + }, + "44KzN": { + "content": "保存" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "NtjSj", + "name": "FormCard", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "WkZ36", + "name": "formCardTitle", + "fill": "$--gray-700", + "content": "FormCard", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "cfUs4", + "name": "FormCard/Default", + "reusable": true, + "width": "fill_container", + "fill": "$--card", + "cornerRadius": "$--radius-m", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000D", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "W1Ioj", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 16, + 24, + 24, + 24 + ], + "children": [ + { + "type": "frame", + "id": "t0I42", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "48b3G", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "NVaTq", + "width": 16, + "height": 16, + "iconFontName": "store", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + }, + { + "type": "text", + "id": "nFvxI", + "fill": "$--gray-900", + "content": "基本情報", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "DUQnL", + "slot": [], + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "id": "n2bIz", + "type": "ref", + "ref": "NJ4vQ", + "width": "fill_container", + "name": "input1", + "descendants": { + "1zWU0": { + "content": "店舗名" + }, + "6mDjD": { + "content": "店舗名を入力" + }, + "mJGXQ": { + "content": "2〜50文字で入力してください" + } + } + }, + { + "id": "5fJ0Z", + "type": "ref", + "ref": "NJ4vQ", + "width": "fill_container", + "name": "input2", + "descendants": { + "1zWU0": { + "content": "メールアドレス" + }, + "6mDjD": { + "content": "example@mail.com" + }, + "mJGXQ": { + "content": "" + } + } + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ebFl5", + "name": "Select", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "g1X0z", + "name": "selectTitle", + "fill": "$--gray-700", + "content": "Select", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "jcNaY", + "name": "selectRow", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "h7sJ3", + "name": "Select/Default", + "reusable": true, + "width": 280, + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "YToyK", + "fill": "$--gray-700", + "content": "ラベル", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "4Z2KZ", + "width": "fill_container", + "height": 40, + "fill": "$--background", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--input-border" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OKXZb", + "fill": "$--muted-foreground", + "content": "選択してください", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "4JaYc", + "width": 16, + "height": 16, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--gray-400" + } + ] + } + ] + }, + { + "type": "frame", + "id": "o6Cfi", + "name": "Select/Filled", + "reusable": true, + "width": 280, + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "rZLiA", + "fill": "$--gray-700", + "content": "ラベル", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "bBAmj", + "width": "fill_container", + "height": 40, + "fill": "$--background", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--input-border" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "aCajh", + "fill": "$--gray-900", + "content": "30分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "MHehY", + "width": 16, + "height": 16, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--gray-400" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "OJbDa", + "name": "Dialog", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "GQ9nl", + "name": "dialogTitle", + "fill": "$--gray-700", + "content": "Dialog", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "K7M5q", + "name": "Dialog/Default", + "reusable": true, + "width": 480, + "fill": "$--card", + "cornerRadius": "$--radius-lg", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000026", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 24 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "GNQNU", + "width": "fill_container", + "padding": [ + 20, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "0KzyV", + "fill": "$--gray-900", + "content": "ダイアログタイトル", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "nUZd1", + "width": 20, + "height": 20, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--gray-400" + } + ] + }, + { + "type": "frame", + "id": "iYKbp", + "slot": [], + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 24, + 24, + 24 + ], + "children": [ + { + "type": "text", + "id": "oSmSO", + "fill": "$--gray-600", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "ダイアログの本文がここに入ります。確認やフォームの入力などを配置します。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "f5MRR", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 16, + 24 + ], + "justifyContent": "end", + "children": [ + { + "id": "qx60M", + "type": "ref", + "ref": "wZllY", + "name": "dCancelBtn", + "descendants": { + "W3DQt": { + "enabled": false + }, + "wCWtb": { + "content": "キャンセル" + } + } + }, + { + "id": "Lbsj6", + "type": "ref", + "ref": "qAHH3", + "name": "dSubmitBtn", + "descendants": { + "0pyXh": { + "enabled": false + }, + "44KzN": { + "content": "送信" + } + } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "2wwVf", + "name": "Toast", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "yuOBy", + "name": "toastTitle", + "fill": "$--gray-700", + "content": "Toast", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "rFxU7", + "name": "toastRow", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "7zQAB", + "name": "Toast/Success", + "reusable": true, + "width": 320, + "fill": "$--card", + "cornerRadius": "$--radius-m", + "stroke": { + "align": "inside", + "thickness": { + "left": 3 + }, + "fill": "$--color-success" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 12 + }, + "gap": 12, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "zRuYI", + "width": 20, + "height": 20, + "iconFontName": "check-circle", + "iconFontFamily": "lucide", + "fill": "$--color-success" + }, + { + "type": "frame", + "id": "ONnHZ", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "mV8hI", + "fill": "$--gray-900", + "content": "保存しました", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "LQprs", + "fill": "$--gray-500", + "content": "店舗情報を更新しました。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "IOn78", + "name": "Toast/Error", + "reusable": true, + "width": 320, + "fill": "$--card", + "cornerRadius": "$--radius-m", + "stroke": { + "align": "inside", + "thickness": { + "left": 3 + }, + "fill": "$--color-error" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 12 + }, + "gap": 12, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "awuks", + "width": 20, + "height": 20, + "iconFontName": "alert-circle", + "iconFontFamily": "lucide", + "fill": "$--color-error" + }, + { + "type": "frame", + "id": "7nfjK", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "GVYL5", + "fill": "$--gray-900", + "content": "エラー", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "89V2L", + "fill": "$--gray-500", + "content": "保存に失敗しました。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "KXErL", + "name": "EmptyState & Loading", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "GYkDd", + "name": "emptyTitle", + "fill": "$--gray-700", + "content": "EmptyState / LoadingState", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Vcwut", + "name": "emptyRow", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "UgyK5", + "name": "EmptyState/Default", + "reusable": true, + "width": 400, + "height": 300, + "fill": "$--card", + "cornerRadius": "$--radius-m", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 16, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "PUGD9", + "width": 48, + "height": 48, + "iconFontName": "inbox", + "iconFontFamily": "lucide", + "fill": "$--gray-300" + }, + { + "type": "text", + "id": "uD6dU", + "fill": "$--gray-700", + "content": "データがありません", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "4yvUH", + "fill": "$--gray-400", + "content": "新しいデータを追加してください。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "AoG40", + "name": "LoadingState/Default", + "reusable": true, + "width": 400, + "height": 300, + "fill": "$--card", + "cornerRadius": "$--radius-m", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 16, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "ssDkT", + "sweepAngle": 270, + "width": 40, + "height": 40, + "stroke": { + "align": "center", + "thickness": 4, + "fill": "$--primary" + } + }, + { + "type": "text", + "id": "CU9Cm", + "fill": "$--gray-500", + "content": "読み込み中...", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "WxLH8", + "name": "divider4", + "fill": "$--border", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "lsPg0", + "name": "Spacing & Radius", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "text", + "id": "O3rBo", + "name": "spacingTitle", + "fill": "$--gray-900", + "content": "Spacing & Border Radius", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "HQdcP", + "name": "Spacing", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "DZ05N", + "name": "spacingLabel", + "fill": "$--gray-700", + "content": "Spacing Scale", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "srFDN", + "name": "spacingItems", + "width": "fill_container", + "gap": 16, + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "P9wbU", + "name": "sp1", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "eGBO0", + "fill": "$--primary", + "width": 4, + "height": 40 + }, + { + "type": "text", + "id": "A2TQ9", + "fill": "$--gray-600", + "content": "4px", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "N30aM", + "name": "sp2", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "Xp1DX", + "fill": "$--primary", + "width": 8, + "height": 40 + }, + { + "type": "text", + "id": "bTFHw", + "fill": "$--gray-600", + "content": "8px", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "0RskW", + "name": "sp3", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "ALwo8", + "fill": "$--primary", + "width": 12, + "height": 40 + }, + { + "type": "text", + "id": "rHbtO", + "fill": "$--gray-600", + "content": "12px", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Sj11u", + "name": "sp4", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "OjfDj", + "fill": "$--primary", + "width": 16, + "height": 40 + }, + { + "type": "text", + "id": "Qnhav", + "fill": "$--gray-600", + "content": "16px", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "tzfOl", + "name": "sp5", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "hAJId", + "fill": "$--primary", + "width": 24, + "height": 40 + }, + { + "type": "text", + "id": "YznjO", + "fill": "$--gray-600", + "content": "24px", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lwQ9X", + "name": "sp6", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "5bVJZ", + "fill": "$--primary", + "width": 32, + "height": 40 + }, + { + "type": "text", + "id": "bNpfX", + "fill": "$--gray-600", + "content": "32px", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "D5VPa", + "name": "Border Radius", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "NAIyl", + "name": "radiusLabel", + "fill": "$--gray-700", + "content": "Border Radius", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "XaN4L", + "name": "radiusItems", + "width": "fill_container", + "gap": 16, + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "pgI6j", + "name": "r1", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "id": "KSdFy", + "fill": "$--primary-subtle", + "width": 64, + "height": 64, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$--primary" + } + }, + { + "type": "text", + "id": "6o1Tx", + "fill": "$--gray-600", + "content": "none (0)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oAOcQ", + "name": "r2", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 4, + "id": "cjvkr", + "fill": "$--primary-subtle", + "width": 64, + "height": 64, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$--primary" + } + }, + { + "type": "text", + "id": "mRVOr", + "fill": "$--gray-600", + "content": "sm (4px)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "7m173", + "name": "r3", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 8, + "id": "uzF07", + "fill": "$--primary-subtle", + "width": 64, + "height": 64, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$--primary" + } + }, + { + "type": "text", + "id": "A2GPy", + "fill": "$--gray-600", + "content": "md (8px)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "WmIls", + "name": "r4", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 12, + "id": "x1vtO", + "fill": "$--primary-subtle", + "width": 64, + "height": 64, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$--primary" + } + }, + { + "type": "text", + "id": "BNhCj", + "fill": "$--gray-600", + "content": "lg (12px)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "4jCor", + "name": "r5", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 16, + "id": "knd11", + "fill": "$--primary-subtle", + "width": 64, + "height": 64, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$--primary" + } + }, + { + "type": "text", + "id": "vtbcw", + "fill": "$--gray-600", + "content": "xl (16px)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Lxwai", + "name": "r6", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 9999, + "id": "f5t9w", + "fill": "$--primary-subtle", + "width": 64, + "height": 64, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$--primary" + } + }, + { + "type": "text", + "id": "h3fp1", + "fill": "$--gray-600", + "content": "pill (full)", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "dRot4", + "name": "divider5", + "fill": "$--border", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "lldWu", + "name": "Icons (Lucide)", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "text", + "id": "KXkPn", + "name": "iconTitle", + "fill": "$--gray-900", + "content": "Icons (Lucide)", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "cTpXH", + "name": "iconGrid", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "9Gv8h", + "name": "ic1", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "4IMrB", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "t3OvF", + "width": 24, + "height": 24, + "iconFontName": "store", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "VurMS", + "fill": "$--gray-500", + "content": "store", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "X8C6O", + "name": "ic2", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gNMC9", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "P0njt", + "width": 24, + "height": 24, + "iconFontName": "users", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "Fnyhk", + "fill": "$--gray-500", + "content": "users", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GsD9W", + "name": "ic3", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "uvjr9", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "WOUIR", + "width": 24, + "height": 24, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "PZ2hD", + "fill": "$--gray-500", + "content": "calendar", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "N0X4a", + "name": "ic4", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "9gkPI", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QRtDP", + "width": 24, + "height": 24, + "iconFontName": "settings", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "OwYIm", + "fill": "$--gray-500", + "content": "settings", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "L8ryv", + "name": "ic5", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "D5vjX", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "mDA3E", + "width": 24, + "height": 24, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "0HKNb", + "fill": "$--gray-500", + "content": "plus", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jwgIG", + "name": "ic6", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "JnkeT", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "gP53m", + "width": 24, + "height": 24, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "BIwit", + "fill": "$--gray-500", + "content": "trash-2", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "McwEC", + "name": "ic7", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "UZU7z", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Eyyct", + "width": 24, + "height": 24, + "iconFontName": "edit", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "Oofuc", + "fill": "$--gray-500", + "content": "edit", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IdyKq", + "name": "ic8", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "0PDtQ", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "j1nFi", + "width": 24, + "height": 24, + "iconFontName": "search", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "VUStC", + "fill": "$--gray-500", + "content": "search", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "fs4ja", + "name": "iconGrid2", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "FaPdZ", + "name": "ic9", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DFRBo", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rVfPd", + "width": 24, + "height": 24, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "dXY75", + "fill": "$--gray-500", + "content": "chevron-left", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "6YP56", + "name": "ic10", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "oROgp", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Lkme3", + "width": 24, + "height": 24, + "iconFontName": "check", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "IZ9Dh", + "fill": "$--gray-500", + "content": "check", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wWUrd", + "name": "ic11", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "oZZfL", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "4QlQi", + "width": 24, + "height": 24, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "NAHEp", + "fill": "$--gray-500", + "content": "x", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Z4HPa", + "name": "ic12", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "EEvgA", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "gVYXm", + "width": 24, + "height": 24, + "iconFontName": "mail", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "m2Pqo", + "fill": "$--gray-500", + "content": "mail", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "7EChC", + "name": "ic13", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "mmaOW", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "PpvWC", + "width": 24, + "height": 24, + "iconFontName": "clock", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "McPsn", + "fill": "$--gray-500", + "content": "clock", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gUruS", + "name": "ic14", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "I2u4m", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "DWA0s", + "width": 24, + "height": 24, + "iconFontName": "home", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "WBV3q", + "fill": "$--gray-500", + "content": "home", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dJcMR", + "name": "ic15", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "TmyUl", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "pN5L1", + "width": 24, + "height": 24, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "bhxn5", + "fill": "$--gray-500", + "content": "bell", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "VP6n5", + "name": "ic16", + "width": 80, + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "JNM77", + "width": 48, + "height": 48, + "fill": "$--gray-50", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "opf1r", + "width": 24, + "height": 24, + "iconFontName": "user", + "iconFontFamily": "lucide", + "fill": "$--gray-700" + } + ] + }, + { + "type": "text", + "id": "SoZzi", + "fill": "$--gray-500", + "content": "user", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ], + "variables": { + "--background": { + "type": "color", + "value": "#FFFFFF" + }, + "--border": { + "type": "color", + "value": "#E2E8F0" + }, + "--card": { + "type": "color", + "value": "#FFFFFF" + }, + "--card-foreground": { + "type": "color", + "value": "#171923" + }, + "--color-error": { + "type": "color", + "value": "#E53E3E" + }, + "--color-error-foreground": { + "type": "color", + "value": "#FFFFFF" + }, + "--color-error-subtle": { + "type": "color", + "value": "#FFF5F5" + }, + "--color-info": { + "type": "color", + "value": "#3182CE" + }, + "--color-info-foreground": { + "type": "color", + "value": "#FFFFFF" + }, + "--color-info-subtle": { + "type": "color", + "value": "#EBF8FF" + }, + "--color-success": { + "type": "color", + "value": "#38A169" + }, + "--color-success-foreground": { + "type": "color", + "value": "#FFFFFF" + }, + "--color-success-subtle": { + "type": "color", + "value": "#F0FFF4" + }, + "--color-warning": { + "type": "color", + "value": "#DD6B20" + }, + "--color-warning-foreground": { + "type": "color", + "value": "#FFFFFF" + }, + "--color-warning-subtle": { + "type": "color", + "value": "#FFFAF0" + }, + "--destructive": { + "type": "color", + "value": "#E53E3E" + }, + "--destructive-foreground": { + "type": "color", + "value": "#FFFFFF" + }, + "--font-primary": { + "type": "string", + "value": "Inter" + }, + "--font-secondary": { + "type": "string", + "value": "Inter" + }, + "--foreground": { + "type": "color", + "value": "#171923" + }, + "--gray-100": { + "type": "color", + "value": "#EDF2F7" + }, + "--gray-200": { + "type": "color", + "value": "#E2E8F0" + }, + "--gray-300": { + "type": "color", + "value": "#CBD5E0" + }, + "--gray-400": { + "type": "color", + "value": "#A0AEC0" + }, + "--gray-50": { + "type": "color", + "value": "#F7FAFC" + }, + "--gray-500": { + "type": "color", + "value": "#718096" + }, + "--gray-600": { + "type": "color", + "value": "#4A5568" + }, + "--gray-700": { + "type": "color", + "value": "#2D3748" + }, + "--gray-800": { + "type": "color", + "value": "#1A202C" + }, + "--gray-900": { + "type": "color", + "value": "#171923" + }, + "--input-border": { + "type": "color", + "value": "#CBD5E0" + }, + "--muted": { + "type": "color", + "value": "#F7FAFC" + }, + "--muted-foreground": { + "type": "color", + "value": "#A0AEC0" + }, + "--primary": { + "type": "color", + "value": "#319795" + }, + "--primary-foreground": { + "type": "color", + "value": "#FFFFFF" + }, + "--primary-hover": { + "type": "color", + "value": "#2C7A7B" + }, + "--primary-subtle": { + "type": "color", + "value": "#E6FFFA" + }, + "--radius-lg": { + "type": "number", + "value": 12 + }, + "--radius-m": { + "type": "number", + "value": 8 + }, + "--radius-none": { + "type": "number", + "value": 0 + }, + "--radius-pill": { + "type": "number", + "value": 9999 + }, + "--radius-sm": { + "type": "number", + "value": 4 + }, + "--radius-xl": { + "type": "number", + "value": 16 + }, + "--secondary": { + "type": "color", + "value": "#EDF2F7" + }, + "--secondary-foreground": { + "type": "color", + "value": "#2D3748" + }, + "--shadow-sm": { + "type": "string", + "value": "0 1px 2px rgba(0,0,0,0.05)" + }, + "--spacing-1": { + "type": "number", + "value": 4 + }, + "--spacing-2": { + "type": "number", + "value": 8 + }, + "--spacing-3": { + "type": "number", + "value": 12 + }, + "--spacing-4": { + "type": "number", + "value": 16 + }, + "--spacing-5": { + "type": "number", + "value": 20 + }, + "--spacing-6": { + "type": "number", + "value": 24 + }, + "--spacing-8": { + "type": "number", + "value": 32 + }, + "--teal-100": { + "type": "color", + "value": "#B2F5EA" + }, + "--teal-50": { + "type": "color", + "value": "#E6FFFA" + }, + "--teal-500": { + "type": "color", + "value": "#319795" + }, + "--teal-600": { + "type": "color", + "value": "#2C7A7B" + }, + "--teal-700": { + "type": "color", + "value": "#285E61" + } + } +} \ No newline at end of file From 4b1b9761793c518f9db13a73481e65f33ca72f8a Mon Sep 17 00:00:00 2001 From: y-natani <yn1323@gmail.com> Date: Mon, 2 Mar 2026 00:09:48 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=E3=82=B7=E3=83=95=E3=83=88?= =?UTF-8?q?=E7=B7=A8=E9=9B=86=E3=83=BB=E7=A2=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 321 +++++--------- CLAUDE.team.md | 3 - convex/_generated/api.d.ts | 4 + convex/constants.ts | 14 + convex/email/actions.ts | 72 ++- convex/helpers.ts | 18 +- convex/position/mutations.ts | 23 +- convex/recruitment/mutations.ts | 111 ++++- convex/recruitment/queries.ts | 25 ++ convex/schema.ts | 39 +- convex/shiftAssignment/mutations.ts | 51 +++ convex/shiftAssignment/queries.ts | 30 ++ convex/shiftRequest/mutations.ts | 22 +- convex/shiftRequest/queries.ts | 174 +++++--- doc/ARCHITECTURE.md | 16 +- doc/claude/basic.md | 156 ------- doc/claude/self.md | 414 ------------------ doc/claude/soul.md | 5 + ...25\343\203\210\347\256\241\347\220\206.md" | 136 +++++- ...47\343\203\263\347\256\241\347\220\206.md" | 18 +- ...57\343\203\225\343\203\255\343\203\274.md" | 211 +++++++++ .../Shift/RecruitmentDetail/index.stories.tsx | 17 +- .../Shift/RecruitmentDetail/index.tsx | 118 ++++- .../features/Shift/RecruitmentList/index.tsx | 2 +- .../features/Shift/ShiftConfirm/index.tsx | 254 +++++++++++ .../features/Shift/ShiftForm/index.tsx | 16 +- .../utils/transformRecruitmentData.test.ts | 191 ++++++++ .../Shift/utils/transformRecruitmentData.ts | 168 +++++++ .../features/ShiftSubmit/ConfirmedView.tsx | 102 +++++ .../features/Shop/PositionManager/index.tsx | 43 +- src/components/pages/ShiftSubmit/index.tsx | 23 + .../Shops/RecruitmentDetailPage/index.tsx | 116 +++-- .../pages/Shops/ShiftConfirmPage/index.tsx | 172 +++----- src/components/ui/ColorPicker/index.tsx | 37 ++ 34 files changed, 2037 insertions(+), 1085 deletions(-) delete mode 100644 CLAUDE.team.md create mode 100644 convex/shiftAssignment/mutations.ts create mode 100644 convex/shiftAssignment/queries.ts delete mode 100644 doc/claude/basic.md delete mode 100644 doc/claude/self.md create mode 100644 doc/claude/soul.md create mode 100644 "doc/plans/2026-03-01_\343\202\267\343\203\225\343\203\210\347\256\241\347\220\206_\347\256\241\347\220\206\350\200\205\343\203\257\343\203\274\343\202\257\343\203\225\343\203\255\343\203\274.md" create mode 100644 src/components/features/Shift/ShiftConfirm/index.tsx create mode 100644 src/components/features/Shift/utils/transformRecruitmentData.test.ts create mode 100644 src/components/features/Shift/utils/transformRecruitmentData.ts create mode 100644 src/components/features/ShiftSubmit/ConfirmedView.tsx create mode 100644 src/components/ui/ColorPicker/index.tsx diff --git a/CLAUDE.md b/CLAUDE.md index e68b28ad..acb24d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,205 +1,124 @@ # CLAUDE.md -このファイルは、Claude Code (claude.ai/code) がこのリポジトリで作業する際のガイダンスを提供します。 - -## 参照ドキュメント -- @doc/claude/basic.md -- @doc/claude/self.md -- @doc/ARCHITECTURE.md -- @doc/INDEX.md - -## 🚨 核心制約 - -### NEVER(絶対禁止) -- NEVER: data-testidをテストで使用 - -### YOU MUST(必須事項) -- YOU MUST: 質問をする場合は、1つずつ質問してください。チャットなので。。。 -- YOU MUST: ユーザーの指示で不明瞭な箇所は必ず聞き返してください。これすごく重要!!ぜひ一緒に仕様をつくっていきましょう! -- YOU MUST: コードの確認は下記のコマンドを利用してください。 - - `pnpm format` - Biomeフォーマット(作業完了前に必ず実行) - - `pnpm lint` - Biomeリンティング(作業完了前に必ず実行) - - `pnpm type-check` - TypeScript型チェック(作業完了前に必ず実行) - - `pnpm test` - Vitestテスト(ロジック、UI修正時のみ) - - `pnpm e2e` - Playwright E2Eテスト(E2E作成・修正時のみ) -- YOU MUST: 機能実装後(新機能追加、API追加、画面追加、スキーマ変更等)はドキュメント更新を確認してください。スキル `/doc-update` を使用。 -- YOU MUST: ワイヤーはASCIIで表示してください - -### IMPORTANT(重要事項) -- IMPORTANT: Chakra UI v3 Modern API準拠 -- IMPORTANT: 3ステップ以上でTodoWrite使用 -- IMPORTANT: 作業開始前に計画することを好む -- IMPORTANT: バレルエクスポート禁止 -- IMPORTANT: utf-8を利用すること -- IMPORTANT: TypeScriptの型は推論を利用すること -- IMPORTANT: 定数化は2箇所以上で利用しているときのみとする -- IMPORTANT: 開発者の指摘が誤っているときは、根拠を示して反論すること -- IMPORTANT: UIX/UX方向性を決めるときは、skill frontend-design, skill ux-designerを利用して検討すること -- IMPORTANT: E2Eテスト実装時はskill playwright-skillを利用すること(E2Eテストは現段階では不要) - - ブラウザ起動後のログインはこちらで行うので、playwright mcp利用時は一声かけてください -- IMPORTANT: リリース前につきソースコードの修正時のマイグレーション考慮は不要。でも警告くらいは出してね -- IMPORTANT: 3ステップ以上の実装計画を立てたら、`doc/plans/yyyy-mm-dd_<機能名>.md` に保存すること - - コンテキスト圧縮後も参照できるようにするため - - skill save-plan を使用、または直接 Write で保存 - - コンテキスト圧縮から復帰後はドキュメントを見て、実装計画を再度考えること -- IMPORTANT: コンテキスト圧縮からの復帰時・セッション再開時は、まず `doc/plans/` を確認すること - - 作業中の計画ファイルがあれば、必ず読み込んでから作業を再開 - - 「8. 現在の進捗」セクションを確認し、次にやるべきことを把握 - - 計画ファイルがなければ、ユーザーに状況を確認 - -## 開発コマンド - -### コア開発 -- `pnpm dev` - Vite開発サーバーの起動(ポート3000) -- `pnpm build` - Viteプロダクションビルド + TypeScript型チェック -- `pnpm start` - 開発サーバーの起動(devと同じ) -- `pnpm serve` - プロダクションビルドのプレビュー - -### コード品質・型チェック -- `pnpm lint` - Biomeリンティングの実行(チェックのみ) -- `pnpm format` - Biomeによるコードフォーマット -- `pnpm type-check` - TypeScript型チェックの実行 - -### テスト -- `pnpm test` - 全てのVitestテストの実行 -- `pnpm test:logic` - ロジック・ユニットテストのみ実行(./src/**/*.test.ts) -- `pnpm test:ui` - StorybookによるUI・コンポーネントテスト(ブラウザモード) -- `pnpm e2e` - Playwright E2Eテストの実行 -- `pnpm e2e:ui` - Playwright UIでE2Eテストを実行 -- `pnpm e2e:debug` - E2Eテストのデバッグ -- `pnpm e2e:report` - Playwrightテストレポートの表示 -- `pnpm e2e:codegen` - E2Eテストコードの生成 - -### ドキュメント・コンポーネント -- `pnpm storybook` - Storybook開発サーバーをポート6006で起動 -- `pnpm storybook:build` - Storybookのプロダクションビルド -- `pnpm scaffdog` - コード雛形の生成 - -### Convex(バックエンド) -- `pnpm convex:dev` - Convex開発モード起動 -- `pnpm convex:import` - データインポート -- `pnpm convex:export` - データエクスポート - -## アーキテクチャ概要 +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## プロジェクト概要 + +店舗スタッフのシフト管理SaaSアプリケーション。React + Vite + Convex構成。 + +## コマンド + +```bash +pnpm dev # 開発サーバー起動 (port 3000) +pnpm build # ビルド (vite build && tsc) +pnpm lint # Biomeでlint +pnpm format # Biomeでフォーマット (--write) +pnpm type-check # TypeScriptの型チェック +pnpm test # 全テスト (vitest: logic + ui) +pnpm test:logic # ロジックテストのみ (src/**/*.test.ts) +pnpm test:ui # UIテスト (Storybook + Playwright browser) +pnpm e2e # E2Eテスト (Playwright) +pnpm storybook # Storybook起動 (port 6006) +pnpm scaffdog # コンポーネントの雛形生成 +pnpm convex:dev # Convex開発サーバー +``` + +### 単一テスト実行 + +```bash +pnpm vitest --project=logic src/path/to/file.test.ts # 特定ファイル +pnpm vitest --project=logic -t "テスト名" # 特定テスト名 +pnpm e2e e2e/path/to/file.spec.ts # 特定E2Eファイル +``` + +## アーキテクチャ ### 技術スタック -- **ビルドツール**: Vite 7.1.7(高速開発サーバー) -- **ルーティング**: TanStack Router 1.132.23(ファイルベースルーティング) -- **UIフレームワーク**: React 19.1.1 -- **UIライブラリ**: Chakra UI v3.27.0(Emotionスタイリング) -- **フォーム**: React Hook Form + Zodバリデーション -- **状態管理**: Jotai 2.15.0(アトミック状態管理) -- **認証**: Clerk (@clerk/clerk-react) -- **バックエンド**: Convex 1.27.3(リアルタイムデータベース) -- **パッケージマネージャ**: pnpm -- **日付**: dayjs - -### プロジェクト構造 - -#### ソースコード(`src/`) -- `routes/` - TanStack Routerのルート定義(ファイルベースルーティング)、pagesの呼び出し。state管理はしない -- `components/` - 目的別に整理されたReactコンポーネント - - `features/` - 機能固有コンポーネント - - `layout/` - レイアウトコンポーネント - - `pages/` - ページコンポーネント(店舗、シフト、勤怠等) - - `ui/` - UI基盤コンポーネント -- `stores/` - Jotaiアトム定義(状態管理) -- `helpers/` - ユーティリティ関数 -- `constants/` - 定数・バリデーションスキーマ -- `configs/` - 設定ファイル - -#### Convexバックエンド(`convex/`) -- サーバーレスバックエンドコード -- リアルタイムデータベース機能 - -### テストアーキテクチャ -プロジェクトでは多層テスト手法を採用: - -1. **ロジックテスト**: Vitestを使用したユーティリティ・ビジネスロジックのユニットテスト - - `src/**/*.test.ts`に配置 - - 分離されたNode.js環境で実行 - -2. **UIテスト**: Storybook統合によるコンポーネントテスト - - 実ブラウザテスト用Playwrightブラウザプロバイダーを使用 - - Storybookストーリーを直接テスト - -3. **E2Eテスト**: Playwrightによるフルアプリケーションテスト - - `e2e/`ディレクトリに配置 - - テスト用開発サーバーの自動起動 - -### 状態管理パターン -- アトミック状態管理にJotaiを使用 -- ドメイン別ストア定義(例: `src/stores/user/`) -- UIとユーザーデータ用のクライアントサイド状態アトム - -### フォームアーキテクチャ -- React Hook Form + Zodスキーマバリデーション -- フォームコンポーネントのパターン: schema.ts + index.tsx + index.stories.tsx -- `src/constants/validations.ts`での一元的バリデーションパターン -- 型は`z.infer<typeof Schema>`で自動生成 - -### バックエンド統合 -- Convexによるリアルタイムデータベース -- Clerkによる認証機能 -- 型安全なAPI呼び出し - -## コード品質基準 - -### フォーマット・リンティング -- Biome設定: 2スペースインデント、120文字行幅を強制 -- インポート整理を有効化 -- Reactドメインルールを適用 -- 配列インデックスキーを許可(noArrayIndexKey無効) -- forEachを許可(noForEach無効) - -### ファイル整理 -- コンポーネントには対応する.stories.tsxファイルを含む -- スキーマは専用ファイルに分離(schema.ts) -- パスエイリアス設定: @/src, @/e2e, @/convex - -### デザイン -- アイコンは react-iconsのLucideセットを利用すること(Chakraの Iconタグで呼び出すこと<Icon as={SomeIcon}>) -- `"@storybook/react"`; は、 ` "@storybook/react-vite";`で呼び出すこと - -### 汎用コンポーネント -- Selectボックス:@yps-crispy-carnival/src/components/ui/Select/index.tsx -- Formのカード:@yps-crispy-carnival/src/components/ui/FormCard/index.tsx -- ページタイトル:@yps-crispy-carnival/src/components/ui/Title/index.tsx -- モーダルダイアログ:@yps-crispy-carnival/src/components/ui/Dialog/index.tsx - - ビジネスロジック側で利用する場合、○○Modal/でディレクトリを切り、index.tsx, index.stories.tsxを切り出すこと - -### Formバリデーション -- react-hook-form x zodを利用。schemaはコロケーションでschema.tsとして切り出すこと - -### 全体バリデーション方針 -- @src/configs/zod/zop-setup.ts にメッセージは集約し、専用のメッセージなしでも通じるようにする -- 個別のschemaでは可能な限り専用メッセージなしにしたい -- バリデーションの定数は @src/constants/validations.ts に集約 - -## DBについて -- convexはバックエンドとしてアップロードするため、./convexにすべてのコードが入っている必要があります -- 定数などは @convex/constants.ts に集約する - - -## エラーハンドリング戦略 -- Formのエラー以外は、toastで成功・失敗をユーザーに通知するようにしてください - -## コンポーネントの責務(大事!) -1. routes/ - - page配下のコンポーネント呼び出しのみ - - それ以外は禁止! -2. src/components/pages - - useQueryの呼び出し - - APIに応じたエラー、ローディング、正常ケースのコンポーネント呼び出し - - useMutationの定義は禁止 - - 正常系ケースのコンポーネント呼び出し時はエラー、ローディングなどの判定は終わっているものとしたい! -3. src/components/features - - 主にレイアウト、ドメインロジックを持つ - - useMutationの定義 - - index.tsx内に正常系、エラー、ローディングのコンポーネントを持ち、これらは適宜src/components/pagesで呼び出される - -## Claude Code Web(Claude Code Desktop)のE2E実行 -- スキル `e2e-execution` を参照(`.claude/skills/e2e-execution/SKILL.md`) - -## Agent Team開発ルール -@CLAUDE.team.md を参照 \ No newline at end of file + +React 19 / Vite / TanStack Router / Chakra UI v3 / React Hook Form + Zod / Jotai / Clerk(認証) / Convex(BaaS) / Biome(lint/format) + +### レイヤー構造とデータフロー + +``` +routes/ → ページ呼び出しのみ(ロジック禁止) + ↓ +pages/ → useQuery、エラー/ローディング処理(useMutation禁止) + ↓ +features/ → ドメインロジック、useMutation、UI組成 + ↓ +convex/ → queries.ts(読み取り) / mutations.ts(書き込み) / policies.ts(権限判定) +``` + +- **routes/**: TanStack Routerのファイルベースルーティング。ページコンポーネントの呼び出し**のみ** +- **pages/**: `useQuery`でデータ取得し、エラー/ローディング/正常系を振り分け。正常系のみfeaturesを呼ぶ +- **features/**: ドメイン別ディレクトリ(Shop, Shift, Staff等)。`useMutation`はここで定義 +- **ui/**: 汎用UIコンポーネント(FormCard, BottomSheet等)。Select, DialogなどChakra UIのラッパーもここに配置 +- **templates/**: レイアウトコンポーネント(BottomMenu, SideMenu等) + +### Convexバックエンド(詳細は `convex/CLAUDE.md` を参照) + +- Feature Slices + CQRS + Policy Pattern +- ドメイン単位でディレクトリ分割(shop/, user/, staff/等) +- `policies.ts`は純粋関数(DBアクセスなし)。命名: `can*` / `is*` +- API呼び出し: `api.shop.queries.getById` / `api.shop.mutations.create` +- 論理削除パターン: `isDeleted`フラグ + +### 状態管理(Jotai) + +- `selectedShopAtom`: 選択中店舗(localStorage永続化) +- `userAtom`: ログインユーザー情報 +- ShiftForm系Atoms: Jotai Providerでスコープ管理 + +### 認証 + +- **Clerk**: アプリ認証(管理者・マネージャー) +- **マジックリンク**: スタッフのシフト申請(Clerkアカウント不要) +- **招待トークン**: マネージャー招待用 + +## コーディング規約 + +### パスエイリアス + +```ts +import { Foo } from "@/src/components/..."; +import { bar } from "@/convex/..."; +``` + +### Biome設定 + +- インデント: スペース2つ / 行幅: 120文字 +- import自動整理有効(`organizeImports`) +- `convex/_generated`、`src/routeTree.gen.ts`は自動生成のため除外 + +### バリデーション + +- Zodスキーマ + カスタムエラーマップ(日本語メッセージ) +- カスタムバリデータ: `src/helpers/validation/`(`betweenLength`, `time`, `select`等) + +### Storybook + +- `@storybook/react-vite`を使用(`@storybook/react`ではない) +- `@storybook/test`パッケージはインストールされていない。`fn()`は使わず、コールバックは `() => {}` で直接指定する +- stories は各コンポーネントと同階層に配置(`.stories.tsx`) + +## デザイン + +- `design.pen`: UIデザインファイル。Pencil MCPツール経由で読み書きする(`Read`や`Grep`では読めない) +- デザイン確認・編集には `batch_get`、`batch_design`、`get_screenshot` 等のPencil MCPツールを使用 + +## コーディング + +- `pnpm lint`, `pnpm type-check`を必ず実行すること + +## プラン + +- planドキュメント保存時は参考ファイルのパスも記載すること + +## ドキュメント + +- `doc/ARCHITECTURE.md`: 全体構造、機能→ファイルマッピング、データフロー +- `doc/INDEX.md`: 機能仕様ドキュメントのインデックス +- `doc/features/`: 各機能の仕様 +- `doc/plans/`: 実装計画 +- `doc/claude/soul.md`: 設計判断の指針 +- `convex/CLAUDE.md`: Convexアーキテクチャの詳細 diff --git a/CLAUDE.team.md b/CLAUDE.team.md deleted file mode 100644 index f8d983c9..00000000 --- a/CLAUDE.team.md +++ /dev/null @@ -1,3 +0,0 @@ -## Agent Team開発ルール -- Agent Team用のルールやガイドラインをまとめてください。 -- 以下Agent Team内のルールを自由に追加、削除、編集してください。 diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index aef82ec3..acf33c4e 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -19,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 shiftAssignment_mutations from "../shiftAssignment/mutations.js"; +import type * as shiftAssignment_queries from "../shiftAssignment/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"; @@ -47,6 +49,8 @@ declare const fullApi: ApiFromModules<{ "recruitment/queries": typeof recruitment_queries; "requiredStaffing/mutations": typeof requiredStaffing_mutations; "requiredStaffing/queries": typeof requiredStaffing_queries; + "shiftAssignment/mutations": typeof shiftAssignment_mutations; + "shiftAssignment/queries": typeof shiftAssignment_queries; "shiftRequest/mutations": typeof shiftRequest_mutations; "shiftRequest/queries": typeof shiftRequest_queries; "shop/mutations": typeof shop_mutations; diff --git a/convex/constants.ts b/convex/constants.ts index 87f38527..b78c10f4 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -26,6 +26,20 @@ export type PositionType = (typeof DEFAULT_POSITIONS)[number]; export const POSITION_MAX_COUNT = 10; export const POSITION_NAME_MAX_LENGTH = 20; +// ポジションカラーパレット +export const POSITION_COLORS = [ + "#3b82f6", // blue + "#f97316", // orange + "#10b981", // green + "#8b5cf6", // purple + "#ec4899", // pink + "#f59e0b", // amber + "#06b6d4", // cyan + "#84cc16", // lime + "#ef4444", // red + "#6366f1", // indigo +] as const; + // ロール定義 export const STAFF_ROLES = ["owner", "manager", "general"] as const; export type StaffRoleType = (typeof STAFF_ROLES)[number]; diff --git a/convex/email/actions.ts b/convex/email/actions.ts index 4beaad1d..c86f1be2 100644 --- a/convex/email/actions.ts +++ b/convex/email/actions.ts @@ -60,7 +60,54 @@ export const sendRecruitmentNotification = internalAction({ }, }); -// メールHTML組み立て +// シフト確定通知メール送信 +export const sendShiftConfirmationNotification = internalAction({ + args: { + shopName: v.string(), + startDate: v.string(), + endDate: 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: buildConfirmationEmailHtml({ + shopName: args.shopName, + startDate: args.startDate, + endDate: args.endDate, + magicLinkUrl, + }), + }); + } catch (e) { + console.error(`メール送信失敗: ${recipient.email}`, e); + } + } + }, +}); + +// 募集通知メールHTML組み立て const buildEmailHtml = (params: { shopName: string; startDate: string; @@ -87,3 +134,26 @@ const buildEmailHtml = (params: { </div> `.trim(); }; + +// 確定通知メールHTML組み立て +const buildConfirmationEmailHtml = (params: { + shopName: string; + startDate: string; + endDate: 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> + </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/helpers.ts b/convex/helpers.ts index a4a65a5f..5e9e8612 100644 --- a/convex/helpers.ts +++ b/convex/helpers.ts @@ -75,15 +75,19 @@ export const getStaffByEmail = async (ctx: QueryCtx | MutationCtx, shopId: Id<"s return staff; }; -// マジックリンクトークンでスタッフを取得 -export const getStaffByMagicLinkToken = async (ctx: QueryCtx | MutationCtx, token: string) => { - const staff = await ctx.db - .query("staffs") - .withIndex("by_magic_link_token", (q) => q.eq("magicLinkToken", token)) - .filter((q) => q.neq(q.field("isDeleted"), true)) +// マジックリンクトークンからmagicLinkレコードとスタッフを取得 +export const getMagicLinkByToken = async (ctx: QueryCtx | MutationCtx, token: string) => { + const magicLink = await ctx.db + .query("magicLinks") + .withIndex("by_token", (q) => q.eq("token", token)) .first(); - return staff; + if (!magicLink) return null; + + const staff = await ctx.db.get(magicLink.staffId); + if (!staff || staff.isDeleted) return null; + + return { magicLink, staff }; }; // 招待トークンでスタッフを取得 diff --git a/convex/position/mutations.ts b/convex/position/mutations.ts index c6d95d37..87264803 100644 --- a/convex/position/mutations.ts +++ b/convex/position/mutations.ts @@ -7,7 +7,7 @@ */ import { ConvexError, v } from "convex/values"; import { mutation } from "../_generated/server"; -import { DEFAULT_POSITIONS, SKILL_LEVELS } from "../constants"; +import { DEFAULT_POSITIONS, POSITION_COLORS, SKILL_LEVELS } from "../constants"; import { requireShop } from "../helpers"; // ポジション作成 @@ -48,6 +48,7 @@ export const create = mutation({ const positionId = await ctx.db.insert("shopPositions", { shopId: args.shopId, name: trimmedName, + color: POSITION_COLORS[(maxOrder + 1) % POSITION_COLORS.length], order: maxOrder + 1, isDeleted: false, createdAt: Date.now(), @@ -92,6 +93,25 @@ export const updateName = mutation({ }, }); +// ポジションカラー更新 +export const updateColor = mutation({ + args: { + positionId: v.id("shopPositions"), + color: v.string(), + authId: v.string(), + }, + handler: async (ctx, args) => { + const position = await ctx.db.get(args.positionId); + if (!position || position.isDeleted) { + throw new ConvexError({ message: "ポジションが見つかりません", code: "NOT_FOUND" }); + } + + await ctx.db.patch(args.positionId, { color: args.color }); + + return { success: true }; + }, +}); + // ポジション削除(論理削除) export const remove = mutation({ args: { @@ -147,6 +167,7 @@ export const initializeDefaultPositions = mutation({ const positionId = await ctx.db.insert("shopPositions", { shopId: args.shopId, name: DEFAULT_POSITIONS[i], + color: POSITION_COLORS[i % POSITION_COLORS.length], order: i, isDeleted: false, createdAt: Date.now(), diff --git a/convex/recruitment/mutations.ts b/convex/recruitment/mutations.ts index 55a65e5b..f9da6083 100644 --- a/convex/recruitment/mutations.ts +++ b/convex/recruitment/mutations.ts @@ -3,6 +3,8 @@ * * 責務: * - シフト募集の作成 + * - シフト募集の締め切り + * - シフト募集の確定(メール通知付き) */ import { ConvexError, v } from "convex/values"; import { internal } from "../_generated/api"; @@ -65,19 +67,6 @@ 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, @@ -92,6 +81,21 @@ export const create = mutation({ isDeleted: false, }); + // 各スタッフにマジックリンクトークンを生成(募集単位) + 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.insert("magicLinks", { + staffId: staff._id, + recruitmentId, + token, + expiresAt: deadlineEnd, + }); + recipients.push({ email: staff.email, magicLinkToken: token }); + } + // 店舗情報を取得してメール送信をスケジュール const shop = await requireShop(ctx, args.shopId); if (recipients.length > 0) { @@ -107,3 +111,84 @@ export const create = mutation({ return { success: true, data: { recruitmentId, totalStaffCount } }; }, }); + +// シフト募集の締め切り +export const close = mutation({ + args: { + recruitmentId: v.id("recruitments"), + authId: v.string(), + }, + handler: async (ctx, args) => { + const recruitment = await ctx.db.get(args.recruitmentId); + if (!recruitment || recruitment.isDeleted) { + throw new ConvexError({ message: "募集が見つかりません", code: "RECRUITMENT_NOT_FOUND" }); + } + + // 権限チェック + await requireShopOwnerOrManager(ctx, recruitment.shopId, args.authId); + + // ステータスチェック(openのみ締め切り可能) + if (recruitment.status !== RECRUITMENT_STATUS[0]) { + throw new ConvexError({ message: "この募集は締め切り済みです", code: "ALREADY_CLOSED" }); + } + + await ctx.db.patch(args.recruitmentId, { + status: RECRUITMENT_STATUS[1], // "closed" + }); + + return { success: true }; + }, +}); + +// シフト募集の確定(確定通知メール送信) +export const confirm = mutation({ + args: { + recruitmentId: v.id("recruitments"), + authId: v.string(), + }, + handler: async (ctx, args) => { + const recruitment = await ctx.db.get(args.recruitmentId); + if (!recruitment || recruitment.isDeleted) { + throw new ConvexError({ message: "募集が見つかりません", code: "RECRUITMENT_NOT_FOUND" }); + } + + // 権限チェック + await requireShopOwnerOrManager(ctx, recruitment.shopId, args.authId); + + // ステータスチェック(closedのみ確定可能) + if (recruitment.status !== RECRUITMENT_STATUS[1]) { + throw new ConvexError({ message: "締め切り後に確定してください", code: "NOT_CLOSED" }); + } + + await ctx.db.patch(args.recruitmentId, { + status: RECRUITMENT_STATUS[2], // "confirmed" + confirmedAt: Date.now(), + }); + + // 確定通知メール送信 + const shop = await requireShop(ctx, recruitment.shopId); + const magicLinksForRecruitment = await ctx.db + .query("magicLinks") + .withIndex("by_recruitment", (q) => q.eq("recruitmentId", args.recruitmentId)) + .collect(); + + const recipients: { email: string; magicLinkToken: string }[] = []; + for (const ml of magicLinksForRecruitment) { + const staff = await ctx.db.get(ml.staffId); + if (staff && !staff.isDeleted && staff.status !== "resigned") { + recipients.push({ email: staff.email, magicLinkToken: ml.token }); + } + } + + if (recipients.length > 0) { + await ctx.scheduler.runAfter(0, internal.email.actions.sendShiftConfirmationNotification, { + shopName: shop.shopName, + startDate: recruitment.startDate, + endDate: recruitment.endDate, + recipients, + }); + } + + return { success: true }; + }, +}); diff --git a/convex/recruitment/queries.ts b/convex/recruitment/queries.ts index 2ce8f10a..e2ef00e8 100644 --- a/convex/recruitment/queries.ts +++ b/convex/recruitment/queries.ts @@ -3,11 +3,36 @@ * * 責務: * - 店舗のシフト募集一覧取得 + * - 募集詳細取得 */ import { v } from "convex/values"; import { query } from "../_generated/server"; import type { RecruitmentStatusType } from "../constants"; +// 募集詳細取得 +export const getById = query({ + args: { recruitmentId: v.id("recruitments") }, + handler: async (ctx, args) => { + const recruitment = await ctx.db.get(args.recruitmentId); + if (!recruitment || recruitment.isDeleted) { + return null; + } + + return { + _id: recruitment._id, + shopId: recruitment.shopId, + startDate: recruitment.startDate, + endDate: recruitment.endDate, + deadline: recruitment.deadline, + status: recruitment.status as RecruitmentStatusType, + appliedCount: recruitment.appliedCount, + totalStaffCount: recruitment.totalStaffCount, + confirmedAt: recruitment.confirmedAt, + createdAt: recruitment.createdAt, + }; + }, +}); + // 店舗の募集一覧取得 export const listByShop = query({ args: { shopId: v.id("shops") }, diff --git a/convex/schema.ts b/convex/schema.ts index 6505dfaf..97783f5d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -48,10 +48,6 @@ const staffs = defineTable({ ), maxWeeklyHours: v.optional(v.number()), - // マジックリンク(シフト申請サイクルごとに発行) - magicLinkToken: v.optional(v.string()), - magicLinkExpiresAt: v.optional(v.number()), - // 招待トークン(マネージャー招待用) inviteToken: v.optional(v.string()), inviteExpiresAt: v.optional(v.number()), @@ -73,7 +69,6 @@ const staffs = defineTable({ .index("by_shop", ["shopId"]) .index("by_email", ["email"]) .index("by_shop_and_email", ["shopId", "email"]) - .index("by_magic_link_token", ["magicLinkToken"]) .index("by_invite_token", ["inviteToken"]) .index("by_user", ["userId"]); @@ -81,6 +76,7 @@ const staffs = defineTable({ const shopPositions = defineTable({ shopId: v.id("shops"), name: v.string(), // "ホール", "キッチン" など + color: v.optional(v.string()), // "#3b82f6" など order: v.number(), // 表示順 isDeleted: v.boolean(), createdAt: v.number(), @@ -139,6 +135,27 @@ const shiftRequests = defineTable({ .index("by_staff", ["staffId"]) .index("by_recruitment_and_staff", ["recruitmentId", "staffId"]); +// シフト割当テーブル(管理者が編集・確定するシフト) +const shiftAssignments = defineTable({ + recruitmentId: v.id("recruitments"), + assignments: v.array( + v.object({ + staffId: v.string(), + date: v.string(), // "YYYY-MM-DD" + positions: v.array( + v.object({ + positionId: v.string(), + positionName: v.string(), + color: v.string(), + start: v.string(), // "09:00" + end: v.string(), // "17:00" + }), + ), + }), + ), + updatedAt: v.number(), +}).index("by_recruitment", ["recruitmentId"]); + // シフト募集テーブル const recruitments = defineTable({ shopId: v.id("shops"), @@ -157,6 +174,16 @@ const recruitments = defineTable({ .index("by_shop_and_status", ["shopId", "status"]) .index("by_shop_and_startDate", ["shopId", "startDate"]); +// マジックリンクテーブル(スタッフ × 募集単位でトークン管理) +const magicLinks = defineTable({ + staffId: v.id("staffs"), + recruitmentId: v.id("recruitments"), + token: v.string(), + expiresAt: v.number(), +}) + .index("by_token", ["token"]) + .index("by_recruitment", ["recruitmentId"]); + const schema = defineSchema({ users, shops, @@ -165,7 +192,9 @@ const schema = defineSchema({ staffSkills, requiredStaffing, shiftRequests, + shiftAssignments, recruitments, + magicLinks, }); // テーブル名を型安全にエクスポート(testing.tsで使用) diff --git a/convex/shiftAssignment/mutations.ts b/convex/shiftAssignment/mutations.ts new file mode 100644 index 00000000..28d21e06 --- /dev/null +++ b/convex/shiftAssignment/mutations.ts @@ -0,0 +1,51 @@ +/** + * シフト割当ドメイン - ミューテーション(書き込み操作) + * + * 責務: + * - 管理者が編集したシフト割当データの保存(upsert) + */ +import { v } from "convex/values"; +import { mutation } from "../_generated/server"; + +const assignmentValidator = v.object({ + staffId: v.string(), + date: v.string(), + positions: v.array( + v.object({ + positionId: v.string(), + positionName: v.string(), + color: v.string(), + start: v.string(), + end: v.string(), + }), + ), +}); + +// シフト割当データの保存(upsert) +export const save = mutation({ + args: { + recruitmentId: v.id("recruitments"), + assignments: v.array(assignmentValidator), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("shiftAssignments") + .withIndex("by_recruitment", (q) => q.eq("recruitmentId", args.recruitmentId)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + assignments: args.assignments, + updatedAt: Date.now(), + }); + return { success: true, id: existing._id, isNew: false }; + } + + const id = await ctx.db.insert("shiftAssignments", { + recruitmentId: args.recruitmentId, + assignments: args.assignments, + updatedAt: Date.now(), + }); + return { success: true, id, isNew: true }; + }, +}); diff --git a/convex/shiftAssignment/queries.ts b/convex/shiftAssignment/queries.ts new file mode 100644 index 00000000..0c6eb682 --- /dev/null +++ b/convex/shiftAssignment/queries.ts @@ -0,0 +1,30 @@ +/** + * シフト割当ドメイン - クエリ(読み取り操作) + * + * 責務: + * - 募集に紐づく管理者編集済みシフトデータの取得 + */ +import { v } from "convex/values"; +import { query } from "../_generated/server"; + +// 募集に紐づくシフト割当データを取得 +export const getByRecruitment = query({ + args: { recruitmentId: v.id("recruitments") }, + handler: async (ctx, args) => { + const record = await ctx.db + .query("shiftAssignments") + .withIndex("by_recruitment", (q) => q.eq("recruitmentId", args.recruitmentId)) + .first(); + + if (!record) { + return null; + } + + return { + _id: record._id, + recruitmentId: record.recruitmentId, + assignments: record.assignments, + updatedAt: record.updatedAt, + }; + }, +}); diff --git a/convex/shiftRequest/mutations.ts b/convex/shiftRequest/mutations.ts index e8b7cadb..e3d7dcf2 100644 --- a/convex/shiftRequest/mutations.ts +++ b/convex/shiftRequest/mutations.ts @@ -6,7 +6,7 @@ */ import { ConvexError, v } from "convex/values"; import { mutation } from "../_generated/server"; -import { getStaffByMagicLinkToken, isValidTimeFormat } from "../helpers"; +import { getMagicLinkByToken, isValidTimeFormat } from "../helpers"; // シフト希望提出 export const submit = mutation({ @@ -22,25 +22,21 @@ export const submit = mutation({ ), }, handler: async (ctx, args) => { - // トークンからスタッフを取得 - const staff = await getStaffByMagicLinkToken(ctx, args.token); - if (!staff) { + // トークンからmagicLinkレコードとスタッフを取得 + const result = await getMagicLinkByToken(ctx, args.token); + if (!result) { throw new ConvexError({ message: "無効なトークンです", code: "INVALID_TOKEN" }); } + const { magicLink, staff } = result; // トークン有効期限チェック - if (staff.magicLinkExpiresAt && staff.magicLinkExpiresAt < Date.now()) { + if (magicLink.expiresAt < 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) { + // トークンに紐づく募集を直接取得 + const recruitment = await ctx.db.get(magicLink.recruitmentId); + if (!recruitment || recruitment.isDeleted || recruitment.status !== "open") { throw new ConvexError({ message: "募集が見つかりません", code: "NO_OPEN_RECRUITMENT" }); } diff --git a/convex/shiftRequest/queries.ts b/convex/shiftRequest/queries.ts index 93b1e307..2b2790ab 100644 --- a/convex/shiftRequest/queries.ts +++ b/convex/shiftRequest/queries.ts @@ -3,23 +3,44 @@ * * 責務: * - マジックリンクからの提出ページデータ取得 + * - 募集に紐づく全申請の取得 */ import { v } from "convex/values"; import { query } from "../_generated/server"; -import { getStaffByMagicLinkToken } from "../helpers"; +import { getMagicLinkByToken } from "../helpers"; + +// 募集に紐づく全申請を取得(管理者の募集詳細ページ用) +export const listByRecruitment = query({ + args: { recruitmentId: v.id("recruitments") }, + handler: async (ctx, args) => { + const requests = await ctx.db + .query("shiftRequests") + .withIndex("by_recruitment", (q) => q.eq("recruitmentId", args.recruitmentId)) + .collect(); + + return requests.map((r) => ({ + _id: r._id, + staffId: r.staffId, + entries: r.entries, + submittedAt: r.submittedAt, + updatedAt: r.updatedAt, + })); + }, +}); // 提出ページデータ取得(マジックリンクトークンで認証) export const getSubmitPageData = query({ args: { token: v.string() }, handler: async (ctx, args) => { - // トークンからスタッフを取得 - const staff = await getStaffByMagicLinkToken(ctx, args.token); - if (!staff) { + // トークンからmagicLinkレコードとスタッフを取得 + const result = await getMagicLinkByToken(ctx, args.token); + if (!result) { return { error: "INVALID_TOKEN" as const }; } + const { magicLink, staff } = result; // トークン有効期限チェック - if (staff.magicLinkExpiresAt && staff.magicLinkExpiresAt < Date.now()) { + if (magicLink.expiresAt < Date.now()) { return { error: "TOKEN_EXPIRED" as const }; } @@ -29,67 +50,98 @@ export const getSubmitPageData = query({ 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) { + // トークンに紐づく募集を直接取得 + const recruitment = await ctx.db.get(magicLink.recruitmentId); + if (!recruitment || recruitment.isDeleted) { 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(); + // 募集ステータスに応じた分岐 + if (recruitment.status === "open") { + 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 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; + const frequentTimePatterns = calcFrequentTimePatterns(allPastRequests); + + return { + error: null, + status: "open" as const, + 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, + }; + } + + if (recruitment.status === "confirmed") { + const positions = await ctx.db + .query("shopPositions") + .withIndex("by_shop", (q) => q.eq("shopId", staff.shopId)) + .filter((q) => q.neq(q.field("isDeleted"), true)) + .collect(); + + const allStaffs = await ctx.db + .query("staffs") + .withIndex("by_shop", (q) => q.eq("shopId", staff.shopId)) + .filter((q) => q.and(q.neq(q.field("isDeleted"), true), q.neq(q.field("status"), "resigned"))) + .collect(); + + const shiftRequests = await ctx.db + .query("shiftRequests") + .withIndex("by_recruitment", (q) => q.eq("recruitmentId", recruitment._id)) + .collect(); + + const shiftAssignment = await ctx.db + .query("shiftAssignments") + .withIndex("by_recruitment", (q) => q.eq("recruitmentId", recruitment._id)) + .first(); + + return { + error: null, + status: "confirmed" as const, + 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, + }, + positions: positions + .sort((a, b) => a.order - b.order) + .map((p) => ({ _id: p._id, name: p.name, color: p.color, order: p.order })), + staffs: allStaffs.map((s) => ({ _id: s._id, displayName: s.displayName, status: s.status })), + shiftRequests: shiftRequests.map((r) => ({ _id: r._id, staffId: r.staffId, entries: r.entries })), + shiftAssignment: shiftAssignment ? { assignments: shiftAssignment.assignments } : null, + }; + } + + if (recruitment.status === "closed") { + return { error: "RECRUITMENT_CLOSED" as const }; + } - 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, - }; + return { error: "NO_OPEN_RECRUITMENT" as const }; }, }); diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 5eec528b..4ae95fbf 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -37,7 +37,7 @@ convex/ | スキル管理 | - | `StaffDetail`内 | `staffSkill/queries`, `mutations` | | ユーザー管理 | `MyPage`, `Settings` | `User/UserRegister`, `Setting/UserSetting` | `user/queries`, `mutations` | | 招待機能 | `Invite` | `Shop/MemberAddModal` | `invite/queries`, `mutations` | -| シフト管理 | `Shops/ShiftsPage`, `RecruitmentNewPage`, `RecruitmentDetailPage`, `ShiftConfirmPage`, `StaffingSettingsPage` | `Shift/ShiftForm`, `RecruitmentForm`, `RecruitmentList`, `RecruitmentDetail`, `RecruitmentNew`, `StaffingRequirement` | `requiredStaffing/queries`, `mutations` | +| シフト管理 | `Shops/ShiftsPage`, `RecruitmentNewPage`, `RecruitmentDetailPage`, `ShiftConfirmPage`, `StaffingSettingsPage` | `Shift/ShiftForm`, `ShiftConfirm`, `RecruitmentForm`, `RecruitmentList`, `RecruitmentDetail`, `RecruitmentNew`, `StaffingRequirement` | `recruitment/queries,mutations`, `shiftRequest/queries,mutations`, `shiftAssignment/queries,mutations`, `requiredStaffing/queries,mutations` | --- @@ -62,8 +62,9 @@ convex/ ### ポジション管理 | ファイルパス | 責務 | |-------------|------| -| `src/components/features/Shop/PositionManager/` | ポジション一覧管理 | -| `src/components/features/Shop/PositionEditor/` | ポジション個別編集 | +| `src/components/features/Shop/PositionManager/` | ポジション一覧管理(カラー選択対応) | +| `src/components/features/Shop/PositionEditor/` | ポジション個別編集(カラー選択対応) | +| `src/components/ui/ColorPicker/` | プリセットカラー選択コンポーネント | | `convex/position/` | DB操作 | ### スキル管理 @@ -97,10 +98,15 @@ convex/ | `src/routes/_auth/shops/$shopId/shifts/` | ルーティング | | `src/components/pages/Shops/ShiftsPage/` | useQuery、エラー/ローディング処理 | | `src/components/pages/Shops/RecruitmentNewPage/` | 募集作成ページ | -| `src/components/pages/Shops/RecruitmentDetailPage/` | 募集詳細ページ | -| `src/components/pages/Shops/ShiftConfirmPage/` | シフト確定ページ | +| `src/components/pages/Shops/RecruitmentDetailPage/` | 募集詳細ページ(申請状況確認) | +| `src/components/pages/Shops/ShiftConfirmPage/` | シフト編集・確定ページ | | `src/components/pages/Shops/StaffingSettingsPage/` | 必要人員設定ページ | | `src/components/features/Shift/` | ドメインロジック、UI | +| `src/components/features/Shift/ShiftConfirm/` | シフト編集・保存・締切・確定 | +| `src/components/features/Shift/utils/transformRecruitmentData.ts` | Convexデータ→ShiftForm用props変換 | +| `convex/recruitment/` | 募集のCRUD・締切・確定 | +| `convex/shiftRequest/` | スタッフの希望提出 | +| `convex/shiftAssignment/` | 管理者のシフト割当 | | `convex/requiredStaffing/` | 必要人員DB操作 | --- diff --git a/doc/claude/basic.md b/doc/claude/basic.md deleted file mode 100644 index 048cab10..00000000 --- a/doc/claude/basic.md +++ /dev/null @@ -1,156 +0,0 @@ - -### コンポーネント設計原則 - -**Feature-First + コロケーション**パターン: - -``` -src/components/DraftRoom/ -├── index.tsx # メインコンポーネント -├── index.stories.tsx # Storybookファイル(パターン作成、簡単なテスト) -└── hooks.ts # ローカルカスタムフック(必要時) -``` - -**厳格ルール:** -- ❌ HOC/Render Props使用禁止 -- ❌ Context API使用禁止(Props Drilling基本) -- ❌ interface使用禁止(typeのみ) -- ✅ Custom Hooks基本 -- ✅ 特化優先 → リファクタで汎用化 - -## 💻 コーディングルール - -### 関数定義(厳格) -```tsx -// ✅ Arrow Function一択 -const handleSubmit = async (data: FormData) => { - // 処理 -}; - -// ❌ 絶対禁止: Function Declaration -function handleSubmit() { /* 禁止 */ } -``` - -### コンポーネント定義(厳格) -```tsx -// ✅ 通常の関数コンポーネント + type -type DraftRoomProps = { - draft: DraftType; - onUpdate: (draft: DraftType) => void; -}; - -const DraftRoom = ({ draft }: DraftRoomProps) => { - return <div>{draft.name}</div>; -}; - -// ❌ 禁止: React.FC + interface -const DraftRoom: React.FC<Props> = () => {}; // 禁止 -interface Props {} // 禁止 -``` - -### 引数・制御フロー -```tsx -// ✅ 2個以上は必ずオブジェクト化 -const createDraft = (name: string, options: { - maxPlayers: number; - timeLimit: number; -}) => {}; - -// ✅ Early Return必須 -const processData = (data: Data | null) => { - if (!data) return null; - if (data.isEmpty()) return <EmptyState />; - // メイン処理 - return <MainContent data={data} />; -}; -``` - -### TypeScript(厳格) -```tsx -// ✅ type一択、Union Types使用 -type StatusType = 'waiting' | 'playing' | 'finished'; - -// ❌ 禁止パターン -interface Status {} // interface禁止 -enum Status {} // enum禁止 -``` - -## 🧪 テスト戦略 - -### 実行方針 -- **E2Eテスト**: 毎PR、ハッピーパスのみ、Chrome only -- **単体テスト**: 日本語命名、比重5:1(ハッピー:エッジ) -- **Storybook**: 全コンポーネント必須、代表パターンのみ - -### テスト実装例 -```tsx -// ✅ 日本語命名必須 -describe('useDraftRoom', () => { - test('ドラフトルームデータを正常に取得できる', () => { - const { result } = renderHook(() => useDraftRoom('draft123')); - expect(result.current.draft).toBeDefined(); - }); -}); -``` - -## 🎨 UI/UX実装 - -### Chakra UI使用ルール -```tsx -// ✅ inline style props必須 -<Box - bg="blue.500" - p={4} - _hover={{ bg: "blue.600" }} -> - -// ✅ レスポンシブ:配列記法、2段階(PC/SP) -<Text fontSize={["sm", "md"]}> -``` - -### アニメーション統一 -- **Duration**: 150ms統一 -- **Easing**: ease統一 -- **ローディング**: スピナー使用 -- **実装**: Framer Motion使用 - -## 🗃️ 状態管理戦略 - -### 階層別管理 -```tsx -// Level 1: コンポーネント内(優先) -const [localState, setLocalState] = useState(); - -// Level 2: Custom Hook(共通ロジック) -const { data, error } = useDraftData(draftId); - -// Level 3: Jotai(画面遷移で必要) -const [globalUser] = useAtom(userAtom); - -// ❌ 禁止: Context API -``` - -### Firebase連携 -- **更新方式**: 全てリアルタイム更新 -- **永続化**: Firebase > SessionStorage -- **アプローチ**: 悲観的更新基本 - -## 📋 重要なルール - -### やること(必須) -- ✅ Arrow Function -- ✅ type定義(interface禁止) -- ✅ const優先(let最小限) -- ✅ 分割代入積極活用 -- ✅ async/await(Promise.then禁止) -- ✅ Early Return -- ✅ 日本語テスト -- ✅ Props Drilling - -### やらないこと(厳格禁止) -- ❌ Function Declaration -- ❌ interface -- ❌ React.FC -- ❌ HOC/Render Props -- ❌ Context API -- ❌ Enum -- ❌ 過度な最適化 diff --git a/doc/claude/self.md b/doc/claude/self.md deleted file mode 100644 index ad3d183a..00000000 --- a/doc/claude/self.md +++ /dev/null @@ -1,414 +0,0 @@ ---- -description: "あなたの完全分身として、実用主義に基づいた高品質リファクタリングを実行" ---- - -# 🔄 リファクタリング実行(あなたの完全分身モード) - -対象: $ARGUMENTS - -## 🎯 リファクタリングの哲学 - -**「今の挙動そのまま」でコードをきれいにする。機能的には一切変えずに、コードの見た目や構造だけを改善する。** - -- ❌ バグは直さない -- ✅ 副次的な効果でパフォーマンスが上がることはある -- 🎯 **文脈を考慮した実用的な判断**を最優先 -- 💪 **迷ったらやる!**の精神 - -## 🧠 判断基準(あなたの思考プロセス) - -### 共通化の判断 -**「ときと場合による」を基準に文脈重視で決める:** - -✅ **積極的に共通化するもの:** -- ほぼ同じコンポーネント(UserProfile vs UserCard のような重複) -- 頻繁に使う処理(fetch、API呼び出し等)→ 超汎用化して外だし -- バリデーション等の純粋関数 → **テストしやすさ重視**で別ファイルに外だし -- UI部品(input、button等)→ 汎用コンポーネント化 - -❌ **共通化しないもの:** -- ドメイン固有の部分(責務が違うから) -- form全体(それぞれの責務が明確に違う) -- 1つのファイル内の関数(ファイル内での重複は許容) -- 無理な定数化(文脈上自然でないもの) - -🎯 **柔軟対応:** -- ロジック、HTML、CSSの共通化は積極的に -- 同じファイル内のコンポーネントと関数の共存はOK - -### 重複コード管理の明確基準 -- **2回目の重複** → 警告・検討 -- **3回目の重複** → **必ず共通化** - -### 関数分割の判断 -**Step Down Rule(段階的抽象化)を重視:** - -- **「全体を見たい」と「詳細を見たい」が混在している** → 分割対象 -- 上から下に「だんだん詳細になっていく」構造にする -- public関数(高レベル・抽象的)→ private関数(詳細・具体的) -- **テストしやすい形に分割**(特にMSW利用時のC/Pパターン) - -## 📝 ネーミング改善(一貫性最優先) - -### 基本ルール -- **具体的な名前**:`data` → `userData` -- **省略形禁止**:`btn` → `button`、`isAuth` → `isUserAuthenticated` -- **動詞vs名詞**:関数は動詞始まり、変数・定数は名詞 -- **複数形統一**:基本`-s`、難しいときのみ`-List` -- **ドメイン用語は絶対統一**:プロジェクト内で同じ概念は同じ名前 - -### Boolean命名 -- **基本**:`is〜` -- **所有・存在**:`has〜`(hasPermission、hasChildren) -- **能力**:`can〜`(canEdit、canDelete) -- **義務**:`should〜`(shouldValidate) -- **否定**:`isDisabled`(`isNot〜`は避ける) - -## 🏗️ 技術的指針 - -### 優先する構造 -- **関数ベース** > class(classは避ける) -- **テスタビリティ**を常に考慮 -- **関心の分離**だけど過度なモジュール化は避ける -- **エラーハンドリング**:汎用的な部分は共通化、具体的な扱いはドメインロジック側 - -### コメント・ドキュメント方針 -- **「コードで語る」**を基本とする -- 既存コメントは見直し、不要なものは削除 -- Step Down Ruleに従い、関数名で意図を表現 -- 自明でない複雑なロジックにのみ最小限のコメント -- ドメイン知識が必要な部分は適切にドキュメント化 - -## 🚫 厳格な禁止事項(絶対遵守) - -### React・TypeScript禁止パターン -```typescript -// ❌ 絶対禁止: Function Declaration -function handleSubmit() {} - -// ✅ 必須: Arrow Function -const handleSubmit = () => {} - -// ❌ 絶対禁止: interface -interface UserProps {} - -// ✅ 必須: type -type UserProps = {} - -// ❌ 絶対禁止: React.FC -const Button: React.FC = () => {} - -// ✅ 必須: 通常関数コンポーネント -const Button = () => {} - -// ❌ 絶対禁止: Context API -const Context = createContext() - -// ✅ 必須: Props Drilling容認 -<Child userProp={user} /> - -// ❌ 絶対禁止: Enum -enum Status { ACTIVE = 'active' } - -// ✅ 必須: Union Types -type Status = 'active' | 'inactive' -``` - -### 副作用最小化 -- **useEffect最小限**(バグの原因になりやすい) -- **Context API禁止**(Props Drilling容認) -- **HOC/Render Props禁止**(Custom Hooks推奨) - -### 引数設計の厳格ルール -```typescript -// ✅ 2個以上は必ずオブジェクト化 -const createDraft = (name: string, options: { - maxPlayers: number; - timeLimit: number; - isPrivate: boolean; -}) => {} - -// ❌ 禁止: 個別引数の羅列 -const createDraft = (name: string, maxPlayers: number, timeLimit: number) => {} -``` - -### 非同期処理の厳格ルール -```typescript -// ✅ async/await必須 -const fetchData = async () => { - try { - const result = await api.getData(); - return result; - } catch (error) { - throw error; - } -}; - -// ❌ 絶対禁止: Promise.then() -const fetchData = () => { - return api.getData().then(data => data); // 使用禁止 -}; - -// ✅ 並列実行はPromise.all必須 -const [users, drafts] = await Promise.all([ - fetchUsers(), - fetchDrafts() -]); -``` - -### 変数・定数の厳格ルール -```typescript -// ✅ const優先(強制) -const userName = "太郎"; -const userList = ["太郎", "花子"]; - -// ✅ 分割代入積極活用 -const { name, age, email } = user; -const [first, second, ...rest] = items; - -// ✅ 説明的命名(短縮禁止) -const isUserAuthenticated = true; // ✅ 分かりやすい -const isAuth = true; // ❌ 短縮形禁止 -``` - -## 📁 コロケーション戦略(Locality of Behavior) - -### ファイル配置の哲学 -``` -components/feature/gacha/GachaForm/ -├── index.tsx # メインコンポーネント -├── action.ts # サーバーアクション -├── index.stories.tsx # Storybook -└── hooks.ts # ローカルカスタムフック(必要時) -``` - -**原則:関連するものは物理的に近くに配置** -- 変更時の影響範囲が明確 -- 新しい人でも迷わない構造 -- **なるべくsrc/*/まで持ち上げず、コロケーションに閉じ込める** - -### Barrel Export禁止 -```typescript -// ❌ 避ける -import { MailInput, PasswordInput } from '@/components/form' - -// ✅ 推奨: Explicit Import -import { MailInput } from '@/components/form/MailInput' -import { PasswordInput } from '@/components/form/PasswordInput' -``` - -**理由:依存関係の明確化、Tree-shaking効率化、バンドルサイズ最適化** - -## ⚛️ React実装パターン(厳格ルール) - -### Early Return必須パターン -```typescript -// ✅ 必ずEarly Returnを使用 -const processData = (data: Data | null) => { - if (!data) return null; - if (data.isEmpty()) return <EmptyState />; - if (data.hasError()) return <ErrorState />; - - // メイン処理 - return <MainContent data={data} />; -}; - -// ❌ 禁止: ネスト構造 -const processData = (data: Data | null) => { - if (data) { - if (!data.isEmpty()) { - // 深いネスト禁止 - } - } -}; -``` - -### 条件付きレンダリング -```typescript -// ✅ &&演算子使用 -{isLoading && <Spinner />} -{error && <ErrorMessage error={error} />} -{data && <DataDisplay data={data} />} - -// ✅ 複雑な条件はif文 -const renderContent = () => { - if (isLoading) return <LoadingState />; - if (error) return <ErrorState error={error} />; - if (!data) return <EmptyState />; - return <DataContent data={data} />; -}; -``` - -### Hooks使用方針 -```typescript -// ✅ useState基本、useReducer最小限 -const [count, setCount] = useState(0); - -// ✅ useEffect最小限(バグの原因) -useEffect(() => { - // 本当に必要な場合のみ -}, []); - -// ✅ カスタムフック分離基準 -const useDraftLogic = () => { - // テストしやすさを重視 - // モック化が必要な場合 -}; -``` - -## 🧪 テスト戦略(最小限で最大効果) - -### テスト方針 -- **Storybookはパターン最小限、VRTでカバー** -- **Interaction Test あまり書かない** -- **基本的には"Basic"のみ** -- **data-testid なるべく利用しない**(セマンティック要素優先) - -```typescript -// ✅ セマンティック要素重視 -role="textbox" -role="button" - -// ❌ data-testid は最後の手段 -data-testid="email" // 本当に必要な時のみ -``` - -### テスト実装パターン -```typescript -// ✅ 日本語命名必須 -describe('GachaForm', () => { - test('ガチャボタンをクリックできる', () => { - // テスト実装 - }); -}); - -// ✅ VRT重視のStorybook -export const Basic: StoryObj<typeof meta> = {} -// 複雑なパターンは避ける -``` - -## 🎯 品質・効率バランス戦略 - -### CI/CD設計思想 -- **段階的品質チェック**(push → ready → merge) -- **renovate除外**で自動更新時の無駄実行回避 -- **draft PR除外**で不要なリソース消費防止 - -### 定数化の実用的判断 -```typescript -// ✅ 型安全性に直結するもの → 必ず共通化 -export const commonSchemas = z.object({...}) - -// ✅ 本当に汎用的なもののみ → helpers化 -export const sleep = (ms: number) => new Promise(...) - -// ❌ 無理な定数化は避ける -// 文脈上自然でないものは各ドメインに残す -``` - -### TypeScript実用戦略 -```typescript -// ✅ スキーマから型を自動生成 -export type SchemaType = z.infer<typeof Schema> - -// ✅ 手動型定義は最小限 -type SearchQueryParams = { - ids: string - term: string - noStore?: string // デバッグ用オプション -} - -// ✅ 唯一のany許可ケース -// biome-ignore lint/suspicious/noExplicitAny: ライブラリ型不明のため -const libraryResult: any = externalLib.process() -``` - -## 🔧 エラーハンドリング実用戦略 - -### 条件分岐による制御 -```typescript -// ✅ 条件分岐で制御(推奨) -const historyPost = params.noStore - ? () => {} // デバッグ時は何もしない - : serverFetch<PostGachaHistory>(...); // 本番では実行 - -// ✅ 実用的なエラーハンドリング -const processRequest = async (request: Request) => { - if (!request.isValid()) { - return { error: 'Invalid request' }; - } - - try { - const result = await api.process(request); - return { data: result }; - } catch (error) { - return { error: error.message }; - } -}; -``` - -## 🎨 UI/UX実装統一ルール - -### アニメーション統一 -```typescript -// ✅ 150ms + easeOut統一 -transition={{ duration: 0.15, ease: "easeOut" }} -``` - -### レスポンシブ設計 -```typescript -// ✅ 2段階ブレイクポイント(PC/SP) -<Text fontSize={["sm", "md"]}> {/* SP: sm, PC: md */} -``` - -## やること(Claude Code最適化) -- ✅ Arrow Function(必須) -- ✅ type定義(interface禁止) -- ✅ const使用(let最小限) -- ✅ 分割代入(積極活用) -- ✅ async/await(Promise.then禁止) -- ✅ Early Return(必須) -- ✅ 日本語テスト(必須) -- ✅ Props Drilling(Context禁止) - -## やらないこと(厳格禁止) -- ❌ Function Declaration -- ❌ interface使用 -- ❌ React.FC使用 -- ❌ HOC/Render Props -- ❌ Context API -- ❌ Enum使用 -- ❌ 過度な最適化 -- ❌ any使用(ライブラリ除く) - -## 🚀 実行スタイル - -### コミュニケーション -- **理由付きで積極的に提案**(「〜だから〜しましょう」) -- **段階的に確認**しながら進める -- **時間がかかる作業は事前に報告** -- 迷ったときは選択肢を提示(「A案とB案どちらがいいですか?」) - -### 作業の進め方 -1. **現状分析**:対象コードの構造と問題点を特定 -2. **優先順位付け**:最も効果的な改善から順番に -3. **段階的実行**:一度にすべてではなく、確認しながら -4. **テスト重視**:リファクタ前後で挙動が変わらないことを確認 - -### 最終判断基準 -**「迷ったらやる!」** -- 共通化で迷ったら → やっておく -- 抽象化で迷ったら → やっておく -- 分割で迷ったら → やっておく -- テスト追加で迷ったら → やっておく - -**理由:後戻りより前進、リファクタは改善行為、やりすぎは後で直せる** - -## 🎯 今すぐ開始 - -対象コード($ARGUMENTS)を分析して、あなたの完全な思考プロセスに従って段階的にリファクタリングを実行します。 - -**最初に現状分析を行い、改善点を優先順位付けして提案します。理由と共に説明するので、一つずつ確認しながら進めましょう!** - -**Claude Code協働最適化された実用主義で、一貫性のある高品質なコードを作り上げます。** - diff --git a/doc/claude/soul.md b/doc/claude/soul.md new file mode 100644 index 00000000..99851e7c --- /dev/null +++ b/doc/claude/soul.md @@ -0,0 +1,5 @@ +## このAI自身の考え方をまとめる重要なファイル + +1. ユーザーの視点で物事を考えること +2. UXデザインの観点で解決策を考えること +3. UIで解決すること \ No newline at end of file diff --git "a/doc/features/\343\202\267\343\203\225\343\203\210\347\256\241\347\220\206.md" "b/doc/features/\343\202\267\343\203\225\343\203\210\347\256\241\347\220\206.md" index f3e1da10..dfe69d31 100644 --- "a/doc/features/\343\202\267\343\203\225\343\203\210\347\256\241\347\220\206.md" +++ "b/doc/features/\343\202\267\343\203\225\343\203\210\347\256\241\347\220\206.md" @@ -5,12 +5,27 @@ スタッフのシフト編集・管理を行う機能。シフト募集の作成、シフト確定、必要人員設定を含む。 PC版はドラッグ操作によるペイント/消去/リサイズ、SP版はBottomSheetによるSelect式編集に対応。 +### 管理者ワークフロー + +``` +管理者: 募集作成 → 申請確認 → (締め切り) → シフト編集 → 確定 → スタッフに通知 + いつでも編集可 ←────────────→ + 微修正も可 ←───→ + +スタッフ: メール受信 → 希望提出 → ─── 待ち ─── → 確定メール → シフト確認 +``` + +- 募集ステータス遷移: `open` → `closed` → `confirmed` +- 締め切りは手動(管理者がボタンを押す) +- シフト編集はいつでも可能(締切前から組み始められる) +- 確定後も微修正OK(ただし再通知メールは飛ばない) + ## 関連ファイル - **Routes**: `src/routes/_auth/shops/$shopId/shifts/` - **Pages**: `src/components/pages/Shops/` (ShiftsPage, RecruitmentNewPage, RecruitmentDetailPage, ShiftConfirmPage, StaffingSettingsPage) - **Features**: `src/components/features/Shift/` -- **Convex**: `convex/requiredStaffing/` +- **Convex**: `convex/recruitment/`, `convex/shiftRequest/`, `convex/shiftAssignment/`, `convex/requiredStaffing/` ## 主な機能 @@ -22,8 +37,13 @@ PC版はドラッグ操作によるペイント/消去/リサイズ、SP版はBo - ソート(デフォルト/希望順/出勤順、両ビュー共有) - 自動正規化(マージ + 休憩自動挿入) - シフト募集の作成・管理 +- 申請状況の確認(提出済み/未提出サマリ) +- 募集の締め切り(手動、open→closed) +- シフト割当の編集・保存(下書き) +- シフト確定 + スタッフへの確定通知メール(マジックリンク付き) +- 確定後の微修正(再通知なし) - 必要人員設定(StaffingRequirement) -- Read-Onlyモード +- Read-Onlyモード(スタッフの確定シフト閲覧、自分ハイライト) - スタッフハイライト ## データモデル @@ -54,14 +74,64 @@ RequiredStaffingData = { } ``` +### DBテーブル(convex/schema.ts) + +```typescript +// シフト募集テーブル +recruitments = { + shopId: Id<"shops">, + startDate: string, // "YYYY-MM-DD" + endDate: string, // "YYYY-MM-DD" + deadline: string, // "YYYY-MM-DD" + status: string, // "open" | "closed" | "confirmed" + appliedCount: number, // 申請済みスタッフ数 + totalStaffCount: number, // 作成時のアクティブスタッフ数 + confirmedAt?: number, // 確定日時 + createdBy: string, // authId + createdAt: number, + isDeleted: boolean, +} + +// シフト提出テーブル(スタッフがマジックリンクから提出) +shiftRequests = { + recruitmentId: Id<"recruitments">, + staffId: Id<"staffs">, + entries: { + date: string, // "YYYY-MM-DD" + isAvailable: boolean, + startTime?: string, // "09:00"(isAvailable=true時) + endTime?: string, // "17:00"(isAvailable=true時) + }[], + submittedAt: number, + updatedAt?: number, +} + +// シフト割当テーブル(管理者が編集・確定するシフト) +shiftAssignments = { + recruitmentId: Id<"recruitments">, + assignments: { + staffId: string, + date: string, // "YYYY-MM-DD" + positions: { + positionId: string, + positionName: string, + color: string, + start: string, // "09:00" + end: string, // "17:00" + }[], + }[], + updatedAt: number, +} +``` + ## 画面一覧 | 画面 | パス | 説明 | |------|------|------| | シフト管理 | `/shops/:shopId/shifts` | シフト編集メイン画面 | | シフト募集作成 | `/shops/:shopId/shifts/recruitments/new` | 募集作成 | -| シフト募集詳細 | `/shops/:shopId/shifts/recruitments/:id` | 募集詳細・シフト確定 | -| シフト確定 | `/shops/:shopId/shifts/recruitments/:id/confirm` | シフト確定画面 | +| シフト募集詳細 | `/shops/:shopId/shifts/recruitments/:id` | 募集詳細・申請状況確認 | +| シフト編集・確定 | `/shops/:shopId/shifts/recruitments/:id/confirm` | シフト編集・保存・締切・確定 | | 必要人員設定 | `/shops/:shopId/shifts/settings` | 必要人員の設定 | ## コンポーネント構成 @@ -80,8 +150,11 @@ Shift/ │ └── utils/ # ユーティリティ(shiftOperations, calculations等) ├── RecruitmentForm/ # 募集フォーム ├── RecruitmentList/ # 募集一覧 -├── RecruitmentDetail/ # 募集詳細 +├── RecruitmentDetail/ # 募集詳細(ステータス表示、締切ボタン) ├── RecruitmentNew/ # 募集新規作成 +├── ShiftConfirm/ # シフト編集・保存・締切・確定(管理者ワークフロー) +├── utils/ +│ └── transformRecruitmentData.ts # Convexデータ→ShiftForm用props変換 └── StaffingRequirement/ # 必要人員設定(PC/SP対応) ├── StaffingTable/ # 人員テーブル(PC: Table / SP: MobileAccordionView) ├── WeeklyHeatmap/ # 週間ヒートマップ @@ -99,11 +172,41 @@ Shift/ ## API -### Queries +### 募集(recruitment) + +#### Queries +- `recruitment.queries.getById` - 募集1件の詳細取得 +- `recruitment.queries.listByShop` - 店舗の募集一覧(非削除、日付降順) + +#### Mutations +- `recruitment.mutations.create` - 募集作成(マジックリンクトークン生成 + 通知メール送信) +- `recruitment.mutations.close` - 締め切り(open→closed) +- `recruitment.mutations.confirm` - 確定(closed→confirmed、確定通知メール送信) + +### シフト提出(shiftRequest) + +#### Queries +- `shiftRequest.queries.listByRecruitment` - 募集に紐づく全申請を取得 +- `shiftRequest.queries.getSubmitPageData` - マジックリンク用(ステータスに応じて提出フォーム or 確定シフト閲覧を返す) + +#### Mutations +- `shiftRequest.mutations.submit` - スタッフの希望提出/更新 + +### シフト割当(shiftAssignment) + +#### Queries +- `shiftAssignment.queries.getByRecruitment` - 管理者が編集したシフト割当データの取得 + +#### Mutations +- `shiftAssignment.mutations.save` - シフト割当の保存/更新(upsert) + +### 必要人員(requiredStaffing) + +#### Queries - `requiredStaffing.queries.getByShopId` - 店舗の全曜日分の必要人員設定を取得 - `requiredStaffing.queries.getByShopIdAndDay` - 特定曜日の必要人員設定を取得 -### Mutations +#### Mutations - `requiredStaffing.mutations.upsert` - 曜日単位の保存/更新 - `requiredStaffing.mutations.copyToMultipleDays` - 複数曜日への一括コピー - `requiredStaffing.mutations.saveAll` - 全曜日分一括保存(初期設定用) @@ -123,6 +226,25 @@ ShiftFormはJotai Providerでスコープされたアトムを使用(グロー | `toolModeAtom` | ツールモード(select/assign/erase、PCのみ) | | `selectedPositionIdAtom` | 選択中ポジションID | +### データ変換(transformRecruitmentData) + +RecruitmentDetailPage / ShiftConfirmPage で共用するデータ変換ユーティリティ。 + +- `generateDateRange(startDate, endDate)` - 募集期間の日付配列生成 +- `parseTimeRange(shop)` - 店舗の営業時間をTimeRangeに変換 +- `transformStaffs(staffList, shiftRequests)` - スタッフ一覧 + 提出済み判定 +- `transformPositions(positions)` - ポジション定義のマッピング(カラーフォールバック付き) +- `transformShiftRequests(...)` - 申請データ→ShiftData[]変換 +- `mergeAssignments(baseShifts, assignments, staffList)` - 保存済み割当をShiftDataにマージ + +### ShiftForm連携 + +ShiftFormはJotai Providerでスコープされており、外部からatomにアクセスできない。 +ShiftConfirmで保存するために `onShiftsChange` コールバックを使用。 + +- ShiftForm: `onShiftsChange?: (shifts: ShiftData[]) => void` プロップ +- ShiftConfirm: `useRef`で最新シフトデータを保持し、保存/確定時に参照 + ## 仕様書 - [シフト編集機能仕様](./detail//2026-02-08_シフト編集機能仕様.md) - ShiftFormの詳細仕様(操作・イベント・表示仕様) diff --git "a/doc/features/\343\203\235\343\202\270\343\202\267\343\203\247\343\203\263\347\256\241\347\220\206.md" "b/doc/features/\343\203\235\343\202\270\343\202\267\343\203\247\343\203\263\347\256\241\347\220\206.md" index 0698bbfa..7c230121 100644 --- "a/doc/features/\343\203\235\343\202\270\343\202\267\343\203\247\343\203\263\347\256\241\347\220\206.md" +++ "b/doc/features/\343\203\235\343\202\270\343\202\267\343\203\247\343\203\263\347\256\241\347\220\206.md" @@ -22,6 +22,7 @@ shopPositions = { shopId: Id<"shops">, // 所属店舗 name: string, // ポジション名(最大20文字) + color?: string, // HEXカラー("#3b82f6"等、未設定時はPOSITION_COLORSパレットからフォールバック) order: number, // 表示順 isDeleted: boolean, // 削除フラグ createdAt: number, // 作成日時 @@ -44,8 +45,11 @@ shopPositions = { ``` Shop/ -├── PositionManager/ # ポジション一覧・追加・削除 -└── PositionEditor/ # ポジション個別編集 +├── PositionManager/ # ポジション一覧・追加・削除(カラー選択対応) +└── PositionEditor/ # ポジション個別編集(カラー選択対応) + +ui/ +└── ColorPicker/ # プリセットカラー選択コンポーネント(10色パレット) ``` ## API @@ -54,7 +58,9 @@ Shop/ - `position.queries.listByShop` - 店舗のポジション一覧 ### Mutations -- `position.mutations.create` - ポジション作成 -- `position.mutations.update` - ポジション更新 -- `position.mutations.remove` - ポジション削除 -- `position.mutations.reorder` - 表示順変更 +- `position.mutations.create` - ポジション作成(カラー自動割当) +- `position.mutations.updateName` - ポジション名更新 +- `position.mutations.updateColor` - ポジションカラー更新 +- `position.mutations.remove` - ポジション削除(論理削除) +- `position.mutations.updateOrder` - 表示順変更 +- `position.mutations.initializeDefaultPositions` - 店舗作成時のデフォルトポジション初期化 diff --git "a/doc/plans/2026-03-01_\343\202\267\343\203\225\343\203\210\347\256\241\347\220\206_\347\256\241\347\220\206\350\200\205\343\203\257\343\203\274\343\202\257\343\203\225\343\203\255\343\203\274.md" "b/doc/plans/2026-03-01_\343\202\267\343\203\225\343\203\210\347\256\241\347\220\206_\347\256\241\347\220\206\350\200\205\343\203\257\343\203\274\343\202\257\343\203\225\343\203\255\343\203\274.md" new file mode 100644 index 00000000..b7f43fb0 --- /dev/null +++ "b/doc/plans/2026-03-01_\343\202\267\343\203\225\343\203\210\347\256\241\347\220\206_\347\256\241\347\220\206\350\200\205\343\203\257\343\203\274\343\202\257\343\203\225\343\203\255\343\203\274.md" @@ -0,0 +1,211 @@ +# シフト管理:管理者ワークフロー実装計画 + +## 背景 + +管理者がシフトの募集→受取→編集→確定→通知を一連の流れで行えるようにしたい。 +現状は「①募集する」と「スタッフの提出フロー」は完成しているが、②以降のデータの流れが通っていない。 + +## 決定事項 + +- **締め切り**: 手動のみ(管理者がボタンを押す) +- **シフト編集**: いつでも可能(締切前から組み始められる) +- **確定後の修正**: 微修正OK。ただし再通知メールは飛ばさない +- **スタッフ閲覧**: 全員のシフト表が見え、自分が強調表示。既存UIを使い回す + +## ワークフロー全体像 + +``` +管理者: 募集作成 → 申請確認 → (締め切り) → シフト編集 → 確定 → スタッフに通知 + ✅ ② ③ ④ ⑤ ⑥ + いつでも編集可 ←────────────→ + 微修正も可 ←───→ + +スタッフ: メール受信 → 希望提出 → ─── 待ち ─── → 確定メール → シフト確認 + ✅ ✅ ⑥ ⑥ +``` + +募集ステータス遷移: `open` → `closed` → `confirmed` + +--- + +## ② 申請を見る + +**管理者が募集詳細を開いたとき:** +- 提出状況サマリ(○人提出済み / ○人未提出) +- ShiftForm に提出済みスタッフの希望時間が反映されて表示される +- 募集中はリアルタイムに更新される(Convex のリアクティブquery) + +**必要なもの:** +- `recruitment.queries.getById` — 募集1件の詳細取得 +- `shiftRequest.queries.listByRecruitment` — 募集に紐づく全申請を取得 +- RecruitmentDetailPage をモックから実データに切り替え + +--- + +## ③ 締め切る + +**管理者が「締め切る」ボタンを押す:** +- ステータスを `open` → `closed` に変更 +- 以降、スタッフは新規提出・変更不可(提出ページに「締め切りました」表示) + +**必要なもの:** +- `recruitment.mutations.close` — ステータス変更 +- スタッフ提出ページ側で closed 状態のハンドリング + +--- + +## ④ シフトを組む(編集) + +**管理者がShiftFormでシフトを編集:** +- 既存のShiftForm UI をそのまま使う(D&D、Undo/Redo等) +- スタッフの希望をベースに、ポジション割当を調整 +- 編集結果は保存ボタンで保存(下書き) +- 締切前でも締切後でも編集可能 + +**必要なもの:** +- シフト編集データの保存先(DBテーブル) + - 募集1件に対して、管理者が編集したシフトデータを丸ごと保存 +- `shiftAssignment.mutations.save` — 編集結果の保存 +- `shiftAssignment.queries.getByRecruitment` — 編集済みデータの読み込み +- RecruitmentDetailPage で「閲覧モード」→「編集モード」の切り替え + +--- + +## ⑤ 確定する + +**管理者が「確定」ボタンを押す:** +- ステータスを `closed` → `confirmed` に変更 +- 確定日時を記録 +- 確認ダイアログを挟む(「確定すると全スタッフにメールが届きます」) + +**確定後:** +- ShiftFormは引き続き編集可能(微修正のため) +- 保存はできるが、再通知メールは飛ばない + +**必要なもの:** +- `recruitment.mutations.confirm` — ステータス変更 + メール送信スケジュール + +--- + +## ⑥ スタッフに知らせる + +**確定時にメール送信:** +- 全スタッフに確定通知メール(マジックリンク付き) +- 既存のメール送信の仕組み(Resend + Convex scheduler)を再利用 + +**スタッフがリンクを開いたとき:** +- 既存のシフト提出ページ(shift-submit)を拡張 + - 募集ステータスが `confirmed` の場合 → 提出フォームではなくシフト閲覧ビューを表示 +- ShiftForm を読み取り専用で表示(既存UIの使い回し) + - 全員のシフトが見える + - 自分の行が強調表示される(`currentStaffId` プロップで対応可能) + +**必要なもの:** +- 確定通知メールのテンプレート +- shift-submit ページでのステータス分岐(open→提出フォーム / confirmed→閲覧) +- `shiftRequest.queries.getSubmitPageData` を拡張(確定シフトデータも返す) + +--- + +## 実装の進め方(提案) + +バックエンドのデータの流れを先に通し、段階的にUIを接続していく。 + +### Step 1: データ基盤 +- シフト編集データのDBテーブル追加 +- 募集詳細・申請一覧のquery追加 +- 締め切り・確定のmutation追加 + +### Step 2: 管理者フロー接続 + ポジションカラー + +#### 変更ファイル一覧 + +**既存ファイルの修正:** +1. `src/components/pages/Shops/RecruitmentDetailPage/index.tsx` — モック削除、useQuery接続 +2. `src/components/features/Shift/RecruitmentDetail/index.tsx` — ステータス表示、締切mutation追加 +3. `src/components/pages/Shops/ShiftConfirmPage/index.tsx` — モック削除、useQuery接続 +4. `src/components/features/Shift/ShiftForm/index.tsx` — `onShiftsChange` コールバック追加 +5. `convex/schema.ts` — shopPositions に color カラム追加 +6. `convex/position/mutations.ts` — create に color 対応、updateColor 追加 +7. `convex/constants.ts` — POSITION_COLORS パレット追加(済) +8. `src/components/features/Shop/PositionManager/index.tsx` — カラー選択UI追加 +9. `src/components/features/Shop/PositionEditor/index.tsx` — カラー選択UI追加 + +**新規ファイル:** +10. `src/components/features/Shift/ShiftConfirm/index.tsx` — 編集ページのfeatureコンポーネント +11. `src/components/features/Shift/utils/transformRecruitmentData.ts` — データ変換ヘルパー +12. `src/components/ui/ColorPicker/index.tsx` — プリセットカラー選択コンポーネント + +#### 2-1: RecruitmentDetailPage → 実データ接続 + +**pages層** (`RecruitmentDetailPage/index.tsx`): +- useQuery で5つのデータを取得: + - `recruitment.queries.getById` — 募集詳細 + - `shiftRequest.queries.listByRecruitment` — 全申請 + - `shop.queries.listStaffs` — スタッフ一覧(提出/未提出の判定に必要) + - `position.queries.listByShop` — ポジション定義 + - `shop.queries.getById` — 店舗情報(timeRange算出) +- データ変換ロジック: + - dates: recruitment.startDate〜endDate の日付配列を生成 + - staffs → `StaffType[]`: スタッフ一覧 + shiftRequestsで提出済み判定 + - positions → `PositionType[]`: `{ id: _id, name, color }` にマッピング + - shifts → `ShiftData[]`: shiftRequestsのentries展開。isAvailable=trueの日 → requestedTime付きShiftData + - timeRange: `{ start: parseInt(shop.openTime), end: parseInt(shop.closeTime), unit: shop.timeUnit }` +- loading/error/not-found の振り分け + +**features層** (`RecruitmentDetail/index.tsx`): +- 新しいprops追加: `recruitmentStatus`, `recruitmentDeadline` +- ステータスバッジ表示(募集中/締切済み/確定済み) +- ボタンをステータスに応じて変更: + - open: 「編集する」→ confirm ページへ, 「締め切る」→ close mutation + - closed: 「編集する」→ confirm ページへ + - confirmed: 「編集する」→ confirm ページへ(微修正用) +- `useMutation(api.recruitment.mutations.close)` を定義 +- 締切確認ダイアログ追加 + +#### 2-2: ShiftForm に `onShiftsChange` コールバック追加 + +ShiftFormはJotai Providerでスコープされており、外部からatomにアクセスできない。 +編集ページで保存するために、シフトデータの変更を親に通知する仕組みが必要。 + +- propsに `onShiftsChange?: (shifts: ShiftData[]) => void` を追加 +- ShiftFormInner内で `shiftsAtom` の変化を監視し、コールバックを呼ぶ + +#### 2-3: ShiftConfirmPage → 実データ接続 + 保存/締切/確定 + +**pages層** (`ShiftConfirmPage/index.tsx`): +- useQuery で6つのデータを取得(2-1の5つ + shiftAssignment): + - `shiftAssignment.queries.getByRecruitment` — 保存済みシフト割当 +- データ変換: 2-1と同じ + shiftAssignmentのpositionsをShiftDataに反映 + +**新規features層** (`ShiftConfirm/index.tsx`): +- ShiftForm を `isReadOnly=false` で配置 +- `onShiftsChange` で最新シフトデータをuseRefに保持 +- 3つのアクションボタン: + - **保存**: `shiftAssignment.mutations.save` 呼び出し(常時表示) + - **締め切る**: `recruitment.mutations.close` 呼び出し(open時のみ、確認ダイアログ付き) + - **確定**: `recruitment.mutations.confirm` 呼び出し(closed時のみ、確認ダイアログ「全スタッフにメールが届きます」) +- 確定済みの場合も編集・保存可能(ただし再通知なし) + +#### 2-4: ポジションカラー機能(DB + UI) + +**DB層:** +- `convex/schema.ts`: shopPositions に `color: v.optional(v.string())` を追加 +- `convex/position/mutations.ts`: create に color、updateColor mutation 追加 +- `convex/constants.ts`: `POSITION_COLORS` パレット定数(済) + +**UI層 — プリセットカラー選択:** +- `src/components/ui/ColorPicker/index.tsx`: 10色プリセットの丸ボタン選択 +- `PositionManager/index.tsx`: カラー選択追加(ポジション名左に色丸 + 編集時ColorPicker) +- `PositionEditor/index.tsx`: カラー選択追加(LocalPosition型にcolor追加) +- フォールバック: `color ?? POSITION_COLORS[index % POSITION_COLORS.length]` + +#### データ変換ヘルパー(共通化) + +`src/components/features/Shift/utils/transformRecruitmentData.ts`: +- `generateDateRange(startDate, endDate): string[]` +- `transformToShiftFormProps(...)`: Convexデータ → ShiftForm用props変換 + +### Step 3: スタッフ通知・閲覧 +- 確定時メール送信 +- shift-submit ページの確定シフト閲覧モード diff --git a/src/components/features/Shift/RecruitmentDetail/index.stories.tsx b/src/components/features/Shift/RecruitmentDetail/index.stories.tsx index 0e72bc8b..bd3352fe 100644 --- a/src/components/features/Shift/RecruitmentDetail/index.stories.tsx +++ b/src/components/features/Shift/RecruitmentDetail/index.stories.tsx @@ -66,10 +66,11 @@ const mockShifts = [ }, ]; -export const Basic: Story = { +export const Open: Story = { args: { shopId: "shop_1", recruitmentId: "recruitment_1", + recruitmentStatus: "open", staffs: mockStaffs, positions: mockPositions, shifts: mockShifts, @@ -78,3 +79,17 @@ export const Basic: Story = { holidays: [], }, }; + +export const Closed: Story = { + args: { + ...Open.args, + recruitmentStatus: "closed", + }, +}; + +export const Confirmed: Story = { + args: { + ...Open.args, + recruitmentStatus: "confirmed", + }, +}; diff --git a/src/components/features/Shift/RecruitmentDetail/index.tsx b/src/components/features/Shift/RecruitmentDetail/index.tsx index 33a8ac52..8f78940e 100644 --- a/src/components/features/Shift/RecruitmentDetail/index.tsx +++ b/src/components/features/Shift/RecruitmentDetail/index.tsx @@ -1,13 +1,20 @@ import { Badge, Box, Button, Card, Container, Flex, Heading, HStack, Icon, Text } from "@chakra-ui/react"; import { useNavigate } from "@tanstack/react-router"; +import { useMutation } from "convex/react"; import dayjs from "dayjs"; import "dayjs/locale/ja"; +import { useAtomValue } from "jotai"; +import { useState } from "react"; import { LuCalendar, LuPencilLine } from "react-icons/lu"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; 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 { Dialog, useDialog } from "@/src/components/ui/Dialog"; import { Title } from "@/src/components/ui/Title"; import { toaster } from "@/src/components/ui/toaster"; +import { userAtom } from "@/src/stores/user"; dayjs.locale("ja"); @@ -17,9 +24,16 @@ const formatDateRange = (startDate: string, endDate: string) => { return `${start.format("M/D(ddd)")} 〜 ${end.format("M/D(ddd)")}`; }; +const STATUS_BADGE = { + open: { colorPalette: "green", label: "募集中" }, + closed: { colorPalette: "orange", label: "締切済み" }, + confirmed: { colorPalette: "blue", label: "確定済み" }, +} as const; + type RecruitmentDetailProps = { shopId: string; recruitmentId: string; + recruitmentStatus: "open" | "closed" | "confirmed"; staffs: StaffType[]; positions: PositionType[]; shifts: ShiftData[]; @@ -31,6 +45,7 @@ type RecruitmentDetailProps = { export const RecruitmentDetail = ({ shopId, recruitmentId, + recruitmentStatus, staffs, positions, shifts, @@ -39,45 +54,96 @@ export const RecruitmentDetail = ({ holidays, }: RecruitmentDetailProps) => { const navigate = useNavigate(); + const user = useAtomValue(userAtom); + const closeMutation = useMutation(api.recruitment.mutations.close); + const closeDialog = useDialog(); + const [isClosing, setIsClosing] = useState(false); const submittedCount = staffs.filter((s) => s.isSubmitted).length; const unsubmittedCount = staffs.length - submittedCount; - const handleCloseAndEdit = () => { - // TODO: 募集締め切りのuseMutation呼び出し - console.log("締切・編集:", recruitmentId); - toaster.create({ - description: "募集を締め切りました", - type: "success", - }); + const navigateToConfirm = () => { navigate({ to: "/shops/$shopId/shifts/recruitments/$recruitmentId/confirm", params: { shopId, recruitmentId }, }); }; + const handleClose = async () => { + if (!user.authId) return; + setIsClosing(true); + try { + await closeMutation({ + recruitmentId: recruitmentId as Id<"recruitments">, + authId: user.authId, + }); + closeDialog.close(); + toaster.create({ description: "募集を締め切りました", type: "success" }); + navigateToConfirm(); + } catch { + toaster.create({ description: "締め切りに失敗しました", type: "error" }); + } finally { + setIsClosing(false); + } + }; + const dateRangeLabel = dates.length > 0 ? formatDateRange(dates[0], dates[dates.length - 1]) : ""; + const badge = STATUS_BADGE[recruitmentStatus]; + + const actionButton = + recruitmentStatus === "open" ? ( + <HStack gap={2}> + <Button variant="outline" size="sm" onClick={navigateToConfirm}> + <LuPencilLine /> + 編集する + </Button> + <Button colorPalette="orange" size="sm" onClick={closeDialog.open}> + 締め切る + </Button> + </HStack> + ) : ( + <Button colorPalette="teal" size="sm" onClick={navigateToConfirm}> + <LuPencilLine /> + 編集する + </Button> + ); + + const mobileActionButton = + recruitmentStatus === "open" ? ( + <Flex gap={2} mb={4}> + <Button flex={1} variant="outline" onClick={navigateToConfirm}> + <LuPencilLine /> + 編集する + </Button> + <Button flex={1} colorPalette="orange" onClick={closeDialog.open}> + 締め切る + </Button> + </Flex> + ) : ( + <Button w="full" colorPalette="teal" onClick={navigateToConfirm} mb={4}> + <LuPencilLine /> + 編集する + </Button> + ); return ( <Container maxW="6xl"> {/* ヘッダー */} <Title prev={{ url: `/shops/${shopId}/shifts`, label: "シフト管理に戻る" }} - action={ - <Button colorPalette="teal" size="sm" onClick={handleCloseAndEdit} display={{ base: "none", md: "flex" }}> - <LuPencilLine /> - 締切・編集へ - </Button> - } + action={<Box display={{ base: "none", md: "flex" }}>{actionButton}</Box>} > <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> + <HStack gap={2}> + <Heading as="h2" size="xl" color="gray.900"> + シフト募集詳細 + </Heading> + <Badge colorPalette={badge.colorPalette}>{badge.label}</Badge> + </HStack> {dateRangeLabel && ( <Text fontSize="sm" color="gray.500"> {dateRangeLabel} @@ -103,10 +169,7 @@ export const RecruitmentDetail = ({ </Card.Root> {/* モバイル用アクションボタン */} - <Button w="full" colorPalette="teal" onClick={handleCloseAndEdit} display={{ base: "flex", md: "none" }} mb={4}> - <LuPencilLine /> - 締切・編集へ - </Button> + <Box display={{ base: "block", md: "none" }}>{mobileActionButton}</Box> {/* ShiftForm: 一覧モード固定、readOnly、シフト希望順 */} <ShiftForm @@ -123,6 +186,21 @@ export const RecruitmentDetail = ({ initialSortMode="request" /> </Animation> + + {/* 締切確認ダイアログ */} + <Dialog + title="募集を締め切りますか?" + isOpen={closeDialog.isOpen} + onOpenChange={closeDialog.onOpenChange} + onSubmit={handleClose} + submitLabel="締め切る" + submitColorPalette="orange" + onClose={closeDialog.close} + isLoading={isClosing} + role="alertdialog" + > + <Text>締め切ると、スタッフは新たにシフト希望を提出できなくなります。</Text> + </Dialog> </Container> ); }; diff --git a/src/components/features/Shift/RecruitmentList/index.tsx b/src/components/features/Shift/RecruitmentList/index.tsx index 8c5457e2..c08d8a92 100644 --- a/src/components/features/Shift/RecruitmentList/index.tsx +++ b/src/components/features/Shift/RecruitmentList/index.tsx @@ -36,7 +36,7 @@ type RecruitmentListProps = { const STATUS_CONFIG = { open: { label: "募集中", colorPalette: "teal", iconBg: "teal.50" }, closed: { label: "締切済み", colorPalette: "orange", iconBg: "orange.50" }, - confirmed: { label: "確定済み", colorPalette: "gray", iconBg: "gray.100" }, + confirmed: { label: "確定済み", colorPalette: "blue", iconBg: "blue.50" }, } as const; const formatDateRange = (startDate: string, endDate: string) => { diff --git a/src/components/features/Shift/ShiftConfirm/index.tsx b/src/components/features/Shift/ShiftConfirm/index.tsx new file mode 100644 index 00000000..f86096f5 --- /dev/null +++ b/src/components/features/Shift/ShiftConfirm/index.tsx @@ -0,0 +1,254 @@ +import { Badge, Box, Button, Container, Flex, Heading, HStack, Icon, Text } from "@chakra-ui/react"; +import { useMutation } from "convex/react"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import { useAtomValue } from "jotai"; +import { useCallback, useRef, useState } from "react"; +import { LuCalendar, LuCheck, LuSave } from "react-icons/lu"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +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 { Dialog, useDialog } from "@/src/components/ui/Dialog"; +import { Title } from "@/src/components/ui/Title"; +import { toaster } from "@/src/components/ui/toaster"; +import { userAtom } from "@/src/stores/user"; + +dayjs.locale("ja"); + +const STATUS_BADGE = { + open: { colorPalette: "green", label: "募集中" }, + closed: { colorPalette: "orange", label: "締切済み" }, + confirmed: { colorPalette: "blue", label: "確定済み" }, +} as const; + +type ShiftConfirmProps = { + shopId: string; + recruitmentId: string; + recruitmentStatus: "open" | "closed" | "confirmed"; + staffs: StaffType[]; + positions: PositionType[]; + initialShifts: ShiftData[]; + dates: string[]; + timeRange: TimeRange; + holidays: string[]; +}; + +export const ShiftConfirm = ({ + shopId, + recruitmentId, + recruitmentStatus, + staffs, + positions, + initialShifts, + dates, + timeRange, + holidays, +}: ShiftConfirmProps) => { + const user = useAtomValue(userAtom); + + const saveMutation = useMutation(api.shiftAssignment.mutations.save); + const closeMutation = useMutation(api.recruitment.mutations.close); + const confirmMutation = useMutation(api.recruitment.mutations.confirm); + + const shiftsRef = useRef<ShiftData[]>(initialShifts); + const handleShiftsChange = useCallback((shifts: ShiftData[]) => { + shiftsRef.current = shifts; + }, []); + + const [isSaving, setIsSaving] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + + const closeDialog = useDialog(); + const confirmDialog = useDialog(); + + const saveShifts = async () => { + const assignments = shiftsRef.current + .filter((s) => s.positions.length > 0) + .map((s) => ({ + staffId: s.staffId, + date: s.date, + positions: s.positions.map((p) => ({ + positionId: p.positionId, + positionName: p.positionName, + color: p.color, + start: p.start, + end: p.end, + })), + })); + await saveMutation({ + recruitmentId: recruitmentId as Id<"recruitments">, + assignments, + }); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + await saveShifts(); + toaster.create({ description: "シフトを保存しました", type: "success" }); + } catch { + toaster.create({ description: "シフトの保存に失敗しました", type: "error" }); + } finally { + setIsSaving(false); + } + }; + + const handleClose = async () => { + if (!user.authId) return; + setIsClosing(true); + try { + await closeMutation({ + recruitmentId: recruitmentId as Id<"recruitments">, + authId: user.authId, + }); + closeDialog.close(); + toaster.create({ description: "募集を締め切りました", type: "success" }); + } catch { + toaster.create({ description: "締め切りに失敗しました", type: "error" }); + } finally { + setIsClosing(false); + } + }; + + const handleConfirm = async () => { + if (!user.authId) return; + setIsConfirming(true); + try { + await saveShifts(); + await confirmMutation({ + recruitmentId: recruitmentId as Id<"recruitments">, + authId: user.authId, + }); + confirmDialog.close(); + toaster.create({ description: "シフトを確定しました。スタッフにメールが送信されます。", type: "success" }); + } catch { + toaster.create({ description: "確定に失敗しました", type: "error" }); + } finally { + setIsConfirming(false); + } + }; + + const dateRangeLabel = + dates.length > 0 + ? `${dayjs(dates[0]).format("M/D(ddd)")} 〜 ${dayjs(dates[dates.length - 1]).format("M/D(ddd)")}` + : ""; + const badge = STATUS_BADGE[recruitmentStatus]; + + return ( + <Container maxW="6xl"> + <Title + prev={{ + url: `/shops/${shopId}/shifts/recruitments/${recruitmentId}`, + label: "募集詳細に戻る", + }} + action={ + <HStack gap={2} display={{ base: "none", md: "flex" }}> + <Button variant="outline" size="sm" onClick={handleSave} loading={isSaving}> + <LuSave /> + 保存 + </Button> + {recruitmentStatus === "open" && ( + <Button colorPalette="orange" size="sm" onClick={closeDialog.open}> + 締め切る + </Button> + )} + {recruitmentStatus === "closed" && ( + <Button colorPalette="teal" size="sm" onClick={confirmDialog.open}> + <LuCheck /> + 確定する + </Button> + )} + {recruitmentStatus === "confirmed" && <Badge colorPalette="blue">確定済み</Badge>} + </HStack> + } + > + <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> + <HStack gap={2}> + <Heading as="h2" size="xl" color="gray.900"> + シフト編集 + </Heading> + <Badge colorPalette={badge.colorPalette}>{badge.label}</Badge> + </HStack> + {dateRangeLabel && ( + <Text fontSize="sm" color="gray.500"> + {dateRangeLabel} + </Text> + )} + </Box> + </Flex> + + + + {/* モバイル用アクションボタン */} + + + {recruitmentStatus === "open" && ( + + )} + {recruitmentStatus === "closed" && ( + + )} + + + + + + {/* 締切確認ダイアログ */} + + 締め切ると、スタッフは新たにシフト希望を提出できなくなります。 + + + {/* 確定ダイアログ */} + + 確定すると、全スタッフにメールが送信されます。 + + 確定後もシフトの編集・保存は可能です(再通知はされません)。 + + + + ); +}; diff --git a/src/components/features/Shift/ShiftForm/index.tsx b/src/components/features/Shift/ShiftForm/index.tsx index c197ac52..4b8e6367 100644 --- a/src/components/features/Shift/ShiftForm/index.tsx +++ b/src/components/features/Shift/ShiftForm/index.tsx @@ -1,5 +1,6 @@ import { Box, Flex, HStack, IconButton, SegmentGroup } from "@chakra-ui/react"; -import { Provider, useAtom } from "jotai"; +import { Provider, useAtom, useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; import { LuRedo2, LuUndo2 } from "react-icons/lu"; import { useShiftFormInit } from "./hooks/useShiftFormInit"; import { useUndoRedo } from "./hooks/useUndoRedo"; @@ -7,7 +8,7 @@ import { DailyView } from "./pc/DailyView"; import { OverviewView } from "./pc/OverviewView"; import { SPDailyView } from "./sp/DailyView"; import { SPOverviewView } from "./sp/OverviewView"; -import { viewModeAtom } from "./stores"; +import { shiftsAtom, viewModeAtom } from "./stores"; import type { PositionType, RequiredStaffingData, ShiftData, SortMode, StaffType, TimeRange, ViewMode } from "./types"; const VIEW_OPTIONS = [ @@ -35,6 +36,7 @@ type ShiftFormProps = { initialViewMode?: ViewMode; hideViewSwitcher?: boolean; initialSortMode?: SortMode; + onShiftsChange?: (shifts: ShiftData[]) => void; }; const ShiftFormInner = ({ @@ -52,6 +54,7 @@ const ShiftFormInner = ({ initialViewMode, hideViewSwitcher = false, initialSortMode, + onShiftsChange, }: ShiftFormProps) => { // props → atoms 初期化 useShiftFormInit({ @@ -70,6 +73,15 @@ const ShiftFormInner = ({ initialSortMode, }); + // シフトデータの変更を親に通知 + const shifts = useAtomValue(shiftsAtom); + const onShiftsChangeRef = useRef(onShiftsChange); + onShiftsChangeRef.current = onShiftsChange; + + useEffect(() => { + onShiftsChangeRef.current?.(shifts); + }, [shifts]); + const [viewMode, setViewMode] = useAtom(viewModeAtom); const { undo, redo, canUndo, canRedo } = useUndoRedo(); diff --git a/src/components/features/Shift/utils/transformRecruitmentData.test.ts b/src/components/features/Shift/utils/transformRecruitmentData.test.ts new file mode 100644 index 00000000..1a39a501 --- /dev/null +++ b/src/components/features/Shift/utils/transformRecruitmentData.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "vitest"; +import { + generateDateRange, + mergeAssignments, + parseTimeRange, + transformPositions, + transformShiftRequests, + transformStaffs, +} from "./transformRecruitmentData"; + +describe("generateDateRange", () => { + test("1日のみ", () => { + expect(generateDateRange("2026-01-01", "2026-01-01")).toEqual(["2026-01-01"]); + }); + + test("複数日", () => { + expect(generateDateRange("2026-01-01", "2026-01-03")).toEqual(["2026-01-01", "2026-01-02", "2026-01-03"]); + }); + + test("月跨ぎ", () => { + expect(generateDateRange("2026-01-30", "2026-02-01")).toEqual(["2026-01-30", "2026-01-31", "2026-02-01"]); + }); +}); + +describe("parseTimeRange", () => { + test("標準的な営業時間", () => { + expect(parseTimeRange({ openTime: "09:00", closeTime: "22:00", timeUnit: 30 })).toEqual({ + start: 9, + end: 22, + unit: 30, + }); + }); + + test("深夜営業", () => { + expect(parseTimeRange({ openTime: "17:00", closeTime: "02:00", timeUnit: 60 })).toEqual({ + start: 17, + end: 2, + unit: 60, + }); + }); +}); + +describe("transformStaffs", () => { + const staffList = [ + { _id: "s1", displayName: "田中太郎", status: "active" }, + { _id: "s2", displayName: "山田花子", status: "active" }, + { _id: "s3", displayName: "佐藤一郎", status: "resigned" }, + ]; + + test("提出済み判定と退職者除外", () => { + const shiftRequests = [{ _id: "r1", staffId: "s1", entries: [] }]; + const result = transformStaffs({ staffList, shiftRequests }); + expect(result).toEqual([ + { id: "s1", name: "田中太郎", isSubmitted: true }, + { id: "s2", name: "山田花子", isSubmitted: false }, + ]); + }); + + test("申請なしの場合は全員未提出", () => { + const result = transformStaffs({ staffList, shiftRequests: [] }); + expect(result).toHaveLength(2); + expect(result.every((s) => !s.isSubmitted)).toBe(true); + }); +}); + +describe("transformPositions", () => { + test("color ありはそのまま使用", () => { + const positions = [{ _id: "p1", name: "ホール", color: "#ff0000", order: 0 }]; + expect(transformPositions(positions)).toEqual([{ id: "p1", name: "ホール", color: "#ff0000" }]); + }); + + test("color なしはフォールバック", () => { + const positions = [{ _id: "p1", name: "ホール", color: undefined, order: 0 }]; + const result = transformPositions(positions); + expect(result[0].color).toBe("#3b82f6"); // POSITION_COLORS[0] + }); + + test("空配列", () => { + expect(transformPositions([])).toEqual([]); + }); +}); + +describe("transformShiftRequests", () => { + const staffList = [ + { _id: "s1", displayName: "田中太郎", status: "active" }, + { _id: "s2", displayName: "山田花子", status: "active" }, + ]; + const positions = [{ id: "p1", name: "ホール", color: "#3b82f6" }]; + + test("isAvailable=true のエントリのみ変換", () => { + const shiftRequests = [ + { + _id: "r1", + staffId: "s1", + entries: [ + { date: "2026-01-01", isAvailable: true, startTime: "09:00", endTime: "17:00" }, + { date: "2026-01-02", isAvailable: false }, + ], + }, + ]; + const result = transformShiftRequests({ shiftRequests, staffList, positions }); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: "s1_2026-01-01", + staffId: "s1", + staffName: "田中太郎", + date: "2026-01-01", + requestedTime: { start: "09:00", end: "17:00" }, + positions: [], + }); + }); + + test("時間なしの場合は requestedTime が null", () => { + const shiftRequests = [ + { + _id: "r1", + staffId: "s1", + entries: [{ date: "2026-01-01", isAvailable: true }], + }, + ]; + const result = transformShiftRequests({ shiftRequests, staffList, positions }); + expect(result[0].requestedTime).toBeNull(); + }); +}); + +describe("mergeAssignments", () => { + const staffList = [ + { _id: "s1", displayName: "田中太郎", status: "active" }, + { _id: "s2", displayName: "山田花子", status: "active" }, + ]; + + const baseShifts = [ + { + id: "s1_2026-01-01", + staffId: "s1", + staffName: "田中太郎", + date: "2026-01-01", + requestedTime: { start: "09:00", end: "17:00" }, + positions: [], + }, + ]; + + test("assignments が null なら baseShifts をそのまま返す", () => { + const result = mergeAssignments({ baseShifts, assignments: null, staffList }); + expect(result).toEqual(baseShifts); + }); + + test("既存シフトのポジションを上書き", () => { + const assignments = { + assignments: [ + { + staffId: "s1", + date: "2026-01-01", + positions: [{ positionId: "p1", positionName: "ホール", color: "#3b82f6", start: "09:00", end: "13:00" }], + }, + ], + }; + const result = mergeAssignments({ baseShifts, assignments, staffList }); + expect(result).toHaveLength(1); + expect(result[0].positions).toHaveLength(1); + expect(result[0].positions[0].positionName).toBe("ホール"); + expect(result[0].positions[0].start).toBe("09:00"); + }); + + test("管理者が追加したシフトが追加される", () => { + const assignments = { + assignments: [ + { + staffId: "s2", + date: "2026-01-01", + positions: [{ positionId: "p1", positionName: "ホール", color: "#3b82f6", start: "10:00", end: "14:00" }], + }, + ], + }; + const result = mergeAssignments({ baseShifts, assignments, staffList }); + expect(result).toHaveLength(2); + const added = result.find((s) => s.staffId === "s2"); + expect(added).toBeDefined(); + expect(added?.staffName).toBe("山田花子"); + expect(added?.requestedTime).toBeNull(); + expect(added?.positions).toHaveLength(1); + }); + + test("空ポジションの assignment は追加しない", () => { + const assignments = { + assignments: [{ staffId: "s2", date: "2026-01-01", positions: [] }], + }; + const result = mergeAssignments({ baseShifts, assignments, staffList }); + expect(result).toHaveLength(1); + }); +}); diff --git a/src/components/features/Shift/utils/transformRecruitmentData.ts b/src/components/features/Shift/utils/transformRecruitmentData.ts new file mode 100644 index 00000000..14d34dbe --- /dev/null +++ b/src/components/features/Shift/utils/transformRecruitmentData.ts @@ -0,0 +1,168 @@ +import dayjs from "dayjs"; +import { POSITION_COLORS } from "@/convex/constants"; +import type { PositionType, ShiftData, StaffType, TimeRange } from "../ShiftForm/types"; + +// ========================================== +// Convex レスポンス型(useQuery の戻り値に対応) +// ========================================== + +type ConvexStaff = { + _id: string; + displayName: string; + status: string; +}; + +type ConvexShiftRequest = { + _id: string; + staffId: string; + entries: { + date: string; + isAvailable: boolean; + startTime?: string; + endTime?: string; + }[]; +}; + +type ConvexPosition = { + _id: string; + name: string; + color?: string; + order: number; +}; + +type ConvexShiftAssignment = { + assignments: { + staffId: string; + date: string; + positions: { + positionId: string; + positionName: string; + color: string; + start: string; + end: string; + }[]; + }[]; +} | null; + +// ========================================== +// 変換関数 +// ========================================== + +/** 開始日〜終了日の日付配列を生成(YYYY-MM-DD) */ +export const generateDateRange = (startDate: string, endDate: string): 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; +}; + +/** 店舗の営業時間 → TimeRange に変換 */ +export const parseTimeRange = (shop: { openTime: string; closeTime: string; timeUnit: number }): TimeRange => ({ + start: Number.parseInt(shop.openTime.split(":")[0], 10), + end: Number.parseInt(shop.closeTime.split(":")[0], 10), + unit: shop.timeUnit, +}); + +/** スタッフ一覧 → StaffType[] に変換(提出済み判定付き) */ +export const transformStaffs = (params: { + staffList: ConvexStaff[]; + shiftRequests: ConvexShiftRequest[]; +}): StaffType[] => { + const submittedStaffIds = new Set(params.shiftRequests.map((r) => r.staffId)); + return params.staffList + .filter((s) => s.status !== "resigned") + .map((s) => ({ + id: s._id, + name: s.displayName, + isSubmitted: submittedStaffIds.has(s._id), + })); +}; + +/** ポジション定義 → PositionType[] に変換(color fallback 付き) */ +export const transformPositions = (positions: ConvexPosition[]): PositionType[] => + positions.map((p, index) => ({ + id: p._id, + name: p.name, + color: p.color ?? POSITION_COLORS[index % POSITION_COLORS.length], + })); + +/** シフト申請 → ShiftData[] に変換(isAvailable=true のエントリのみ展開) */ +export const transformShiftRequests = (params: { + shiftRequests: ConvexShiftRequest[]; + staffList: ConvexStaff[]; + positions: PositionType[]; +}): ShiftData[] => { + const staffMap = new Map(params.staffList.map((s) => [s._id, s.displayName])); + const shifts: ShiftData[] = []; + + for (const req of params.shiftRequests) { + const staffName = staffMap.get(req.staffId) ?? ""; + for (const entry of req.entries) { + if (!entry.isAvailable) continue; + shifts.push({ + id: `${req.staffId}_${entry.date}`, + staffId: req.staffId, + staffName, + date: entry.date, + requestedTime: entry.startTime && entry.endTime ? { start: entry.startTime, end: entry.endTime } : null, + positions: [], + }); + } + } + + return shifts; +}; + +/** 保存済み shiftAssignment のポジションを ShiftData にマージ */ +export const mergeAssignments = (params: { + baseShifts: ShiftData[]; + assignments: ConvexShiftAssignment; + staffList: ConvexStaff[]; +}): ShiftData[] => { + if (!params.assignments) return params.baseShifts; + + const staffMap = new Map(params.staffList.map((s) => [s._id, s.displayName])); + const result = params.baseShifts.map((s) => ({ ...s, positions: [...s.positions] })); + const shiftMap = new Map(result.map((s) => [`${s.staffId}_${s.date}`, s])); + + for (const a of params.assignments.assignments) { + const key = `${a.staffId}_${a.date}`; + const existing = shiftMap.get(key); + + if (existing) { + // 保存済みポジションで上書き + existing.positions = a.positions.map((p, i) => ({ + id: `${key}_pos_${i}`, + positionId: p.positionId, + positionName: p.positionName, + color: p.color, + start: p.start, + end: p.end, + })); + } else if (a.positions.length > 0) { + // 管理者が追加したシフト(元の申請にないもの) + const newShift: ShiftData = { + id: key, + staffId: a.staffId, + staffName: staffMap.get(a.staffId) ?? "", + date: a.date, + requestedTime: null, + positions: a.positions.map((p, i) => ({ + id: `${key}_pos_${i}`, + positionId: p.positionId, + positionName: p.positionName, + color: p.color, + start: p.start, + end: p.end, + })), + }; + result.push(newShift); + } + } + + return result; +}; diff --git a/src/components/features/ShiftSubmit/ConfirmedView.tsx b/src/components/features/ShiftSubmit/ConfirmedView.tsx new file mode 100644 index 00000000..05a11e29 --- /dev/null +++ b/src/components/features/ShiftSubmit/ConfirmedView.tsx @@ -0,0 +1,102 @@ +import { Badge, Box, Center, Heading, Icon, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import "dayjs/locale/ja"; +import { LuCalendarCheck } from "react-icons/lu"; +import { ShiftForm } from "@/src/components/features/Shift/ShiftForm"; +import { + generateDateRange, + mergeAssignments, + parseTimeRange, + transformPositions, + transformShiftRequests, + transformStaffs, +} from "@/src/components/features/Shift/utils/transformRecruitmentData"; + +dayjs.locale("ja"); + +type ConfirmedViewProps = { + staff: { _id: string; displayName: string }; + shop: { shopName: string; timeUnit: number; openTime: string; closeTime: string }; + recruitment: { _id: string; startDate: string; endDate: string }; + positions: { _id: string; name: string; color?: string; order: number }[]; + staffs: { _id: string; displayName: string; status: string }[]; + shiftRequests: { + _id: string; + staffId: string; + entries: { date: string; isAvailable: boolean; startTime?: string; endTime?: string }[]; + }[]; + shiftAssignment: { + assignments: { + staffId: string; + date: string; + positions: { positionId: string; positionName: string; color: string; start: string; end: string }[]; + }[]; + } | null; +}; + +export const ConfirmedView = ({ + staff, + shop, + recruitment, + positions, + staffs, + shiftRequests, + shiftAssignment, +}: ConfirmedViewProps) => { + const dates = generateDateRange(recruitment.startDate, recruitment.endDate); + const timeRange = parseTimeRange(shop); + const transformedStaffs = transformStaffs({ staffList: staffs, shiftRequests }); + const transformedPositions = transformPositions(positions); + const baseShifts = transformShiftRequests({ shiftRequests, staffList: staffs, positions: transformedPositions }); + const allShifts = mergeAssignments({ baseShifts, assignments: shiftAssignment, staffList: staffs }); + + return ( +
+ + {/* ヘッダー */} + +
+ +
+ + {shop.shopName} + + 確定シフト + + 確定済み + + + + + + 期間: + {" "} + {dayjs(recruitment.startDate).format("M/D(ddd)")} 〜 {dayjs(recruitment.endDate).format("M/D(ddd)")} + + + + {staff.displayName} + {" "} + さん + + + +
+ + {/* ShiftForm 読み取り専用 */} + +
+
+ ); +}; diff --git a/src/components/features/Shop/PositionManager/index.tsx b/src/components/features/Shop/PositionManager/index.tsx index f34edfe8..878966d0 100644 --- a/src/components/features/Shop/PositionManager/index.tsx +++ b/src/components/features/Shop/PositionManager/index.tsx @@ -22,6 +22,8 @@ import { useState } from "react"; import { LuCheck, LuGripVertical, LuInfo, LuPencil, LuPlus, LuTag, LuTrash2, LuX } from "react-icons/lu"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; +import { POSITION_COLORS } from "@/convex/constants"; +import { ColorPicker } from "@/src/components/ui/ColorPicker"; import { Dialog, useDialog } from "@/src/components/ui/Dialog"; import { FormCard } from "@/src/components/ui/FormCard"; import { toaster } from "@/src/components/ui/toaster"; @@ -31,6 +33,7 @@ import { userAtom } from "@/src/stores/user"; type PositionType = { _id: Id<"shopPositions">; name: string; + color?: string; order: number; }; @@ -44,9 +47,11 @@ type PositionItemProps = { position: PositionType; isEditing: boolean; editingName: string; + editingColor: string; onEditStart: () => void; onEditCancel: () => void; onEditChange: (value: string) => void; + onColorChange: (color: string) => void; onEditSave: () => void; onDeleteClick: () => void; isUpdating: boolean; @@ -57,9 +62,11 @@ const PositionItem = ({ position, isEditing, editingName, + editingColor, onEditStart, onEditCancel, onEditChange, + onColorChange, onEditSave, onDeleteClick, isUpdating, @@ -111,7 +118,7 @@ const PositionItem = ({ {isEditing ? ( // 編集モード - + + {editError && ( {editError} @@ -139,12 +147,19 @@ const PositionItem = ({ ) : ( // 通常表示 <> + {position.name} { setEditingId(position._id); setEditingName(position.name); + setEditingColor(position.color ?? POSITION_COLORS[position.order % POSITION_COLORS.length]); setEditError(null); }; @@ -317,17 +336,31 @@ export const PositionManager = ({ shopId, positions: initialPositions }: Positio setIsUpdating(true); try { + const currentPosition = positions.find((p) => p._id === editingId); await updatePositionName({ positionId: editingId, name: trimmedName, authId: user.authId, }); - setPositions(positions.map((p) => (p._id === editingId ? { ...p, name: trimmedName } : p))); + // カラーが変更されていたら更新 + if ( + editingColor !== + (currentPosition?.color ?? POSITION_COLORS[currentPosition?.order ?? 0 % POSITION_COLORS.length]) + ) { + await updatePositionColor({ + positionId: editingId, + color: editingColor, + authId: user.authId, + }); + } + + setPositions(positions.map((p) => (p._id === editingId ? { ...p, name: trimmedName, color: editingColor } : p))); setEditingId(null); setEditingName(""); + setEditingColor(""); setEditError(null); - toaster.success({ title: "ポジション名を更新しました" }); + toaster.success({ title: "ポジションを更新しました" }); } catch (error) { const message = error instanceof Error ? error.message : "ポジション名の更新に失敗しました"; setEditError(message); @@ -435,9 +468,11 @@ export const PositionManager = ({ shopId, positions: initialPositions }: Positio position={position} isEditing={editingId === position._id} editingName={editingName} + editingColor={editingColor} onEditStart={() => handleEditStart(position)} onEditCancel={handleEditCancel} onEditChange={setEditingName} + onColorChange={setEditingColor} onEditSave={handleEditSave} onDeleteClick={() => handleDeleteClick(position)} isUpdating={isUpdating} diff --git a/src/components/pages/ShiftSubmit/index.tsx b/src/components/pages/ShiftSubmit/index.tsx index 9ecc8639..12fb6f03 100644 --- a/src/components/pages/ShiftSubmit/index.tsx +++ b/src/components/pages/ShiftSubmit/index.tsx @@ -3,6 +3,7 @@ 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"; +import { ConfirmedView } from "@/src/components/features/ShiftSubmit/ConfirmedView"; type ShiftSubmitPageProps = { token: string; @@ -59,12 +60,34 @@ export const ShiftSubmitPage = ({ token }: ShiftSubmitPageProps) => { title: "現在は募集を受け付けていません", desc: "現在この店舗で受付中の募集はありません。", }, + RECRUITMENT_CLOSED: { + icon: LuCalendarClock, + color: "orange.500", + title: "募集は締め切りました", + desc: "シフトが確定するまでお待ちください。", + }, } as const; const config = errorConfig[data.error]; return ; } + // 確定シフト閲覧 + if (data.status === "confirmed") { + return ( + + ); + } + + // シフト希望提出フォーム return ( { + const user = useAtomValue(userAtom); + const typedShopId = shopId as Id<"shops">; + const typedRecruitmentId = recruitmentId as Id<"recruitments">; -const mockPositions = [ - { id: "pos_hall", name: "ホール", color: "#3b82f6" }, - { id: "pos_kitchen", name: "キッチン", color: "#f97316" }, - { id: "pos_register", name: "レジ", color: "#10b981" }, -]; + const shop = useQuery(api.shop.queries.getById, { shopId: typedShopId }); + const recruitment = useQuery(api.recruitment.queries.getById, { recruitmentId: typedRecruitmentId }); + const shiftRequests = useQuery(api.shiftRequest.queries.listByRecruitment, { recruitmentId: typedRecruitmentId }); + const positions = useQuery(api.position.queries.listByShop, { shopId: typedShopId }); + const staffList = useQuery( + api.shop.queries.listStaffs, + user.authId ? { shopId: typedShopId, authId: user.authId } : "skip", + ); -const mockDates = ["2025-12-01", "2025-12-02", "2025-12-03", "2025-12-04", "2025-12-05", "2025-12-06", "2025-12-07"]; + // ローディング + if ( + shop === undefined || + recruitment === undefined || + shiftRequests === undefined || + positions === undefined || + staffList === undefined + ) { + return ( + + + + ); + } -const mockShifts = [ - { - id: "shift_1", - staffId: "staff_1", - staffName: "田中太郎", - date: "2025-12-01", - requestedTime: { start: "09:00", end: "17:00" }, - positions: [ - { id: "seg_1", positionId: "pos_hall", positionName: "ホール", color: "#3b82f6", start: "09:00", end: "17:00" }, - ], - }, - { - id: "shift_2", - staffId: "staff_2", - staffName: "山田花子", - date: "2025-12-02", - requestedTime: { start: "11:00", end: "19:00" }, - positions: [ - { - id: "seg_2", - positionId: "pos_kitchen", - positionName: "キッチン", - color: "#f97316", - start: "11:00", - end: "19:00", - }, - ], - }, - { - id: "shift_3", - staffId: "staff_1", - staffName: "田中太郎", - date: "2025-12-03", - requestedTime: { start: "10:00", end: "18:00" }, - positions: [ - { id: "seg_3", positionId: "pos_hall", positionName: "ホール", color: "#3b82f6", start: "10:00", end: "18:00" }, - ], - }, -]; + // 見つからない + if (shop === null || recruitment === null) { + return null; + } -export const RecruitmentDetailPage = ({ shopId, recruitmentId }: Props) => { - // 将来的にはuseQueryでデータ取得 - // const recruitment = useQuery(api.recruitment.queries.getById, { recruitmentId }); - // const staffsWithRequests = useQuery(api.shiftRequest.queries.listByRecruitment, { recruitmentId }); + // データ変換 + const dates = generateDateRange(recruitment.startDate, recruitment.endDate); + const timeRange = parseTimeRange(shop); + const transformedStaffs = transformStaffs({ staffList, shiftRequests }); + const transformedPositions = transformPositions(positions); + const shifts = transformShiftRequests({ shiftRequests, staffList, positions: transformedPositions }); return ( ); diff --git a/src/components/pages/Shops/ShiftConfirmPage/index.tsx b/src/components/pages/Shops/ShiftConfirmPage/index.tsx index 0a350dec..fd2525a3 100644 --- a/src/components/pages/Shops/ShiftConfirmPage/index.tsx +++ b/src/components/pages/Shops/ShiftConfirmPage/index.tsx @@ -1,118 +1,82 @@ -import { Box } from "@chakra-ui/react"; -import { ShiftForm } from "@/src/components/features/Shift/ShiftForm"; +import { Spinner } from "@chakra-ui/react"; +import { useQuery } from "convex/react"; +import { useAtomValue } from "jotai"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { ShiftConfirm } from "@/src/components/features/Shift/ShiftConfirm"; +import { + generateDateRange, + mergeAssignments, + parseTimeRange, + transformPositions, + transformShiftRequests, + transformStaffs, +} from "@/src/components/features/Shift/utils/transformRecruitmentData"; +import { LazyShow } from "@/src/components/ui/LazyShow"; +import { userAtom } from "@/src/stores/user"; type Props = { shopId: string; recruitmentId: string; }; -// モックデータ(将来的にはuseQueryで取得) -const mockStaffs = [ - { id: "staff_1", name: "田中太郎", isSubmitted: true }, - { id: "staff_2", name: "山田花子", isSubmitted: true }, - { id: "staff_3", name: "鈴木次郎", isSubmitted: true }, -]; +export const ShiftConfirmPage = ({ shopId, recruitmentId }: Props) => { + const user = useAtomValue(userAtom); + const typedShopId = shopId as Id<"shops">; + const typedRecruitmentId = recruitmentId as Id<"recruitments">; -const mockPositions = [ - { id: "pos_hall", name: "ホール", color: "#3b82f6" }, - { id: "pos_kitchen", name: "キッチン", color: "#f97316" }, - { id: "pos_register", name: "レジ", color: "#10b981" }, -]; + const shop = useQuery(api.shop.queries.getById, { shopId: typedShopId }); + const recruitment = useQuery(api.recruitment.queries.getById, { recruitmentId: typedRecruitmentId }); + const shiftRequests = useQuery(api.shiftRequest.queries.listByRecruitment, { recruitmentId: typedRecruitmentId }); + const positions = useQuery(api.position.queries.listByShop, { shopId: typedShopId }); + const staffList = useQuery( + api.shop.queries.listStaffs, + user.authId ? { shopId: typedShopId, authId: user.authId } : "skip", + ); + const shiftAssignment = useQuery(api.shiftAssignment.queries.getByRecruitment, { + recruitmentId: typedRecruitmentId, + }); -const mockDates = ["2025-12-01", "2025-12-02", "2025-12-03", "2025-12-04", "2025-12-05", "2025-12-06", "2025-12-07"]; + // ローディング + if ( + shop === undefined || + recruitment === undefined || + shiftRequests === undefined || + positions === undefined || + staffList === undefined || + shiftAssignment === undefined + ) { + return ( + + + + ); + } -const mockShifts = [ - { - id: "shift_1", - staffId: "staff_1", - staffName: "田中太郎", - date: "2025-12-01", - requestedTime: { start: "09:00", end: "17:00" }, - positions: [ - { id: "seg_1", positionId: "pos_hall", positionName: "ホール", color: "#3b82f6", start: "09:00", end: "17:00" }, - ], - }, - { - id: "shift_2", - staffId: "staff_2", - staffName: "山田花子", - date: "2025-12-01", - requestedTime: { start: "11:00", end: "19:00" }, - positions: [ - { - id: "seg_2", - positionId: "pos_kitchen", - positionName: "キッチン", - color: "#f97316", - start: "11:00", - end: "19:00", - }, - ], - }, - { - id: "shift_3", - staffId: "staff_3", - staffName: "鈴木次郎", - date: "2025-12-01", - requestedTime: { start: "10:00", end: "18:00" }, - positions: [ - { - id: "seg_3", - positionId: "pos_register", - positionName: "レジ", - color: "#10b981", - start: "10:00", - end: "18:00", - }, - ], - }, - { - id: "shift_4", - staffId: "staff_1", - staffName: "田中太郎", - date: "2025-12-02", - requestedTime: { start: "10:00", end: "18:00" }, - positions: [ - { id: "seg_4", positionId: "pos_hall", positionName: "ホール", color: "#3b82f6", start: "10:00", end: "18:00" }, - ], - }, - { - id: "shift_5", - staffId: "staff_2", - staffName: "山田花子", - date: "2025-12-03", - requestedTime: { start: "09:00", end: "15:00" }, - positions: [ - { - id: "seg_5", - positionId: "pos_kitchen", - positionName: "キッチン", - color: "#f97316", - start: "09:00", - end: "15:00", - }, - ], - }, -]; + // 見つからない + if (shop === null || recruitment === null) { + return null; + } -export const ShiftConfirmPage = ({ shopId }: Props) => { - // 将来的にはuseQueryでデータ取得 - // const recruitment = useQuery(api.recruitment.queries.getById, { recruitmentId }); - // const shiftRequests = useQuery(api.shiftRequest.queries.listByRecruitment, { recruitmentId }); - // const positions = useQuery(api.position.queries.listByShop, { shopId }); + // データ変換 + const dates = generateDateRange(recruitment.startDate, recruitment.endDate); + const timeRange = parseTimeRange(shop); + const transformedStaffs = transformStaffs({ staffList, shiftRequests }); + const transformedPositions = transformPositions(positions); + const baseShifts = transformShiftRequests({ shiftRequests, staffList, positions: transformedPositions }); + const initialShifts = mergeAssignments({ baseShifts, assignments: shiftAssignment, staffList }); return ( - - - + ); }; diff --git a/src/components/ui/ColorPicker/index.tsx b/src/components/ui/ColorPicker/index.tsx new file mode 100644 index 00000000..687a62d0 --- /dev/null +++ b/src/components/ui/ColorPicker/index.tsx @@ -0,0 +1,37 @@ +import { Flex } from "@chakra-ui/react"; +import { LuCheck } from "react-icons/lu"; +import { POSITION_COLORS } from "@/convex/constants"; + +type Props = { + value: string; + onChange: (color: string) => void; + colors?: readonly string[]; +}; + +export const ColorPicker = ({ value, onChange, colors = POSITION_COLORS }: Props) => { + return ( + + {colors.map((color) => ( + + ))} + + ); +}; From a8abc1396cc384e31f8abbf1c17fba99ca2f2b4a Mon Sep 17 00:00:00 2001 From: y-natani Date: Mon, 2 Mar 2026 00:36:20 +0900 Subject: [PATCH 13/14] fix --- convex-seeds/seeds/db.zip | Bin 7018 -> 9210 bytes src/components/features/ShiftSubmit/index.tsx | 15 ++------------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/convex-seeds/seeds/db.zip b/convex-seeds/seeds/db.zip index 28334cccbfcf99efc95702bedee2e5ac27537610..4df01c6e11fa0d9b55f86540e2bc8dc5a51b8c96 100644 GIT binary patch delta 3365 zcmai0c|4R`ADKW6M5cT#8{TOJtk3j1+@H_I)Vp z*kw)P5=kZLA_*baYlhy}E#3Ed{&>F6`Fwxh^F8M|=RDt8rFB%veIVYh_Y_C7C|ks< zQ|%7(fK8vO`Ga}W;3M)a24Uc4_}h7_H&WO-=>2Z(9l*wMFsK2>!^wI&SaVf$m2cPTMa+_2}du!wo&!1*?B#t%+yZyJ&HSlPd{ zh#Dm>WU9Ky5<>e!{!t!D0nF&dEp1Id^2#);nIRK1^cl39XwM+dc@O{xVBFkYu;ysL z05k?mJPnnEG5}~C!1hJwAin3ALZrDYx(sCuWz45QbgnU1jq*14Fsnb>$v?my>xK5- zPwIfgw$xtAPnE>k1-GeQj;`)bM(*C1_OZn-_BX=MmEfZEPW=Mh{n5@ASVtEZcW+ms z78LQ_IN`|scxDi({l_ZKP1B{%ofZb;?&|%MF>}*ozo-4&ZMhK0h#5Z?Gk#W>>0{v5 zjb?QbZI{EH-FI_rS*?z2Oup4gL?@;Eri2_$nfAZYAAHufWtkvOoz3*r+OQsGowMC^ z!eOd5Wf7NuHSSX-EQ96^Mdj9dyO9lRlFE%$|1v1bk`7TcK#)#P{mP3ua+ah5$>O}( zghMof|70d0EN{6(pDE!xatum5BKZ)AN~U{Uu}wDrhWCBQ!9+R6g-2Oxyq)7+a0SL| zs?Fe5QR7)YriZ$l_&ctlEaZT;$Gq%jizAXH9oRA}ZdAaN&&vUaZ%I?+EtBtEdQfj& z_r=z>ODZ(6E_C^NA@%W{)#f1{)myeuxy3chPI*1&q;M916Pb#a#=rMO#*V1V2wFp9 zpXSMJT0XhhW~=L=tgo+WjR~;}XPHckJI2yR?(O=*Gj8(MI(y?_7$xm-DNCP{K$U=E zbhwvNi9{$*q}NQQZm;^aE5M%qe1V5LhHH<;MdUBxV(Mfcr3UK8Ypy#2hR3S6V85jp zZv=6teL8)6>(?@Se9x!%3FK@ziRloldJ`cmnp*5zq$k5gNY@&tMvYd;+FTsWSeGuc zBY99V#fIj)`=V235}qA@?RKVhYKauEyxH6uXdiEa|6^{VhGJeiV}D)t!41M^&#SVA zMP+cK#?FC(i$3_pkJFvaflX@(Im5=H{=LsPDHFXxQ|X`+_(<_72anSp>|r*IF@PSu z0v_2jGaD_-wya6EEzCaNEKf*^lSq_^>`J}By}#D=S0{ZLN9y}!GG11&2VU ziCgFKT4(t^bc++^3eGmZy}CK?w)naaiCQR}d=+yuawAwwNj?{S zVDYy+^Dzun1%K`?lD+$CE%k(+NAly;kD-0rb)g$Muh(+gm&V4M>X1w0K>Jkf*pCm$ z4RvNAJRYoSCf+I4iAOm|oHt#Sk3L*uqx9wWG5dKoi#0nf1gR2!C6&Gzl7gV?t&x zxmA{W>doD{P`hU8AW$AUtBJ=x!mG?qqz5vI6pW~DF+EnfW5PqKU7WVZer2Z}U<#w3 z>4#i-8~YFl-y_OazSxfaFxikWa*{YuRnx<{n*3;ixRi#O;2W|yrKn)1aX`d=#dsN9 zb_Zs_h~i)5M$Kdf2fODXt@s2;pVR>(-IBLm*dPV1TCyWkZi_=Y%LOT^u0zDx_1Uox z`F+L3p|$nFHg=ZHOTX7_HlgsE-2Cs$TDTXy%o%Knf-tB8pOw+m(J>LB?11FbOtyfw zerGtM=YEEhO{K@f<9VhK2@59;qrj};Fp2K>ELYZ>Vm64@Fj4%M`5fhj(6x65>|dVA zj^L!;aV@HKg~a~y4CDQ3C4y-SaiW4UcUQrqeC75SxN|C%T9#JPkjw_+fL%#o^K0vf z2GtI;GiFvRTqYp%L|!vrHPAho;_SR zP*UJXoaZwJKu7S+nK07JN_$~|nWTIGh;&{0)& zld%XEOF+Md)r-vGK9;Y*7W(DlK<_WyHwn1te9DAqw3I!{->Z`^Uv7MObnIx1_e_*j z9)A}vrD%DpEP-4kcFM7eA{bX@ra#y%WZBq#&OA)2>o)#5`zNxnBW}gp4|>aGh}niA ze9)%EllS<-@Eq=QvF9{?QwBLVHBd%qpCDm8i)0%-egze=fp89g=tH-Nq5L{bWDKi3?&+4D#@3mNO*3Sb6T5T4}M%zi6)h4iDm~ zONDd+i5@dm@e>agnVSzLNly0qZrm$q$_ufE1Y)%V4x@*bE3~UB*jrI28-chipFn?9 z+YKuM_Y{htYo_q(ik*>z;bVnRv)jTYNuTGMbnt;#^~8R54a2wNwYlMXF|(oh%3OcK zq=GtXt}k3nnj3JL5L5O|$pJ?bueXy^t6LEju5E9!`Z=OH*XQagCAce{6BKUf8+2?~ zI`#03c2DbuWV^+WwmbC|#vDxPbUG;+>CPj(LbzSSOD<{sBT+;f_olLSRW(z4knNa6 zwi@1ZR7?NRm#`|2L6!8Bgm`VWm9NOl6{V;QzGoEPMdpsD5fLZP`?iPQ6-kWGuadCl zdSNw|Rsp?-m*d0vY^fca*mhI=>}qXIiW%Kmz|@-yoA7abtZ)Qsx_kK z6Jl6j1~jT+tF53EiE%o8CIsYQ)oxxOMqprkw z82`vr_1(S#3M!+{6tl%h+w|!#tRb}2QeFpV0Dl~04X8?fMod@7NkSH(6 zOH_lf5h?s|=$-)NBgn(GO9Fs)!D$eUeTOAa34r({9BANg$Tm=0rU*D zO@r=IGw3?oy>bYBU%fXK?PmeTK(lakuLX%8EbJB8-+>=phBj^?!5_;17q|9vFi9^( z?sXvc4-)(LuL1rx@8Y6A6#H*y(7%N=UqSc}-y)x`aD1-~L1#`1n1Y~OGzg~e6+=NSs1$gI z5(=kN3Ir`ZAObs0W`+=m^bQ1QG92GU008eU`t}^zUF(g4|E*)9A(F(-J8l1&88Sj> MZ@k+#jlaYF51M*MT>t<8 delta 2233 zcmX|?c{~(a8^@U$#=d56m@&kSv0VF}eakXpy4=b-V;fstJB_S~h{>&pu`7EFQm7c& zX^12{<1b;4gZ(-u`9eiFel6Wl)8aarO7-ivSY^ zxd_REfr((rb1)%R7@ya*YL^sZBOpk2rB0Ez#rA#n+Y#)Zd+}#KPhB;u+i4`pT>NIs z!NAzFCEe-o3IpNyeH(fof|O*k(5h)B$?4uI9Ar^!AKZl(A8vDc2P)v)UW%^wu2}&ZxNM;yH@cR(6*`v)xDJ4t`y76&u_uE14@rq&Ey zmTo!moO{P?K-CJJ4-`pIbwpmmb;Pv_T40Ov5H%nR*Ngeam1rF_1b*`l#z(ZKy{j^T z^KrwXTcWDp*VEB1k%H}cYo6eF+H!qJce7n;kcFL>M*-q(PO*Kd(pN`?DnpZN3793V zO!d;TILdnVU5%%^RFQ+5yq4KUd!FpUjT?vX$EOKgo0a;DCAEi-mKyVMy%YS#L9!~m z@xnh6Q(%H<$3>GIi*k@r(vuYamQRjNq{^YyiSiAP%FcWX=h%s2D5e@d9+g?fEtE0l zJvF(_yF3?fpnHIB!_lcWy`?yzuH4f+&CT3h((Qw4^NIMfvR&%o{`;HwGnxsmn%abg47E7PV+Y1{lkHb51WI*mR184lsZH^`P!O z17B|X4uz2A+m`Js^$omTG#qh$Cv|N>1N8v$Svv*2fWm0|m=L=tnDY1MW)0v*aMC00 zG(|$}+?;=-uOJbgT$chyxbBAf)t0;onsO)AYE3m5(&%$iTl85PFG@n8<5h)TTn~Q} zOilj6p^zzGgm`jGu;cWO^%)>{ol*|Me}B+H^C!ozi)SA!XWMV{S|0Q@AI{^2^p<~m z+#%u90qX@J2j+cqjE4m7$S=SnS*4q01SSEvn`}MUeN~F?jbg7hFKpqu`t zoA$guc5KBOb>8Ld_>7j3=GiqRZ*#HL%#s0lt9)zIzNUalr;9EGE{n>Sad+1(8<;Kc zP6+ONOA=Ye6NR9-{q?Fh&*FFA@VXOV=SnyZK{hGD6eJ~MdSCT&DHA>1^qPDyw=x?h=ltA{3YL&D63Oy4d{wH&$&3%u4SmmB_$>Ol&h_Ro(_;<&40 z!x3u!b{zM_Sn@V}c$A8;FBz{DhXq1IbvgeX>HZK+p=KWPoqWkuB1pp0HBUa}tDN4N zCWqxNUYBtl(L`nUX02~+&&?hj4pD{(b0XXtDx*xI#1lTz>R#(h{j(oiV}9_qsR9Oo ze$H%}3EIdnqc-F9nY|-%D%ZNR_<3U{<=WW$hH)Rd4F*GR^qmQ(#Yt6s<~%v$`BK5h z)jC|$cHQ@xylsEc=$L_|`6Y9D<+aPt%f+A2$|C*syF=4_*dJ@nUX=@M3?SsafD}3w zTDyiD)ZIb^nQriNhbB-~-WAT~Jo3t$Pu|HX=D&Sb+g(I(`d0%|w`IY#GV!sYWC+AT z<{wo-G)m1lb83Vb!3{Od}JW^+5zU4EwQ`^NN@wE#=ZS5dnGPdApd&SG>u zi##?gVkzwwBsyp#KI_3N^`yb;pLhx)9CN`q!{WLb^-+;iOJ%RJ41Nws27N}Zux!jRpIC9o0U*)ZrG%caF?+Vwop_GD{SAd?>^0p+1L)9!DFMu~-VlV3s1BC$`&kV2MZ z6G?vqS80!TtjxYST74|_yrp_e3*Fn0FFcUoylz^*BG0L5*L-BJe_2hA?t-GulC#3c z?U7Dp=BFQpBdP7VQ8JBWD=jE%tG3cI=Nb*^N1vF*0guM#4mE6s@#>*sKmh1ynBwIm zGz1Emj+fnU{hJy8LRuLANXinx3}7H1)E?jqWMBl;5>O6e;N-6?f|Y@KFb6;kn1Q7* z6kw6<2tkZr=7hAmz%7pB(ptb{g>W#eq0$b3T5g7w(b_v{V~$gxV+)jzVbO;>2b7&S zPC44LvML@UlMztqH!(4B{igqE&F>Nyh5vsJd_-&#$1(FSakOP&J&L$~?IZuO_dlh^ B{vZGV diff --git a/src/components/features/ShiftSubmit/index.tsx b/src/components/features/ShiftSubmit/index.tsx index 87a5b80f..49bb8972 100644 --- a/src/components/features/ShiftSubmit/index.tsx +++ b/src/components/features/ShiftSubmit/index.tsx @@ -5,6 +5,7 @@ import "dayjs/locale/ja"; import { useMutation } from "convex/react"; import { LuCalendarDays } from "react-icons/lu"; import { api } from "@/convex/_generated/api"; +import { generateDateRange } from "@/src/components/features/Shift/utils/transformRecruitmentData"; import { toaster } from "@/src/components/ui/toaster"; import { ConfirmView } from "./ConfirmView"; import { EntryForm } from "./EntryForm"; @@ -43,18 +44,6 @@ type ShiftSubmitProps = { 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) { @@ -79,7 +68,7 @@ export const ShiftSubmit = ({ previousRequest, frequentTimePatterns, }: ShiftSubmitProps) => { - const dates = generateDates(recruitment.startDate, recruitment.endDate); + const dates = generateDateRange(recruitment.startDate, recruitment.endDate); const [view, setView] = useState(existingRequest ? "submitted" : "form"); const [entries, setEntries] = useState(() => createInitialEntries(dates, existingRequest)); const [submittedAt, setSubmittedAt] = useState( From 9d29c07b6d69b86ad0aaefbc2c944dc7aae63dfd Mon Sep 17 00:00:00 2001 From: y-natani Date: Mon, 2 Mar 2026 00:39:37 +0900 Subject: [PATCH 14/14] del --- .agent/rules/readme-first.md | 149 -------------------------- .agent/rules/test-rules.md | 23 ---- .scaffdog/Component.md | 21 ---- .scaffdog/RouteHandler/path/route.ts | 38 ------- .scaffdog/RouteHandler/query/route.ts | 33 ------ .scaffdog/RouteHandlerPath.md | 17 --- .scaffdog/RouteHandlerQuery.md | 17 --- .scaffdog/component/index.stories.tsx | 12 --- .scaffdog/component/index.tsx | 5 - .scaffdog/config.js | 3 - 10 files changed, 318 deletions(-) delete mode 100644 .agent/rules/readme-first.md delete mode 100644 .agent/rules/test-rules.md delete mode 100644 .scaffdog/Component.md delete mode 100644 .scaffdog/RouteHandler/path/route.ts delete mode 100644 .scaffdog/RouteHandler/query/route.ts delete mode 100644 .scaffdog/RouteHandlerPath.md delete mode 100644 .scaffdog/RouteHandlerQuery.md delete mode 100644 .scaffdog/component/index.stories.tsx delete mode 100644 .scaffdog/component/index.tsx delete mode 100644 .scaffdog/config.js diff --git a/.agent/rules/readme-first.md b/.agent/rules/readme-first.md deleted file mode 100644 index e1e66287..00000000 --- a/.agent/rules/readme-first.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -trigger: always_on ---- - -## 参照ドキュメント -- @doc/claude/basic.md -- @doc/claude/self.md - -## 🚨 核心制約 - -### NEVER(絶対禁止) -- NEVER: data-testidをテストで使用 - -### YOU MUST(必須事項) -- YOU MUST: 質問をする場合は、1つずつ質問してください。チャットなので。。。 -- YOU MUST: ユーザーの指示で不明瞭な箇所は必ず聞き返してください。これすごく重要!!ぜひ一緒に仕様をつくっていきましょう! -- YOU MUST: 回答、implementation plan, task, walkthroughは日本語で書くこと -- YOU MUST: 実装着手前にimplementaiton planを./docに`yyyy-mm-dd_<実装名>`で作成してください(./docのどのディレクトリに配置するかは任せます!) -- YOU MUST: ロジック修正時は、必ず単体テスト、E2Eテストも更新する必要がないか確認してください。 -- YOU MUST: UI変更時はブラウザ連携機能を利用し、キャプチャをwalkthroughに含めてください。 - -### IMPORTANT(重要事項) -- IMPORTANT: Chakra UI v3 Modern API準拠 -- IMPORTANT: 3ステップ以上でTodoWrite使用 -- IMPORTANT: 作業開始前に計画することを好む -- IMPORTANT: バレルエクスポート禁止 -- IMPORTANT: utf-8を利用すること -- IMPORTANT: TypeScriptの型は推論を利用すること -- IMPORTANT: 定数化は2箇所以上で利用しているときのみとする -- IMPORTANT: 開発者の指摘が誤っているときは、根拠を示して反論すること -- IMPORTANT: pnpm dev, pnpm storybook, pnpm convex:dev はこちらで実施済みです。AIコーディング時に再実行する必要はありません。 - -## 開発コマンド - -### コア開発 -- `pnpm dev` - Vite開発サーバーの起動(ポート3000) -- `pnpm build` - Viteプロダクションビルド + TypeScript型チェック -- `pnpm start` - 開発サーバーの起動(devと同じ) -- `pnpm serve` - プロダクションビルドのプレビュー - -### コード品質・型チェック -- `pnpm lint` - Biomeリンティングの実行(チェックのみ) -- `pnpm format` - Biomeによるコードフォーマット -- `pnpm type-check` - TypeScript型チェックの実行 - -### テスト -- `pnpm test` - 全てのVitestテストの実行 -- `pnpm test:logic` - ロジック・ユニットテストのみ実行(./src/**/*.test.ts) -- `pnpm test:ui` - StorybookによるUI・コンポーネントテスト(ブラウザモード) -- `pnpm e2e` - Playwright E2Eテストの実行 -- `pnpm e2e:ui` - Playwright UIでE2Eテストを実行 -- `pnpm e2e:debug` - E2Eテストのデバッグ -- `pnpm e2e:report` - Playwrightテストレポートの表示 -- `pnpm e2e:codegen` - E2Eテストコードの生成 - -### ドキュメント・コンポーネント -- `pnpm storybook` - Storybook開発サーバーをポート6006で起動 -- `pnpm storybook:build` - Storybookのプロダクションビルド -- `pnpm scaffdog` - コード雛形の生成 - -### Convex(バックエンド) -- `pnpm convex:dev` - Convex開発モード起動 -- `pnpm convex:import` - データインポート -- `pnpm convex:export` - データエクスポート - -## アーキテクチャ概要 - -### 技術スタック -- **ビルドツール**: Vite 7.1.7(高速開発サーバー) -- **ルーティング**: TanStack Router 1.132.23(ファイルベースルーティング) -- **UIフレームワーク**: React 19.1.1 -- **UIライブラリ**: Chakra UI v3.27.0(Emotionスタイリング) -- **フォーム**: React Hook Form + Zodバリデーション -- **状態管理**: Jotai 2.15.0(アトミック状態管理) -- **認証**: Clerk (@clerk/clerk-react) -- **バックエンド**: Convex 1.27.3(リアルタイムデータベース) -- **パッケージマネージャ**: pnpm - -### プロジェクト構造 - -#### ソースコード(`src/`) -- `routes/` - TanStack Routerのルート定義(ファイルベースルーティング) -- `components/` - 目的別に整理されたReactコンポーネント - - `features/` - 機能固有コンポーネント - - `layout/` - レイアウトコンポーネント - - `pages/` - ページコンポーネント(店舗、シフト、勤怠等) - - `ui/` - UI基盤コンポーネント -- `stores/` - Jotaiアトム定義(状態管理) -- `helpers/` - ユーティリティ関数 -- `constants/` - 定数・バリデーションスキーマ -- `configs/` - 設定ファイル - -#### Convexバックエンド(`convex/`) -- サーバーレスバックエンドコード -- リアルタイムデータベース機能 - -### テストアーキテクチャ -プロジェクトでは多層テスト手法を採用: - -1. **ロジックテスト**: Vitestを使用したユーティリティ・ビジネスロジックのユニットテスト - - `src/**/*.test.ts`に配置 - - 分離されたNode.js環境で実行 - -2. **UIテスト**: Storybook統合によるコンポーネントテスト - - 実ブラウザテスト用Playwrightブラウザプロバイダーを使用 - - Storybookストーリーを直接テスト - -3. **E2Eテスト**: Playwrightによるフルアプリケーションテスト - - `e2e/`ディレクトリに配置 - - テスト用開発サーバーの自動起動 - -### 状態管理パターン -- アトミック状態管理にJotaiを使用 -- ドメイン別ストア定義(例: `src/stores/user/`) -- UIとユーザーデータ用のクライアントサイド状態アトム - -### フォームアーキテクチャ -- React Hook Form + Zodスキーマバリデーション -- フォームコンポーネントのパターン: schema.ts + index.tsx + index.stories.tsx -- `src/constants/validations.ts`での一元的バリデーションパターン -- 型は`z.infer`で自動生成 - -### バックエンド統合 -- Convexによるリアルタイムデータベース -- Clerkによる認証機能 -- 型安全なAPI呼び出し - -## コード品質基準 - -### フォーマット・リンティング -- Biome設定: 2スペースインデント、120文字行幅を強制 -- インポート整理を有効化 -- Reactドメインルールを適用 -- 配列インデックスキーを許可(noArrayIndexKey無効) -- forEachを許可(noForEach無効) - -### ファイル整理 -- コンポーネントには対応する.stories.tsxファイルを含む -- スキーマは専用ファイルに分離(schema.ts) -- パスエイリアス設定: @/src, @/e2e, @/convex - -### デザイン -- アイコンは react-iconsのLucideセットを利用すること(Chakraの Iconタグで呼び出すこと) -- `"@storybook/react"`; は、 ` "@storybook/react-vite";`で呼び出すこと - -### 汎用コンポーネント -- Selectボックス:@yps-crispy-carnival/src/components/ui/Select/index.tsx -- Formのカード:@yps-crispy-carnival/src/components/ui/FormCard/index.tsx -- ページタイトル:@yps-crispy-carnival/src/components/ui/Title/index.tsx \ No newline at end of file diff --git a/.agent/rules/test-rules.md b/.agent/rules/test-rules.md deleted file mode 100644 index 4cba9f9c..00000000 --- a/.agent/rules/test-rules.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -trigger: model_decision -description: test strategy ---- - -## 🧪 テスト戦略 - -### 実行方針 -- **E2Eテスト**: 毎PR、ハッピーパスのみ、Chrome only -- **単体テスト**: 日本語命名、比重5:1(ハッピー:エッジ) -- **Storybook**: 全コンポーネント必須、代表パターンのみ - -綿密にカバレッジ100%を目指すというよりは、デグレ防止の意味合いが強い - -### テスト実装例 -```tsx -// ✅ 日本語命名必須 -describe('useDraftRoom', () => { - test('ドラフトルームデータを正常に取得できる', () => { - const { result } = renderHook(() => useDraftRoom('draft123')); - expect(result.current.draft).toBeDefined(); - }); -}); \ No newline at end of file diff --git a/.scaffdog/Component.md b/.scaffdog/Component.md deleted file mode 100644 index f8afedb0..00000000 --- a/.scaffdog/Component.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: "Component" -root: "./src/components" -output: [] -ignore: [] -questions: - component: "What is component name??" - path: "What is path??(i.e. features/Timeline)" ---- - -# `{{ inputs.path }}/{{ inputs.component | pascal }}/index.stories.tsx` - -```tsx -{{ "Component/index.stories.tsx" | read }} -``` - -# `{{ inputs.path }}/{{ inputs.component | pascal }}/index.tsx` - -```tsx -{{ "Component/index.tsx" | read }} -``` diff --git a/.scaffdog/RouteHandler/path/route.ts b/.scaffdog/RouteHandler/path/route.ts deleted file mode 100644 index 5b8d4ce5..00000000 --- a/.scaffdog/RouteHandler/path/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import prisma from '@/prisma/libs/db'; -import type { BaseFetch } from '@/src/services/common/fetch'; -import type { Prisma } from '@prisma/client'; -import type { NextRequest } from 'next/server'; - -type Path = { - params: { - userId: string; - }; -}; - -export type {{ inputs.method | pascal }}{{ inputs.pathWithoutSlash | pascal }} = BaseFetch & { - response: CommonResponse; -}; - -const {{ inputs.method | pascal }}ApiName = '{{ inputs.method | pascal }}{{ inputs.pathWithoutSlash | pascal }}'; -export const {{ inputs.method | constant }} = async (_: NextRequest, path: Path) => { - const { userId } = await path.params; - console.log(`${{{ inputs.method | pascal }}ApiName} Started`, path); - - const result = await prisma.user - .findUnique({ - where: { - userId, - }, - }) - .catch((e) => { - console.error(e); - console.error(`${{{ inputs.method | pascal }}ApiName} Failed`); - }); - - console.log(`${{{ inputs.method | pascal }}ApiName} Ended`, result); - - return Response.json({ - success: !!result, - result, - }); -}; \ No newline at end of file diff --git a/.scaffdog/RouteHandler/query/route.ts b/.scaffdog/RouteHandler/query/route.ts deleted file mode 100644 index 4b7c3e44..00000000 --- a/.scaffdog/RouteHandler/query/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import prisma from '@/prisma/libs/db'; -import type { BaseFetch } from '@/src/services/common/fetch'; -import type { Prisma } from '@prisma/client'; -import type { NextRequest } from 'next/server'; - -export type {{ inputs.method | pascal }}{{ inputs.pathWithoutSlash | pascal }} = BaseFetch & { - response: CommonResponse; - mutation: query: Prisma.UserCreateInput; - method: 'POST' -}; - -const {{ inputs.method | pascal }}ApiName = '{{ inputs.method | pascal }}{{ inputs.pathWithoutSlash | pascal }}'; -export const {{ inputs.method | constant }} = async (request: NextRequest) => { - - const data: {{ inputs.method | pascal }}{{ inputs.pathWithoutSlash | pascal }}['requestOptions']['query'] = await request.json(); - console.log(`${ {{ inputs.method | pascal }}ApiName} Started`, data); - - const result = await prisma.user - .create({ - data, - }) - .catch((e) => { - console.error(e); - console.error(`${ {{ inputs.method | pascal }}ApiName} Failed`); - }); - - console.log(`${ {{ inputs.method | pascal }}ApiName} Ended`, result); - - return Response.json({ - success: !!result, - result, - }); -}; diff --git a/.scaffdog/RouteHandlerPath.md b/.scaffdog/RouteHandlerPath.md deleted file mode 100644 index 1e2c1b8f..00000000 --- a/.scaffdog/RouteHandlerPath.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: "RouteHandler (Path Params)" -root: "./app/api" -output: [] -ignore: [] -questions: - path: "What is path begins after app, type after api/ ??(i.e. auth/user/signup)" - pathWithoutSlash: "Path without slash ??(i.e. AuthUserSignup)" - method: "What is http method??(i.e. post, get, put, delete)" ---- - - -# `{{ inputs.path }}/route.ts` - -```tsx -{{ "RouteHandler/path/route.ts" | read }} -``` diff --git a/.scaffdog/RouteHandlerQuery.md b/.scaffdog/RouteHandlerQuery.md deleted file mode 100644 index 62e33886..00000000 --- a/.scaffdog/RouteHandlerQuery.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: "RouteHandler (Query Params)" -root: "./app/api" -output: [] -ignore: [] -questions: - path: "What is path begins after app, type after api/ ??(i.e. auth/user/signup)" - pathWithoutSlash: "Path without slash ??(i.e. AuthUserSignup)" - method: "What is http method??(i.e. post, get, put, delete)" ---- - - -# `{{ inputs.path }}/route.ts` - -```tsx -{{ "RouteHandler/query/route.ts" | read }} -``` diff --git a/.scaffdog/component/index.stories.tsx b/.scaffdog/component/index.stories.tsx deleted file mode 100644 index efd00dc9..00000000 --- a/.scaffdog/component/index.stories.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { {{ inputs.component | pascal }} } from "."; - -const meta = { - title: '{{ inputs.path }}/{{ inputs.component | pascal }}', - component: {{ inputs.component | pascal }} , - args: {}, - parameters: {}, -} satisfies Meta; -export default meta; - -export const Basic: StoryObj = {}; diff --git a/.scaffdog/component/index.tsx b/.scaffdog/component/index.tsx deleted file mode 100644 index d670e1bf..00000000 --- a/.scaffdog/component/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -type Props = {} - -export const {{ inputs.component | pascal }} = ({}: Props) => { - return
aaa
-}; diff --git a/.scaffdog/config.js b/.scaffdog/config.js deleted file mode 100644 index c1339da9..00000000 --- a/.scaffdog/config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - files: ["./*"], -};