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: ["./*"], -}; 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`で自動生成 - -### バックエンド統合 -- 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 -- モーダルダイアログ:@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-seeds/seeds/db.zip b/convex-seeds/seeds/db.zip index 28334ccc..4df01c6e 100644 Binary files a/convex-seeds/seeds/db.zip and b/convex-seeds/seeds/db.zip differ 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: { `.trim(); }; + +// 確定通知メールHTML組み立て +const buildConfirmationEmailHtml = (params: { + shopName: string; + startDate: string; + endDate: string; + magicLinkUrl: string; +}) => { + return ` +
+

${params.shopName} のシフトが確定しました

+ + + + + +
シフト期間${params.startDate} 〜 ${params.endDate}
+

以下のリンクから確定シフトを確認してください:

+ シフトを確認する +

※ このリンクはあなた専用です。他の方と共有しないでください。

+
+`.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/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 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
{draft.name}
; -}; - -// ❌ 禁止: React.FC + interface -const DraftRoom: React.FC = () => {}; // 禁止 -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 ; - // メイン処理 - return ; -}; -``` - -### 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必須 - - -// ✅ レスポンシブ:配列記法、2段階(PC/SP) - -``` - -### アニメーション統一 -- **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容認 - - -// ❌ 絶対禁止: 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 ; - if (data.hasError()) return ; - - // メイン処理 - return ; -}; - -// ❌ 禁止: ネスト構造 -const processData = (data: Data | null) => { - if (data) { - if (!data.isEmpty()) { - // 深いネスト禁止 - } - } -}; -``` - -### 条件付きレンダリング -```typescript -// ✅ &&演算子使用 -{isLoading && } -{error && } -{data && } - -// ✅ 複雑な条件はif文 -const renderContent = () => { - if (isLoading) return ; - if (error) return ; - if (!data) return ; - return ; -}; -``` - -### 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 = {} -// 複雑なパターンは避ける -``` - -## 🎯 品質・効率バランス戦略 - -### 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 - -// ✅ 手動型定義は最小限 -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(...); // 本番では実行 - -// ✅ 実用的なエラーハンドリング -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) - {/* 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" ? ( + + + + + ) : ( + + ); + + const mobileActionButton = + recruitmentStatus === "open" ? ( + + + + + ) : ( + + ); return ( {/* ヘッダー */} - <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/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( 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) => ( + + ))} + + ); +};