From 28f744afa32e9588dc99452b7bab84be40286327 Mon Sep 17 00:00:00 2001 From: y-natani Date: Mon, 23 Mar 2026 23:14:18 +0900 Subject: [PATCH 001/176] =?UTF-8?q?chore:=20=E4=B8=8D=E8=A6=81=E3=82=B9?= =?UTF-8?q?=E3=82=AD=E3=83=AB=E3=81=AE=E5=89=8A=E9=99=A4=EF=BC=88create-pr?= =?UTF-8?q?,=20doc-update,=20resume-plan,=20save-plan=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/create-pr/SKILL.md | 86 ------------ .claude/skills/doc-update/SKILL.md | 62 -------- .../skills/doc-update/references/templates.md | 86 ------------ .claude/skills/resume-plan/SKILL.md | 106 -------------- .claude/skills/save-plan/SKILL.md | 132 ------------------ 5 files changed, 472 deletions(-) delete mode 100644 .claude/skills/create-pr/SKILL.md delete mode 100644 .claude/skills/doc-update/SKILL.md delete mode 100644 .claude/skills/doc-update/references/templates.md delete mode 100644 .claude/skills/resume-plan/SKILL.md delete mode 100644 .claude/skills/save-plan/SKILL.md diff --git a/.claude/skills/create-pr/SKILL.md b/.claude/skills/create-pr/SKILL.md deleted file mode 100644 index 5f81394f..00000000 --- a/.claude/skills/create-pr/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: create-pr -description: GitHubにPull Requestを作成するスキル。コミット・プッシュ済みの状態で、現在のブランチの変更内容を分析し、適切なタイトルと説明文でPRを作成する。「PRを作成して」「プルリクエストを作って」「PR作って」などのトリガーで発動。 ---- - -# PR作成スキル - -## 前提条件 - -- コミット済み -- プッシュ済み -- GitHubリポジトリと連携済み - -## ワークフロー - -### 1. 現在のブランチ情報を取得 - -```bash -git branch --show-current -``` - -### 2. デフォルトブランチを自動検出 - -```bash -gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' -``` - -### 3. 変更内容を把握 - -デフォルトブランチとの差分コミットを取得: - -```bash -git log ..HEAD --oneline -``` - -詳細な変更内容を確認: - -```bash -git diff ...HEAD --stat -``` - -### 4. PRを作成 - -```bash -gh pr create --title "<タイトル>" --body "<本文>" -``` - -## PR本文フォーマット(日本語で出力) - -```markdown -## Summary - - - -## Design - - -### コンポーネント構成 -```mermaid -graph TD - A[ParentComponent] --> B[ChildComponent] - B --> C[SubComponent] -``` - -### データフロー -```mermaid -sequenceDiagram - User->>Component: アクション - Component->>Convex: mutation呼び出し - Convex-->>Component: 結果返却 -``` - -## Changes - -- **変更1**: 具体的な説明 -- **変更2**: 具体的な説明 -``` - -## 注意事項 - -- PRのタイトルと本文は**日本語**で作成すること -- コミットメッセージを参考にしつつ、読み手にわかりやすい表現にする -- mermaid図は変更内容に応じて適切なものを選択(不要なら省略可) - - コンポーネント構成: 新規コンポーネント追加時 - - データフロー: API連携やデータの流れが重要な場合 -- 変更が小さい場合はDesignセクションを省略してシンプルに保つ diff --git a/.claude/skills/doc-update/SKILL.md b/.claude/skills/doc-update/SKILL.md deleted file mode 100644 index 48769341..00000000 --- a/.claude/skills/doc-update/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: doc-update -description: 機能実装後のドキュメント更新をサポート。新機能追加、API変更、画面追加、スキーマ変更時に doc/ARCHITECTURE.md、doc/INDEX.md、doc/features/*.md を更新する手順と判断基準を提供。 ---- - -# ドキュメント更新ガイド - -## 更新フロー - -1. 今回の変更内容を確認 -2. 下記の判断基準で更新対象を特定 -3. 該当ドキュメントを更新 -4. 更新内容をユーザーに報告 - -## 更新判断基準 - -### 更新が必要 -| 変更内容 | 更新対象 | -|----------|----------| -| 新機能追加 | `features/新機能.md` 新規作成 + `INDEX.md` | -| 既存機能の仕様変更 | 該当の `features/*.md` | -| データモデル変更 | 該当の `features/*.md` データモデルセクション | -| API追加・削除 | 該当の `features/*.md` APIセクション | -| 画面追加・削除 | 該当の `features/*.md` 画面一覧 | -| コンポーネント構造変更 | `ARCHITECTURE.md` 機能マッピング | -| ディレクトリ構造変更 | `ARCHITECTURE.md` ディレクトリ構造図 | -| 技術スタック変更 | `ARCHITECTURE.md` 技術スタック | -| 状態管理変更 | `ARCHITECTURE.md` 状態管理セクション | - -### 更新不要 -- バグ修正(仕様変更なし) -- リファクタリング(構造変更なし) -- スタイル修正 -- テスト追加 -- パフォーマンス改善 - -## 各ファイルの更新内容 - -### doc/ARCHITECTURE.md -- ディレクトリ構造図 -- 機能→ファイルマッピング -- ファイル→機能マッピング -- データフロー図 -- コンポーネント責務 -- 状態管理(Jotai) -- 技術スタック - -### doc/INDEX.md -- 機能一覧テーブル -- 新機能のリンク追加 - -### doc/features/*.md -テンプレートは [templates.md](references/templates.md) 参照 - -## チェックリスト - -- [ ] 新しいテーブル/フィールド追加? → データモデル更新 -- [ ] 新しいAPI追加? → APIセクション更新 -- [ ] 新しい画面追加? → 画面一覧更新 -- [ ] 新しいコンポーネント追加? → コンポーネント構成更新 -- [ ] ディレクトリ構造変更? → ARCHITECTURE.md更新 -- [ ] 新機能? → features/*.md新規作成 + INDEX.md追加 diff --git a/.claude/skills/doc-update/references/templates.md b/.claude/skills/doc-update/references/templates.md deleted file mode 100644 index e1661c1a..00000000 --- a/.claude/skills/doc-update/references/templates.md +++ /dev/null @@ -1,86 +0,0 @@ -# ドキュメントテンプレート - -## features/*.md テンプレート - -```markdown -# 機能名 - -## 概要 - -[機能の説明] - -## 関連ファイル - -- **Routes**: `src/routes/...` -- **Pages**: `src/components/pages/...` -- **Features**: `src/components/features/...` -- **Convex**: `convex/[domain]/` - -## 主な機能 - -- 機能1 -- 機能2 - -## データモデル - -\`\`\`typescript -tableName = { - field1: type, - field2: type, -} -\`\`\` - -## 画面一覧 - -| 画面 | パス | 説明 | -|------|------|------| -| 画面名 | `/path` | 説明 | - -## コンポーネント構成 - -[構成の説明] - -## API - -### Queries -- `domain.queries.xxx` - 説明 - -### Mutations -- `domain.mutations.xxx` - 説明 -``` - ---- - -## INDEX.md への追記形式 - -```markdown -| 機能名 | [機能名.md](features/機能名.md) | 概要説明 | -``` - ---- - -## ARCHITECTURE.md 機能マッピング形式 - -### 機能→ファイルマッピング -```markdown -| 機能名 | `Pages/...` | `Features/...` | `domain/queries`, `mutations` | -``` - -### ファイル→機能マッピング(逆引き) -```markdown -### 機能名 -| ファイルパス | 責務 | -|-------------|------| -| `src/routes/...` | ルーティング | -| `src/components/pages/...` | useQuery、エラー/ローディング処理 | -| `src/components/features/...` | ドメインロジック、UI | -| `convex/domain/` | DB操作 | -``` - ---- - -## 状態管理(Jotai)追加形式 - -```markdown -| `xxxAtom` | 責務説明 | メモリ or localStorage | -``` \ No newline at end of file diff --git a/.claude/skills/resume-plan/SKILL.md b/.claude/skills/resume-plan/SKILL.md deleted file mode 100644 index 58e2dbc5..00000000 --- a/.claude/skills/resume-plan/SKILL.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: resume-plan -description: 実装計画の続きから作業を再開。doc/plans/ の計画ファイルを読み込み、「8. 現在の進捗」セクションから次のタスクを把握して実装を継続する。セッション開始時やコンテキスト圧縮後の復帰に使用。トリガー - 「計画を再開」「続きから」「resume」「plan再開」 ---- - -# 実装計画再開スキル - -**目的**: 保存された実装計画から作業を再開し、コンテキストを復元する - -## 使用タイミング - -- セッション開始時に作業中の計画がある場合 -- コンテキスト圧縮後の復帰時 -- ユーザーが「計画を再開して」「続きから」と指示したとき -- `/resume-plan` で明示的に呼び出されたとき - -## ワークフロー - -### Step 1: 計画ファイルの確認 - -`doc/plans/` ディレクトリを確認し、計画ファイルを一覧表示する。 - -```bash -# 計画ファイルの確認 -ls doc/plans/ -``` - -### Step 2: 計画ファイルの選択 - -**選択ロジック**: - -| 条件 | アクション | -|------|-----------| -| 計画が1つだけ | その計画を自動選択 | -| 計画が複数 | ユーザーに選択を求める(AskUserQuestion) | -| 「作業中」ステータスの計画がある | それを優先提案 | -| 計画がない | ユーザーに報告して終了 | - -### Step 3: 計画ファイルの読み込み - -選択された計画ファイルを読み込み、以下を確認: - -1. **ステータス**: 作業中 / 完了 / 保留 -2. **8. 現在の進捗**セクション: - - 最終更新日時 - - 完了ステップ - - 次にやること - - 残りのタスク - - ブロッカー・課題 - -### Step 4: TodoWrite でタスクをセットアップ - -「残りのタスク」から未完了の項目を抽出し、TodoWrite でセットアップする。 - -``` -残りのタスク: -- [ ] タスクA → TodoWrite で "pending" として追加 -- [ ] タスクB → TodoWrite で "pending" として追加 -``` - -### Step 5: 実装開始 - -ユーザーに現状を報告し、最初のタスクから実装を開始する。 - -**報告テンプレート**: -``` -計画「{計画名}」を読み込んだよ〜! - -**現在の進捗**: -- 完了: Step X まで -- 次にやること: {具体的タスク} - -**残りタスク**: -- [ ] タスクA -- [ ] タスクB - -それでは {次のタスク} から始めるね〜! -``` - -## 実装開始前のチェックリスト - -- [ ] 計画ファイルを読み込んだ -- [ ] 「8. 現在の進捗」を確認した -- [ ] ブロッカー・課題がないか確認した -- [ ] TodoWrite で残りタスクをセットアップした -- [ ] ユーザーに現状を報告した - -## save-plan との連携 - -| スキル | タイミング | 役割 | -|--------|-----------|------| -| `save-plan` | 計画作成時・進捗更新時 | 計画を保存・更新 | -| `resume-plan` | セッション開始時・復帰時 | 計画を読み込み・作業再開 | - -**ベストプラクティス**: -1. 作業開始前: `resume-plan` で計画を読み込む -2. 作業中: 進捗に応じて計画ファイルを更新 -3. 作業終了時: `save-plan` で最終進捗を保存 - -## 注意事項 - -- 計画ファイルが存在しない場合は、新規作成を提案する -- 複数の計画がある場合は、必ずユーザーに選択を求める -- 「完了」ステータスの計画は、再開の必要がないことを確認する -- Phaseが複数ある場合、次に実装すべきPhaseの実装計画を検討し、実装開始の承認を得ること -- ui, ux関連の実装の場合、/ux-designer, /fronend-design のスキルも利用すること diff --git a/.claude/skills/save-plan/SKILL.md b/.claude/skills/save-plan/SKILL.md deleted file mode 100644 index dcc60ac0..00000000 --- a/.claude/skills/save-plan/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: save-plan -description: 実装計画をファイルに保存する。実装計画、タスク計画、作業計画を立てた後に使用。コンテキスト圧縮後も参照可能にするため doc/plans/ に保存する。トリガーキーワード: 実装計画, 計画保存, plan, 作業計画 ---- - -# 実装計画保存スキル - -**目的**: 実装計画をファイルに保存し、コンテキスト圧縮後も参照できるようにする - -## 使用タイミング - -- 3ステップ以上の実装計画を立てた直後 -- 複数ステップの作業計画を作成した後 -- ユーザーが「計画を保存して」と指示したとき - -## ファイル命名規則 - -`doc/plans/yyyy-mm-dd_<機能名または概要>.md` - -例: -- `doc/plans/2026-01-14_シフト管理機能_実装計画.md` -- `doc/plans/2026-01-14_認証フロー改善.md` - -## 保存テンプレート - -```markdown -# <機能名> - 実装計画 - -**作成日**: yyyy-mm-dd -**ステータス**: 作業中 | 完了 | 保留 -**参照仕様書**: [ある場合はリンク] - ---- - -## 1. 概要 -[タスクの背景と目的を簡潔に] - -## 2. スコープ - -### 対象 -| 画面/機能 | 状態 | 説明 | -|-----------|------|------| -| ... | 新規/既存 | ... | - -### 対象外(今回のスコープ外) -- [今回含まれないもの] - -## 3. 技術的な決定事項 -[なぜこのアプローチを選んだか、検討した代替案など] - -## 4. UI設計(必要に応じて) -[ワイヤーフレームやモック図] - -## 5. ファイル構成 -[ディレクトリツリー] - -## 6. データ型定義(必要に応じて) -[TypeScript型定義] - -## 7. 実装ステップ - -### Step 1: [ステップ名] -- [ ] タスク1 -- [ ] タスク2 -- 対象ファイル: `path/to/file.ts` - -### Step 2: [ステップ名] -... - -## 8. 現在の進捗 🔴 - -**最終更新**: yyyy-mm-dd HH:mm -**完了ステップ**: Step X まで完了 -**次にやること**: Step Y の [具体的タスク] から再開 - -### 完了したタスク -- [x] タスクA - -### 残りのタスク -- [ ] タスクB - -### ブロッカー・課題(あれば) -- [詰まっている点] - -## 9. 参考ファイル一覧 - -### パターン参照用 -| 目的 | ファイルパス | -|------|-------------| -| ... | `path/to/file.ts` | - -### 変更対象ファイル -| ファイル | 操作 | 変更内容 | -|----------|------|----------| -| `path/to/file.ts` | 新規/修正 | ... | - -## 10. 検証方法 -[コマンドや確認手順] - -## 11. 注意事項 -- [実装時の注意点] - ---- - -**更新履歴**: -- yyyy-mm-dd HH:mm: 初版作成 -``` - -## ワークフロー - -### 計画保存時 -1. 現在のコンテキストから計画内容を抽出 -2. ファイル名を決定(日付 + 機能名) -3. `doc/plans/` ディレクトリが存在するか確認、なければ作成 -4. テンプレートに沿ってファイル保存 -5. ユーザーに保存先パスを通知 - -### 進捗更新時 -1. 該当する計画ファイルを読み込み -2. 「8. 現在の進捗」セクションを更新 - - 最終更新日時 - - 完了ステップ - - 次にやること - - 完了/残りタスクのチェックリスト -3. 更新履歴に追記 - -## 注意事項 - -- 既存の計画ファイルがあれば上書きではなく更新(または別名で保存) -- 計画の進捗に応じてステータスを更新 -- clear後は `doc/plans/` を確認して作業を再開 -- 次世代のあなた向けに調査内容のまとめも同じファイルに書いておいてください! From 944571e7e5f38626d61208a31869b8d160fff081 Mon Sep 17 00:00:00 2001 From: y-natani Date: Mon, 23 Mar 2026 23:17:50 +0900 Subject: [PATCH 002/176] =?UTF-8?q?chore:=20commit=E3=82=B9=E3=82=AD?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E8=BF=BD=E5=8A=A0=EF=BC=88=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E3=82=B3=E3=83=9F=E3=83=83=E3=83=88=E5=88=86=E5=89=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/commit/SKILL.md | 90 ++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .claude/skills/commit/SKILL.md diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 00000000..795fce04 --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,90 @@ +--- +name: commit +description: 変更を論理単位で分析し、自動で複数コミットに分割して作成する。「コミットして」「/commit」などのトリガーで発動。 +--- + +# Auto-Commit + +現在の変更(staged + unstaged + untracked)を分析し、論理単位ごとに分割して自動コミットする。 + +## ワークフロー + +### 1. 品質チェック + +```bash +pnpm lint +pnpm type-check +``` + +失敗したら修正してから再実行。自動修正可能な場合は `pnpm format` で修正。 + +### 2. 変更の収集 + +```bash +git status # -uall禁止 +git diff # unstaged +git diff --cached # staged +``` + +変更なしなら「コミットする変更がありません」と報告して終了。 + +### 3. 除外ファイル(絶対にコミットしない) + +- `.env*` / credentials / secrets系 + +これらが変更に含まれる場合は `git checkout` で戻すか、ステージングから除外する。 + +### 4. 論理グループへの分割 + +**同一グループにすべきもの:** +- Convex schema変更 + 関連する queries/mutations/policies +- コンポーネント + そのStory (.stories.tsx) +- routes/ + pages/ + features/ が同一機能に属する場合 +- テストファイル + テスト対象ファイル +- 同一ドメイン(Shop, Shift, Staff等)の一連の変更 + +**分離すべきもの:** +- feat vs fix vs refactor vs chore(種類が異なる変更) +- 無関係なドメインの変更 +- ドキュメントのみの変更 +- 依存関係の更新 + +**判断基準:** 「このコミットを revert したとき、意味のある単位で元に戻るか?」 + +### 5. コミット作成 + +各グループについて順番に: + +1. `git add <対象ファイル>` で個別にステージング(`git add .` / `git add -A` 禁止) +2. HEREDOCでコミットメッセージを渡す: + +```bash +git commit -m "$(cat <<'EOF' +: <日本語の簡潔な説明> + +Co-Authored-By: Claude Opus 4.6 (1M context) +EOF +)" +``` + +### 6. 完了報告 + +作成したコミット一覧を `git log --oneline` で表示。 + +## コミットメッセージ + +- **type**: feat / fix / refactor / chore / docs / test +- **説明**: 「何が変わるか」を日本語で簡潔に +- scope括弧なし(プロジェクト慣習) +- PR番号なし(PRマージ時に付与) + +例: +- `feat: スタッフ用シフト提出ページの追加` +- `fix: シフト一覧の日付ソートが逆順になる問題を修正` +- `refactor: ShiftFormの状態管理をJotaiに移行` + +## 禁止事項 + +- `git add -i` / `git rebase -i` など対話的コマンド +- `--amend`(失敗時は新規コミット) +- `--no-verify`(フック省略禁止) From 88f18576d2b2eae1c703528d019b9bcea10b88bc Mon Sep 17 00:00:00 2001 From: y-natani Date: Mon, 23 Mar 2026 23:17:57 +0900 Subject: [PATCH 003/176] =?UTF-8?q?docs:=20CLAUDE.md=E3=81=AE=E8=AA=AC?= =?UTF-8?q?=E6=98=8E=E5=85=85=E5=AE=9F=E3=83=BB=E8=87=AA=E5=8B=95=E7=94=9F?= =?UTF-8?q?=E6=88=90=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E6=B3=A8?= =?UTF-8?q?=E6=84=8F=E4=BA=8B=E9=A0=85=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 30 +++++++++++++++++++++++++----- convex/_generated/api.d.ts | 2 ++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index acb24d33..d9b39e11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,16 +26,25 @@ pnpm convex:dev # Convex開発サーバー ### 単一テスト実行 ```bash -pnpm vitest --project=logic src/path/to/file.test.ts # 特定ファイル +pnpm vitest --project=logic src/path/to/file.test.ts # 特定ロジックテスト pnpm vitest --project=logic -t "テスト名" # 特定テスト名 +pnpm vitest --project=ui # UIテスト(Storybook必須) pnpm e2e e2e/path/to/file.spec.ts # 特定E2Eファイル ``` +- `logic`プロジェクト: `src/**/*.test.ts` のユニットテスト +- `ui`プロジェクト: Storybook + Playwright(ブラウザモード)でのインタラクションテスト + +### 環境変数 + +- `.env`ファイルはGoogle Drive(`/g/マイドライブ/80_環境変数/yps-crispy-carnival/`)にシンボリックリンク +- `pnpm convex:env:setup`で環境変数を同期 + ## アーキテクチャ ### 技術スタック -React 19 / Vite / TanStack Router / Chakra UI v3 / React Hook Form + Zod / Jotai / Clerk(認証) / Convex(BaaS) / Biome(lint/format) +React 19 / Vite 7 / TanStack Router / Chakra UI v3 / React Hook Form + Zod 4 / Jotai / Clerk(認証) / Convex(BaaS) / Biome(lint/format) ### レイヤー構造とデータフロー @@ -49,7 +58,7 @@ features/ → ドメインロジック、useMutation、UI組成 convex/ → queries.ts(読み取り) / mutations.ts(書き込み) / policies.ts(権限判定) ``` -- **routes/**: TanStack Routerのファイルベースルーティング。ページコンポーネントの呼び出し**のみ** +- **routes/**: TanStack Routerのファイルベースルーティング。ページコンポーネントの呼び出し**のみ**。`_auth/`(Clerk認証必須)と`_unregistered/`(ゲスト)でレイアウト分離 - **pages/**: `useQuery`でデータ取得し、エラー/ローディング/正常系を振り分け。正常系のみfeaturesを呼ぶ - **features/**: ドメイン別ディレクトリ(Shop, Shift, Staff等)。`useMutation`はここで定義 - **ui/**: 汎用UIコンポーネント(FormCard, BottomSheet等)。Select, DialogなどChakra UIのラッパーもここに配置 @@ -92,7 +101,7 @@ import { bar } from "@/convex/..."; ### バリデーション -- Zodスキーマ + カスタムエラーマップ(日本語メッセージ) +- Zod v4スキーマ + カスタムエラーマップ(日本語メッセージ) - カスタムバリデータ: `src/helpers/validation/`(`betweenLength`, `time`, `select`等) ### Storybook @@ -109,6 +118,10 @@ import { bar } from "@/convex/..."; ## コーディング - `pnpm lint`, `pnpm type-check`を必ず実行すること +- 以下の自動生成ファイルは絶対に手動で編集しないこと(各ツールが自動再生成する) + - `convex/_generated/` — Convex CLIが生成(`pnpm convex:dev`) + - `src/routeTree.gen.ts` — TanStack Routerが生成(`pnpm dev`) + - `pnpm-lock.yaml` — pnpmが管理 ## プラン @@ -118,7 +131,14 @@ import { bar } from "@/convex/..."; - `doc/ARCHITECTURE.md`: 全体構造、機能→ファイルマッピング、データフロー - `doc/INDEX.md`: 機能仕様ドキュメントのインデックス -- `doc/features/`: 各機能の仕様 +- `doc/features/`: 各機能の概要(関連ファイル・画面一覧・API一覧)。詳細な仕様はコードを参照(Single Source of Truth) - `doc/plans/`: 実装計画 - `doc/claude/soul.md`: 設計判断の指針 - `convex/CLAUDE.md`: Convexアーキテクチャの詳細 + +### ドキュメント運用ルール + +- 新機能を実装したら `doc/features/` に概要ドキュメントを作成・更新する +- 機能概要には: 機能説明(1-2文)、関連ファイルパス、画面一覧、API一覧を含める +- 詳細な仕様・ロジックはコードに書く(ドキュメントとコードの二重管理を避ける) +- `doc/INDEX.md` に新規ドキュメントへのリンクを追加する diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index acf33c4e..a94f107c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -27,6 +27,7 @@ import type * as shop_mutations from "../shop/mutations.js"; import type * as shop_queries from "../shop/queries.js"; import type * as staffSkill_mutations from "../staffSkill/mutations.js"; import type * as staffSkill_queries from "../staffSkill/queries.js"; +import type * as staff_mutations from "../staff/mutations.js"; import type * as testing from "../testing.js"; import type * as user_mutations from "../user/mutations.js"; import type * as user_queries from "../user/queries.js"; @@ -57,6 +58,7 @@ declare const fullApi: ApiFromModules<{ "shop/queries": typeof shop_queries; "staffSkill/mutations": typeof staffSkill_mutations; "staffSkill/queries": typeof staffSkill_queries; + "staff/mutations": typeof staff_mutations; testing: typeof testing; "user/mutations": typeof user_mutations; "user/queries": typeof user_queries; From 1d431c5ffab885ef7fd15a63823ff8d648aa1bd6 Mon Sep 17 00:00:00 2001 From: y-natani Date: Mon, 23 Mar 2026 23:33:04 +0900 Subject: [PATCH 004/176] =?UTF-8?q?fix:=20=E5=85=85=E8=B6=B3=E5=BA=A6?= =?UTF-8?q?=E3=81=AE=E6=95=B0=E5=80=A4=E8=A1=A8=E7=A4=BA=E3=81=8C=E3=82=B0?= =?UTF-8?q?=E3=83=AA=E3=83=83=E3=83=89=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=A8?= =?UTF-8?q?=E3=81=9A=E3=82=8C=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx index a6e9e519..1cf5fafa 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx @@ -2,7 +2,7 @@ import { Box, Flex, Icon, IconButton, Table, Text } from "@chakra-ui/react"; import { useMemo } from "react"; import { LuChevronDown, LuChevronRight, LuHash, LuInfo, LuPalette } from "react-icons/lu"; import { Tooltip } from "@/src/components/ui/tooltip"; -import { FILL_RATE_COLORS } from "../../constants"; +import { FILL_RATE_COLORS, TIME_AXIS_PADDING_PX } from "../../constants"; import type { PositionType, ShiftData, SummaryDisplayMode, TimeRange } from "../../types"; import { GridLines } from "./ShiftGrid/GridLines"; @@ -199,7 +199,7 @@ export const SummaryRow = ({ ) : ( - + {timeSlots.map((time, idx) => { const count = totalCounts[idx]; const { text } = getFillRateColor(count, requiredCountPerHour); @@ -249,7 +249,7 @@ export const SummaryRow = ({ ) : ( - + {timeSlots.map((time, idx) => { const count = counts[idx]; const { text } = getFillRateColor(count, required); From 18e58773e5d198268a7b1c1ddc3eacdbfa2ba51a Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 00:01:03 +0900 Subject: [PATCH 005/176] =?UTF-8?q?chore:=20discuss=E3=82=B9=E3=82=AD?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/discuss/SKILL.md | 114 ++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .claude/skills/discuss/SKILL.md diff --git a/.claude/skills/discuss/SKILL.md b/.claude/skills/discuss/SKILL.md new file mode 100644 index 00000000..1570d851 --- /dev/null +++ b/.claude/skills/discuss/SKILL.md @@ -0,0 +1,114 @@ +--- +name: discuss +description: 実装方針や設計の悩みを、複数のペルソナによるSlack風チャット形式で議論し、最終的に修正・実装プランを作成する。「相談したい」「議論しよう」「discuss」「設計どうしよう」「方針決めたい」などのトリガーで発動。コードの設計判断、リファクタリング方針、技術選定、バグ修正のアプローチなど、一人で決めにくい課題に特に有効。 +--- + +# Discuss — ペルソナ議論で実装プランを作る + +ユーザーの悩み・課題を、動的に生成した3人のペルソナと一緒にSlack風のカジュアルな議論で解決し、最終的に `doc/plans/` に実装プランを保存する。 + +## なぜこのスキルが必要か + +一人で設計を考えると視野が狭くなりがち。複数の専門家視点でわいわい議論することで、見落としやトレードオフに気づきやすくなる。堅苦しいレビューではなく、Slackの雑談チャンネルのような雰囲気で、気軽に意見を出し合えるのがポイント。 + +## ワークフロー + +### Phase 1: 悩みのヒアリング + +ユーザーの悩みを聞き出す。このとき: + +- 関連するコードを積極的に読んで現状を把握する +- 「何に困っているか」「どこまで考えたか」「制約はあるか」を自然に聞く +- 一度に複数質問せず、1つずつ聞く +- 十分に理解できたらPhase 2に進む + +### Phase 2: ペルソナ生成 & 議論 + +悩みの内容に応じて **3人のペルソナ** を動的に生成する。 + +#### ペルソナの設計ルール + +- **専門性**: 悩みの解決に必要な異なる専門領域から選ぶ(例: バックエンド / フロントエンド / インフラ) +- **性格**: 全員違う性格にする。慎重派・楽観派・実務派など、視点の多様性を確保する +- **トレードマーク絵文字**: 各ペルソナにランダムな絵文字を1つ割り当てる(Slackアイコンの代わり) +- **名前**: 短くて覚えやすい日本語の名前 + +ペルソナ生成時に、一覧を表示してユーザーに紹介する: + +``` +今回のメンバー紹介するね〜! + +🦊 タケシ — バックエンドエンジニア / 石橋を叩いて渡るタイプ、慎重派 +🌵 ミカ — フロントエンドエンジニア / とりあえずやってみよう精神、行動派 +🎸 リョウ — テックリード / バランス重視、落とし所を見つけるのが上手い +``` + +#### 議論のフォーマット + +Slackのチャットっぽく、カジュアルに進行する。各発言は短めに(2-4文程度)。 + +``` +🦊 タケシ: それDBのインデックス貼らないとやばくない?N+1も気になるし 🤔 +🌵 ミカ: でもまずは動くもの作ってからでよくない?最適化は後でもいけるっしょ〜 💪 +🎸 リョウ: 一理あるけど、テーブル設計は後から変えるのしんどいから、そこだけ先に固めとこうよ 👍 +``` + +#### 議論の進め方 + +- ペルソナ同士で自然に会話させる(意見のぶつかり合いOK) +- ユーザーにも意見を求める(「〇〇さんはどう思う?」) +- 適度にユーザーの反応を待つ。一気に長い議論を流さない +- 論点が出揃ったら、合意形成に向けてまとめ役(テックリード的なペルソナ)が整理する +- 必要に応じてコードを読んで具体的な提案をする + +#### ユーザーへの質問のタイミング + +- 方向性の分岐点で意見を聞く +- 議論が白熱してきたら「ここまでどう?」と確認する +- ペルソナの意見が割れたとき、ユーザーに決めてもらう + +### Phase 3: プランの作成 + +議論がまとまってきたら、実装プランを作成して `doc/plans/` に保存する。 + +#### プランのファイル命名 + +`doc/plans/YYYYMMDD-<トピック名>.md`(例: `doc/plans/20260323-shift-form-refactor.md`) + +#### プランの構成 + +```markdown +# <プランタイトル> + +## 背景・課題 + +<議論で明らかになった課題の要約> + +## 方針 + +<決定した方針と、その理由> + +## 実装ステップ + +1. <ステップ1> + - 対象ファイル: `path/to/file.ts` + - 内容: ... +2. <ステップ2> + ... + +## 参考ファイル + +- `path/to/relevant/file.ts` — <なぜ参考になるか> + +## 議論で出た懸念点・注意事項 + +- <懸念点1> +- <懸念点2> +``` + +参考ファイルのパスは必ず記載すること(CLAUDE.mdのルール)。 + +#### 保存後 + +- プランの内容をチャット上でも簡潔に共有する +- 「これで進めていい?」とユーザーに最終確認する From 5be8797aea00695c554cfd2799a17d849ce9391c Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 00:01:08 +0900 Subject: [PATCH 006/176] =?UTF-8?q?docs:=20ShiftForm=E3=83=87=E3=82=B6?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=AC=E3=82=A4=E3=83=89=E3=83=BB=E3=83=87?= =?UTF-8?q?=E3=82=B6=E3=82=A4=E3=83=B3=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/design/ShiftEditForm.pen | 17 ++ doc/plans/20260323-shiftform-design-guide.md | 300 +++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 doc/design/ShiftEditForm.pen create mode 100644 doc/plans/20260323-shiftform-design-guide.md diff --git a/doc/design/ShiftEditForm.pen b/doc/design/ShiftEditForm.pen new file mode 100644 index 00000000..119d7959 --- /dev/null +++ b/doc/design/ShiftEditForm.pen @@ -0,0 +1,17 @@ +{ + "version": "2.9", + "children": [ + { + "type": "frame", + "id": "bi8Au", + "x": 0, + "y": 0, + "name": "Frame", + "clip": true, + "width": 800, + "height": 600, + "fill": "#FFFFFF", + "layout": "none" + } + ] +} \ No newline at end of file diff --git a/doc/plans/20260323-shiftform-design-guide.md b/doc/plans/20260323-shiftform-design-guide.md new file mode 100644 index 00000000..c3eb2440 --- /dev/null +++ b/doc/plans/20260323-shiftform-design-guide.md @@ -0,0 +1,300 @@ +# ShiftForm デザイン作成ガイド(pencil.dev用) + +**作成日**: 2026-03-23 + +--- + +## 背景・課題 + +ShiftFormは4画面(PC/SP × DailyView/OverviewView)にまたがる複雑なコンポーネント。 +pencil.devでデザインを作成する際、Claudeへの指示が曖昧だと画面間で統一感がなくなる。 + +## 方針 + +- **共通パーツを先に定義** → 各画面で使い回すことで統一感を保つ +- **1回の指示で1〜2画面ずつ** 作成する(情報量を絞って精度を上げる) +- 実装の詳細(atom、hooks、API等)は書かない。見た目に必要な情報だけ伝える + +## 進め方 + +``` +Step 1: スタイルガイド取得 & 共通パーツ定義 +Step 2: PC DailyView +Step 3: PC OverviewView +Step 4: SP DailyView +Step 5: SP OverviewView +``` + +--- + +## Step 1: 共通パーツ定義 + +以下をそのままClaude に投げる。 + +``` +■ ShiftForm 共通パーツ定義 + +シフト管理画面で使い回すパーツを先に定義してください。 +以下の4画面で共通で使います: +- PC DailyView(タイムグリッド形式のシフト編集) +- PC OverviewView(月間テーブル形式の一覧確認) +- SP DailyView(カード形式のシフト編集) +- SP OverviewView(月間テーブル形式の一覧確認) + +【共通パーツ一覧】 + +◇ ポジションバッジ +- ポジション色(背景)+ ポジション名のラベル +- 小サイズ(テーブルセル内用)と通常サイズ(カード内用)の2種 +- データ例: ホール(#3b82f6)、キッチン(#ef4444)、レジ(#22c55e)、休憩(グレー) + +◇ ポジションバー +- 横長の色付きバー。ポジション色 + ポジション名 + 時間範囲テキスト +- データ例: [■ ホール 10:00-13:00] [■ 休憩 13:00-14:00] [■ キッチン 14:00-18:00] + +◇ 日付タブ +- "3/23(月)" 形式の日付が横に並ぶ(横スクロール) +- 選択中: ハイライト / 祝日: 赤文字 +- データ例: 3/21(金・祝), 3/22(土), 3/23(日), 3/24(月), ... + +◇ 充足度インジケーター +- 時間帯ごとの必要人員に対する充足率を色で表現 +- 6段階: 赤(0%) → オレンジ → 黄 → 黄緑 → 緑 → 青(超過) +- コンパクト版(セル内の小さいドット)とバー版(DailyView下部の帯) + +◇ ビュー切替トグル +- 「日別」「一覧」の2つを切り替えるセグメントコントロール + +◇ ソートメニュー +- ドロップダウン。選択肢: デフォルト順 / 希望時間順 / 開始時刻順 +``` + +--- + +## Step 2: PC DailyView + +``` +■ PC DailyView(PC・シフト日別編集) + +【目的】特定の日のスタッフ全員のシフトをタイムグリッド上で編集する + +【レイアウト】 +- 上部ツールバー: 日付タブ + ビュー切替 + Undo/Redoボタン + ソートメニュー +- ツールバー直下: ポジション選択ボタン群(ホール、キッチン等。選択中はハイライト) +- 中央: タイムグリッド(横軸=時間、縦軸=スタッフ。横スクロール可) +- 最下行: 充足度サマリー行(時間帯ごとの色付きバー) + +【レイアウト図】 +| | 9:00 | 9:30 | 10:00 | 10:30 | ... | 21:30 | +|--------------|-------|-------|-------|-------|-----|-------| +| 田中太郎 | | ██ ホール ██████████ | | | +| 希望10-18 | | | | | | | +| 佐藤花子 | ████ キッチン ████ | | | | +| 希望9-15 | | | | | | | +| 鈴木一郎 | | | | | | | +| 未提出 | | | | | | | +|--------------|-------|-------|-------|-------|-----|-------| +| 充足度 | ██ | ████ | ██████| | | | + +【各パーツの中身】 +◇ スタッフ行(左固定列) +- スタッフ名 +- 希望時間のグレーバー(読み取り専用の背景表示) +- 希望未提出の場合は「未提出」テキスト + +◇ グリッドセル +- ポジション色で塗られたバー。バー上にポジション名テキスト +- 30分刻みのグリッド線 +- バーの両端はドラッグでリサイズ可能(リサイズハンドル表示) + +◇ ポジション選択ツールバー +- 各ポジションのボタン(色付き)が横に並ぶ +- 選択中のポジションはハイライト + チェックマーク +- 消しゴムボタン(削除モード用) + +【状態バリエーション】 +- 割当なし: グリッド行が空(希望時間バーのみ表示) +- 休憩: グレーのバーがポジション間に自動挿入 +- 選択中ポジション: ポップオーバーで詳細表示(ポジション名、時間、削除ボタン) + +【データ例】 +- スタッフ: 田中太郎、佐藤花子、鈴木一郎、山田次郎、高橋美咲(5名) +- ポジション: ホール(#3b82f6)、キッチン(#ef4444)、レジ(#22c55e) +- 営業時間: 9:00-22:00(30分刻み) +- 田中太郎: ホール10:00-13:00 → 休憩13:00-14:00 → キッチン14:00-18:00 +- 佐藤花子: キッチン9:00-13:00 → 休憩13:00-13:30 → ホール13:30-15:00 +``` + +--- + +## Step 3: PC OverviewView + +``` +■ PC OverviewView(PC・月間シフト一覧) + +【目的】月全体のシフト割当状況を俯瞰し、労働時間や連勤のアラートを確認する + +【レイアウト】 +- 上部: ビュー切替 + ソートメニュー +- 中央: テーブル(縦軸=スタッフ、横軸=日付。横スクロール可) +- 右固定列: 月合計時間 + アラート +- 最下行: 日ごとの充足度サマリー + +【レイアウト図】 +| | 3/1(土) | 3/2(日) | 3/3(月) | ... | 3/31(火) | 合計 | +|--------------|---------|---------|---------|-----|----------|---------| +| 田中太郎 | 10-18 | - | 10-18 | | 9-15 | 168h ⚠ | +| 佐藤花子 | 9-15 | 9-15 | - | | 10-18 | 120h | +| 鈴木一郎 | - | 10-18 | 10-18 | | - | 96h | +|--------------|---------|---------|---------|-----|----------|---------| +| 充足度 | ●● | ●●● | ●● | | ●●●● | | + +【各パーツの中身】 +◇ スタッフ行(左固定列) +- スタッフ名(クリックで日別ビューへ遷移) + +◇ 日付セル +- シフトがある日: 開始-終了時刻テキスト + ポジション色のドット +- シフトなしの日: "-" +- セルクリックでその日の日別ビューへ遷移 + +◇ 合計列(右固定列) +- 月間合計労働時間(例: "168h") +- アラートアイコン: + - ⚠ 週40時間超過 + - 🔴 連勤6日以上 + +◇ 充足度サマリー行(最下行) +- 日ごとの充足度をドットの色で表現(6段階) + +【状態バリエーション】 +- アラートなし: 合計時間のみ表示 +- 週40h超過: オレンジ警告アイコン + ツールチップで詳細 +- 連勤6日以上: 赤アイコン + 該当日付範囲をハイライト +- 祝日列: ヘッダーが赤文字 + +【データ例】 +- 期間: 2026年3月(3/1〜3/31) +- スタッフ5名(Step 2と同じ) +- 田中太郎: 月168h(週40h超過アラートあり) +- 鈴木一郎: 3/15-3/21 連勤(連勤アラートあり) +``` + +--- + +## Step 4: SP DailyView + +``` +■ SP DailyView(スマホ・シフト日別編集) + +【目的】特定の日のスタッフごとのシフト割当を確認・編集する + +【レイアウト】 +- 上部: 日付タブ(横スクロール)+ ソートボタン +- 中央: スタッフカードのリスト(縦スクロール) +- 下部: 充足度サマリーバー(固定フッター) + +【各パーツの中身】 +◇ スタッフカード +- スタッフ名 +- 希望時間テキスト(例: "希望: 10:00 - 18:00"、未提出なら"未提出") +- 割当ポジション: ポジションバーが時系列で縦に並ぶ + - 各バー: ポジション色 + ポジション名 + "10:00-14:00" +- カードタップでシフト編集シートが開く + +◇ シフト編集シート(ボトムシート) +- タイトル: "{スタッフ名} {M/D(曜日)}" +- 希望時間表示: "希望: 10:00 - 18:00"(読み取り専用) +- 割当済みポジション一覧: + - 各行: ポジション色 + ポジション名 + 開始セレクト + 終了セレクト + 削除ボタン +- ポジション追加エリア: + - ポジション選択セレクト + 開始時刻セレクト + 終了時刻セレクト + 追加ボタン +- フッター: 全削除ボタン(左寄せ) + +◇ 充足度サマリーバー +- 時間帯ごとの充足率を色帯で表示(Step 1の充足度インジケーター・バー版) + +【状態バリエーション】 +- 割当なし: カード内に「未割当」テキスト表示 +- 希望未提出: 希望時間が「未提出」表示 +- 休憩あり: グレーのポジションバーが自動挿入 +- 編集シート・ポジション0件: 追加エリアのみ表示 + +【データ例】 +- スタッフ: 田中太郎、佐藤花子、鈴木一郎 +- ポジション: ホール(#3b82f6)、キッチン(#ef4444)、レジ(#22c55e) +- 時間帯: 9:00-22:00(30分刻み) +- 田中太郎の割当: + - ホール 10:00-13:00 + - 休憩 13:00-14:00 + - キッチン 14:00-18:00 +``` + +--- + +## Step 5: SP OverviewView + +``` +■ SP OverviewView(スマホ・月間シフト一覧) + +【目的】月全体のシフト状況をスマホで確認する + +【レイアウト】 +- 上部: ビュー切替 + ソートメニュー +- 中央: テーブル(縦軸=スタッフ、横軸=日付。横スクロール可、左列固定) +- 最下行: 日ごとの充足度サマリー + +【レイアウト図】 +| | 1 | 2 | 3 | ... | 31 | 計 | +|----------|----|----|----| ----|----| -----| +| 田中 | ● | | ● | | ● | 168h⚠| +| 佐藤 | ● | ● | | | ● | 120h | +|----------|----|----|----| ----|----| -----| +| 充足 | 🟢 | 🟡 | 🔴 | | 🟢 | | + +【各パーツの中身】 +◇ スタッフ行(左固定列) +- スタッフ名(省略表示。例: 姓のみ) + +◇ 日付セル +- SPではテキストが入りきらないため、ドット表示 +- シフトあり: ポジション色のドット(複数ポジションなら複数ドット) +- シフトなし: 空欄 +- セルタップでその日の日別ビューへ遷移 + +◇ 合計列(右固定列) +- 月間合計労働時間 +- アラートアイコン(PC版と同じ) + +◇ 充足度サマリー行 +- 日ごとの充足度ドット(色で表現) + +【状態バリエーション】 +- アラートなし: 合計時間のみ +- アラートあり: PC版と同じアイコン表示 +- 祝日列: ヘッダー数字が赤文字 + +【データ例】 +- 期間: 2026年3月(3/1〜3/31) +- スタッフ5名 +- セル幅が狭いため、1日あたり最大3ドットまで表示 +``` + +--- + +## 参考ファイル + +- `src/components/features/Shift/ShiftForm/index.tsx` — ShiftFormトップレベルコンポーネント +- `src/components/features/Shift/ShiftForm/types.ts` — 型定義(ShiftData, PositionSegment等) +- `src/components/features/Shift/ShiftForm/stores.ts` — Jotai atoms(状態管理) +- `src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx` — PC日別ビュー +- `src/components/features/Shift/ShiftForm/sp/DailyView/ShiftEditSheet.tsx` — SP編集ボトムシート +- `src/components/features/Shift/ShiftForm/constants.ts` — 定数(時間軸幅等) + +## 議論で出た懸念点・注意事項 + +- デザインシステムは別途作らない(Chakra UI v3が担う)。共通パーツの定義だけで統一感を保つ +- 1回の指示で1〜2画面ずつ作成し、共通パーツは「Step 1で作ったものを使って」と参照する +- 実装詳細(atom構造、API、hooks)はデザイン指示に含めない。見た目に必要な情報だけ伝える +- データ例は具体的な名前・数値を使う(「テキスト」のような抽象表現はNG) +- PC版の複雑なレイアウトにはASCII図を添えると精度が上がる From 3067dc7e7b741e8590c9f1328d1d5bf7a50a77e5 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 00:01:13 +0900 Subject: [PATCH 007/176] =?UTF-8?q?refactor:=20ShiftForm=20DailyView?= =?UTF-8?q?=E3=81=AE=E6=B6=88=E3=81=97=E3=82=B4=E3=83=A0=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=83=89=E5=89=8A=E9=99=A4=E3=83=BB=E7=A9=BA=E7=8A=B6=E6=85=8B?= =?UTF-8?q?=E3=82=AC=E3=82=A4=E3=83=89=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pc/DailyView/PositionToolbar.tsx | 25 +++--- .../pc/DailyView/ShiftGrid/DragPreview.tsx | 10 --- .../pc/DailyView/ShiftGrid/index.tsx | 81 +++++++++++++++---- .../ShiftForm/pc/DailyView/hooks/useDrag.ts | 78 +----------------- .../Shift/ShiftForm/pc/DailyView/index.tsx | 2 +- .../features/Shift/ShiftForm/types.ts | 4 +- 6 files changed, 83 insertions(+), 117 deletions(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx index af95cbe6..11f8105c 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx @@ -1,5 +1,5 @@ import { Box, Button, Flex, Icon, IconButton, Text } from "@chakra-ui/react"; -import { LuEraser, LuMousePointer2, LuPaintbrush, LuRedo2, LuUndo2 } from "react-icons/lu"; +import { LuMousePointer2, LuPaintbrush, LuRedo2, LuUndo2 } from "react-icons/lu"; import type { PositionType, ToolMode } from "../../types"; type ToolButtonProps = { @@ -46,11 +46,9 @@ export const PositionToolbar = ({ canUndo, canRedo, }: PositionToolbarProps) => { - const isPositionDisabled = toolMode !== "assign"; - return ( - {/* 3グループ: 履歴・ツール・ポジション */} + {/* 履歴・ツール・ポジション */} {/* グループ1: 履歴 */} @@ -84,17 +82,10 @@ export const PositionToolbar = ({ /> onToolModeChange("assign")} /> - onToolModeChange("erase")} - activeColorPalette="red" - /> @@ -102,7 +93,7 @@ export const PositionToolbar = ({ {/* グループ3: ポジション */} - + ポジション @@ -117,7 +108,13 @@ export const PositionToolbar = ({ bg={isSelected ? position.color : "transparent"} borderColor={position.color} color={isSelected ? "white" : "gray.700"} - onClick={() => onPositionSelect(position.id)} + onClick={() => { + onPositionSelect(position.id); + // ポジション選択時に自動でシフト割当モードに切替 + if (toolMode !== "assign") { + onToolModeChange("assign"); + } + }} _hover={{ bg: isSelected ? position.color : `${position.color}20`, }} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/DragPreview.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/DragPreview.tsx index 1eadc5b6..3f8ec577 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/DragPreview.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/DragPreview.tsx @@ -47,16 +47,6 @@ export const DragPreview = ({ mode, startMinutes, currentMinutes, timeRange, pos borderRadius: "md", }; - case "erase": - return { - bg: "red.400", - opacity: 0.3, - height: "20px", - borderRadius: "md", - border: "2px dashed", - borderColor: "red.600", - }; - default: return {}; } diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx index 74816ce6..f1152e99 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx @@ -1,6 +1,7 @@ -import { Box, Table } from "@chakra-ui/react"; +import { Box, Flex, Icon, Table, Text } from "@chakra-ui/react"; import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuInfo, LuMousePointer2, LuPaintbrush } from "react-icons/lu"; import { SortMenu } from "../../../shared/SortMenu"; import { selectedDateAtom, @@ -33,7 +34,7 @@ const generateTimeSlots = (start: number, end: number) => { type ShiftGridProps = { onShiftClick: (shiftId: string, positionId: string | null, e: React.MouseEvent) => void; onStaffNameClick: (staffId: string) => void; - // paint/eraseクリック時のポップオーバー表示用 + // paintクリック時のポップオーバー表示用 onPaintClickPopover: (shift: ShiftData, anchorRect: DOMRect) => void; }; @@ -117,7 +118,7 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover [getCursor, isDragging, isScrolling], ); - // paint/eraseクリック時のポップオーバー表示 + // paintクリック時のポップオーバー表示 const handleMouseUpOnRow = useCallback( (_staffId: string) => { // Paint モードで移動なし(クリック)→ 既存ポジション上ならポップオーバー表示 @@ -139,17 +140,6 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover } } } - // Erase モードで移動なし(クリック)→ ポジション上ならポップオーバー表示 - if ( - dragState.mode === "erase" && - dragState.targetShiftId && - Math.abs(dragState.currentMinutes - dragState.startMinutes) < timeRange.unit - ) { - const targetShift = shifts.find((s) => s.id === dragState.targetShiftId); - if (targetShift && paintClickAnchorRef.current) { - onPaintClickPopover(targetShift, paintClickAnchorRef.current); - } - } }, [dragState, shifts, timeRange.unit, onPaintClickPopover], ); @@ -193,8 +183,71 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover }; }, [isDragging, isScrollDragging, handleMouseMove, handleMouseUp, stopScrollDrag, handleScrollDragMove]); + // 空状態判定: 選択日にポジション割当が1つもない + const hasAnyPositions = useMemo(() => { + return shifts.some((s) => s.date === selectedDate && s.positions.length > 0); + }, [shifts, selectedDate]); + return ( + {/* 空状態ガイド */} + {!isReadOnly && !hasAnyPositions && ( + + + + + + 1 + + + + ポジションを選択 + + + + + + 2 + + + + スタッフの行をドラッグして時間を割り当て + + + + + )} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts index e5fdc253..f7755e0a 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts @@ -5,8 +5,6 @@ import { selectedDateAtom, selectedPositionAtom, shiftConfigAtom, shiftsAtom, to import type { DragMode, LinkedResizeTarget, ShiftData } from "../../../types"; import { detectLinkedResizeEdge, - erasePosition, - findPositionAtPosition, findShiftAtPosition, paintPosition, resizeLinkedPositions, @@ -165,36 +163,6 @@ export const useDrag = (): UseDragReturn => { return true; } - // === 消すモード: リサイズ or ドラッグ消去 === - if (toolMode === "erase") { - // まずリサイズエッジを判定(select/assignと同様) - if (tryDetectResize(staffId, x, minutes)) return true; - - // リサイズエッジでなければドラッグ消去 - const positionInfo = findPositionAtPosition({ - shifts, - staffId, - date: selectedDate, - minutes, - }); - - if (positionInfo) { - setDragState({ - mode: "erase", - staffId, - startMinutes: minutes, - currentMinutes: minutes, - targetShiftId: positionInfo.shiftId, - targetPositionId: positionInfo.positionId, - positionColor: null, - resizeEdge: null, - linkedTarget: null, - }); - return true; - } - return false; - } - return false; }, [shifts, setShifts, selectedPosition, toolMode, selectedDate, timeRange, generateId, getStaffName, tryDetectResize], @@ -271,24 +239,8 @@ export const useDrag = (): UseDragReturn => { } } - // 3. 消去モード(ドラッグ範囲消去 -- クリック時は消去しない) - if (mode === "erase" && dragState.staffId && Math.abs(currentMinutes - startMinutes) >= timeRange.unit) { - const staffShifts = shifts.filter((s) => s.staffId === dragState.staffId && s.date === selectedDate); - let updatedShifts = [...shifts]; - - for (const staffShift of staffShifts) { - const erasedShift = erasePosition({ - shift: staffShift, - startMinutes, - endMinutes: currentMinutes, - }); - updatedShifts = updatedShifts.map((s) => (s.id === staffShift.id ? erasedShift : s)); - } - setShifts(updatedShifts); - } - setDragState(initialDragState); - }, [dragState, shifts, setShifts, selectedPosition, selectedDate, timeRange, generateId]); + }, [dragState, shifts, setShifts, selectedPosition, timeRange, generateId]); // === カーソル判定 === const getCursor = useCallback( @@ -298,7 +250,7 @@ export const useDrag = (): UseDragReturn => { if (dragState.mode === "position-resize-start" || dragState.mode === "position-resize-end") { return "ew-resize"; } - if (dragState.mode === "erase" || dragState.mode === "paint") { + if (dragState.mode === "paint") { return "crosshair"; } return "default"; @@ -339,32 +291,6 @@ export const useDrag = (): UseDragReturn => { return "default"; } - // 消すモード - if (toolMode === "erase") { - const linkedResizeInfo = detectLinkedResizeEdge({ - shifts, - staffId, - date: selectedDate, - x, - timeRange, - threshold: RESIZE_EDGE_THRESHOLD, - }); - if (linkedResizeInfo) { - return "ew-resize"; - } - const minutes = pixelToMinutes({ x, timeRange }); - const positionInfo = findPositionAtPosition({ - shifts, - staffId, - date: selectedDate, - minutes, - }); - if (positionInfo) { - return "crosshair"; - } - return "default"; - } - return "default"; }, [isDragging, dragState.mode, shifts, selectedDate, timeRange, selectedPosition, toolMode], diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx index 38c7577c..beaf1e86 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx @@ -102,7 +102,7 @@ export const DailyView = ({ undo, redo, canUndo, canRedo }: UndoRedoHandlers) => handlePopoverClose(); }, [popoverShift, shifts, setShifts, handlePopoverClose]); - // paint/eraseクリック時のポップオーバー表示 + // paintクリック時のポップオーバー表示 const handlePaintClickPopover = useCallback((shift: ShiftData, anchorRect: DOMRect) => { setPopoverShift(shift); setPopoverAnchor(anchorRect); diff --git a/src/components/features/Shift/ShiftForm/types.ts b/src/components/features/Shift/ShiftForm/types.ts index 3408d917..975fb165 100644 --- a/src/components/features/Shift/ShiftForm/types.ts +++ b/src/components/features/Shift/ShiftForm/types.ts @@ -55,10 +55,10 @@ export type TimeRange = { export type ViewMode = "daily" | "overview"; // ドラッグモード(希望シフトバーは編集不可のため、ポジション関連のみ) -export type DragMode = "position-resize-start" | "position-resize-end" | "paint" | "erase" | "scroll" | null; +export type DragMode = "position-resize-start" | "position-resize-end" | "paint" | "scroll" | null; // ツールモード(常にどれか1つが選択される) -export type ToolMode = "select" | "assign" | "erase"; +export type ToolMode = "select" | "assign"; // サマリー行の表示モード export type SummaryDisplayMode = "color" | "number"; From 6ca564c82c721da71ec4532944a0837965985777 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 00:57:46 +0900 Subject: [PATCH 008/176] =?UTF-8?q?chore:=20Claude=20Code=E6=A8=A9?= =?UTF-8?q?=E9=99=90=E8=A8=AD=E5=AE=9A=E3=81=AE=E6=9B=B4=E6=96=B0=E3=83=BB?= =?UTF-8?q?discuss=E3=82=B9=E3=82=AD=E3=83=AB=E3=81=AE=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 58 +++++++++++++++++++++++++++++++-- .claude/skills/discuss/SKILL.md | 4 ++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 8e68ae68..a36aa3a2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -25,8 +25,62 @@ }, "permissions": { "allow": [ - "Bash(pnpm *)", - "WebSearch" + "Bash(git:*)", + "Bash(pnpm:*)", + "Bash(npx:*)", + "Bash(node:*)", + "Bash(npm:*)", + "Bash(tsc:*)", + "Bash(eslint:*)", + "Bash(vitest:*)", + "Bash(vite:*)", + "Bash(tsx:*)", + "Bash(claude:*)", + "Bash(cat:*)", + "Bash(ls:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(sort:*)", + "Bash(uniq:*)", + "Bash(mkdir:*)", + "Bash(cp:*)", + "Bash(mv:*)", + "Bash(touch:*)", + "Bash(echo:*)", + "Bash(pwd)", + "Bash(which:*)", + "Bash(dirname:*)", + "Bash(basename:*)", + "Bash(realpath:*)", + "Bash(diff:*)", + "Bash(sed:*)", + "Bash(awk:*)", + "Bash(xargs:*)", + "Bash(curl:*)", + "Bash(jq:*)", + "Bash(gh:*)", + "Bash(sqlite3:*)", + "Bash(kill:*)", + "Bash(lsof:*)", + "Bash(ps:*)", + "Bash(env:*)", + "Bash(printenv:*)", + "Bash(true)", + "Bash(false)", + "Bash(test:*)", + "Read", + "Edit", + "Write", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "TodoWrite", + "Agent" ], "deny": [], "ask": [] diff --git a/.claude/skills/discuss/SKILL.md b/.claude/skills/discuss/SKILL.md index 1570d851..aec52cf1 100644 --- a/.claude/skills/discuss/SKILL.md +++ b/.claude/skills/discuss/SKILL.md @@ -59,7 +59,9 @@ Slackのチャットっぽく、カジュアルに進行する。各発言は短 - ユーザーにも意見を求める(「〇〇さんはどう思う?」) - 適度にユーザーの反応を待つ。一気に長い議論を流さない - 論点が出揃ったら、合意形成に向けてまとめ役(テックリード的なペルソナ)が整理する -- 必要に応じてコードを読んで具体的な提案をする +- **議論の途中でも気になったらすぐソースコードを読みに行くこと**。「ちょっと実装見てくるわ」と宣言してからコードを確認し、結果を議論に持ち帰る。推測で話を進めず、事実ベースで議論する +- **多角的な視点を意識する**: 現在の実装・設計を複数の観点(パフォーマンス、保守性、ユーザー体験、チーム開発のしやすさ等)から検討する +- **批判的な視点も大事にする**: 「本当にそれでいい?」「見落としてない?」という問いかけを恐れない。既存の実装や提案に対しても、問題点やリスクがあれば率直に指摘する。全員が賛成するだけの議論は議論じゃない #### ユーザーへの質問のタイミング From 9cacbbdb00fbb5d68edb2215b630a7715963684a Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 00:57:50 +0900 Subject: [PATCH 009/176] =?UTF-8?q?docs:=20ShiftForm=E6=83=85=E5=A0=B1?= =?UTF-8?q?=E8=A8=AD=E8=A8=88=E3=83=97=E3=83=A9=E3=83=B3=E3=81=AE=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../20260324-shiftform-information-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 doc/plans/20260324-shiftform-information-design.md diff --git a/doc/plans/20260324-shiftform-information-design.md b/doc/plans/20260324-shiftform-information-design.md new file mode 100644 index 00000000..b0b520cc --- /dev/null +++ b/doc/plans/20260324-shiftform-information-design.md @@ -0,0 +1,204 @@ +# ShiftForm 情報設計改善プラン + +## 背景・課題 + +ShiftFormのPC DailyViewは機能は揃っているが、初見ユーザーにとって操作が直感的でなく、情報量が多すぎてUXが悪い。ITリテラシーが低い店長層(Excel程度)でも迷わず使えるレベルを目指す。 + +主な課題: +- ツールモード(select/assign)の切り替えが意味不明 +- undo/redoなどパワーユーザー向け機能がフラットに並んでいる +- 充足度が30分刻み×全時間帯の色グラデーションで読み解けない +- 充足度の入力(曜日×30分刻み=182マス)が重すぎて使われない +- 休憩のUI表現が曖昧(空白=休憩?割り当て忘れ?) +- Overview↔Dailyの役割分担が不明確 + +## 方針 + +Progressive Disclosure(段階的開示)の原則に基づき、初見でも迷わない最小限のUIをデフォルトにする。将来のAI自動割当(直近1-2ヶ月の実績ベース)を見据えつつ、今回は手動割当のUX改善に集中する。 + +## 設計決定事項 + +### 1. PositionToolbar の簡素化 + +**Before:** undo/redo + ツールモード切替(select/assign)+ ポジションボタン群 +**After:** ポジション選択ボタンのみ + +- **select モード廃止**: スクロールはマウスホイール/スクロールバーで対応。将来D&D移動が必要になったら改めて設計 +- **undo/redo 廃止**: 間違えたら上書きペイント or Popoverから削除で対応。塗り直しのほうが直感的 +- **休憩ボタンは区切り線で分離**: ポジション(仕事)と休憩は概念が違うことをUIで表現 + +``` +Before: [← →] [Select | Assign] [ホール] [レジ] [キッチン] [休憩] +After: [ホール] [レジ] [キッチン] | [☕休憩] +``` + +### 2. 休憩の自動挿入 + +- ポジション間に空き時間がある場合、**自動で休憩バーを挿入** +- 対象: 最初のポジションのstartから最後のポジションのendの間の空きのみ(出勤前・退勤後は対象外) +- 見た目: ストライプやハッチング等で他ポジションと差別化 +- 手動でリサイズ・上書きも可能(休憩時間を変えたいケース) +- ツールバーの休憩ボタンも残す(空きなしで直接塗りたいケース用) + +``` +[■ホール 10:00-13:00■][░休憩 13:00-14:00░][■レジ 14:00-18:00■] + ↑ 自動挿入、ストライプで差別化 +``` + +**メリット:** +- 空白 = 常に「割り当て忘れ」を意味するようになる(曖昧さ解消) +- 勤務枠のデータモデル変更なしで、暗黙的に勤務時間が決まる + +### 3. 充足度の入力簡素化 + +**Before:** 曜日×30分刻みで全時間帯の必要人数(182マス) +**After:** ピーク帯定義 + 最低人員 + +``` +ピーク帯①: ランチ 11:00-14:00 → 5人必要 +ピーク帯②: ディナー 17:00-21:00 → 8人必要 +最低人員: 常に最低3人 +``` + +- ピーク帯の数・時間帯は店舗ごとにカスタム可能 +- 入力項目: 5-10項目(182マス → 大幅削減) + +### 4. 充足度の表示改善 + +**Before:** 30分刻み×全時間帯の6色グラデーション(SummaryRow) +**After:** ピーク帯ベースのアラート表示 + +**DailyView(グリッド上部):** +``` +⚠️ ランチ帯 あと1人 / ディナー帯 ✅ +``` +- 不足時: ⚠️ + 「あと○人」(目立たせる) +- 充足時: ✅のみ(控えめ) +- リアルタイム更新(割当操作のたびに再計算) + +**OverviewView(日付列):** +- 日単位の⚠️/✅バッジ → クリックでDailyViewへ遷移 + +### 5. DateTabs の改善 + +タブ形式は維持。状態バッジと曜日・休日表示を追加。 + +``` +[3/1(土)] [3/2(日)] [3/3(月)⚠️] [3/4(火)✅] [3/5(水)] ... + 青文字 赤文字 黒文字 黒文字 黒文字 +``` + +| タブ状態 | 条件 | 見た目 | +|---------|------|--------| +| 未着手 | 誰にも割当なし | バッジなし | +| ⚠️不足 | ピーク帯の必要人数 or 最低人員を満たしてない | ⚠️バッジ | +| ✅OK | 全ピーク帯 + 最低人員クリア | ✅バッジ | + +- 土曜: 青文字、日曜・祝日: 赤文字、平日: 黒文字 + +### 6. Overview↔Daily の役割明確化 + +| View | 役割 | 主な操作 | +|------|------|---------| +| DailyView | 作業場(割当を「する」画面) | ドラッグでポジション割当・修正 | +| OverviewView | チェック場(全体を「見る」画面) | 充足状況確認、問題日の発見→DailyViewへ遷移 | + +- OverviewViewの日付⚠️バッジをクリック → その日のDailyViewに遷移 +- OverviewViewは閲覧専用のまま維持 + +### 7. ShiftPopover(維持) + +- 左クリックでシフトバーをクリック → Popover表示 +- 内容: 申請状況 + ポジション一覧(個別削除)+ 全ポジション削除 +- 変更なし + +### 8. スタッフ名クリック + +**Before:** StaffEditModal(スタッフ情報の編集可能) +**After:** 参照情報のみ表示(スキル、希望、契約情報など)。編集は不可 + +### 9. 将来のAI自動割当への備え + +- 直近1-2ヶ月の実績データをベースにAIがシフト案を生成 +- OverviewView = AI案の全体プレビュー確認 +- DailyView = AI案の微調整 +- 今回の情報設計はこのフローと矛盾しない(0から手動で組む場合も、AI案を微調整する場合も同じUI) + +## 実装ステップ + +### Step 1: PositionToolbar 簡素化 +- selectモード関連コード削除(toolModeAtom, useScrollDrag等) +- undo/redo関連コード削除(useUndoRedo, shiftsHistoryAtom等) +- ツールバーUI: ポジションボタンのみ + 休憩を区切り線で分離 +- 対象ファイル: + - `src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx` + - `src/components/features/Shift/ShiftForm/stores.ts` + - `src/components/features/Shift/ShiftForm/hooks/useUndoRedo.ts`(削除候補) + - `src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts` + - `src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useScrollDrag.ts`(削除候補) + +### Step 2: 休憩の自動挿入 +- normalizePositions or 新関数で、ポジション間の空きに休憩バーを自動挿入するロジック追加 +- 休憩バーの見た目差別化(ストライプ/ハッチング) +- 対象ファイル: + - `src/components/features/Shift/ShiftForm/utils/shiftOperations.ts` + - `src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx` + +### Step 3: 充足度の入力簡素化 +- ピーク帯定義 + 最低人員のデータモデル設計 +- 店舗設定画面にピーク帯設定UIを追加 +- 対象ファイル: + - `convex/schema.ts`(requiredStaffingテーブル変更) + - `convex/requiredStaffing/`(queries/mutations更新) + - 店舗設定画面の該当コンポーネント + +### Step 4: 充足度の表示改善 +- DailyView上部にピーク帯アラート表示コンポーネント追加 +- OverviewViewの日付セルに⚠️/✅バッジ追加 +- 既存SummaryRowの置き換え or 簡素化 +- 対象ファイル: + - `src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx` + - `src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx` + - `src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx` + - `src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx` + - `src/components/features/Shift/ShiftForm/utils/calculations.ts` + +### Step 5: DateTabs 改善 +- タブに充足状態バッジ(⚠️/✅)追加 +- 曜日表示 + 休日フォント色(土:青、日祝:赤) +- 対象ファイル: + - `src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx` + - `src/components/features/Shift/ShiftForm/utils/dateUtils.ts` + +### Step 6: Overview→Daily 遷移強化 +- OverviewViewの日付クリックでDailyViewの該当日に遷移 +- 対象ファイル: + - `src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx` + - `src/components/features/Shift/ShiftForm/pc/OverviewView/StaffRow.tsx` + - `src/components/features/Shift/ShiftForm/stores.ts` + +### Step 7: スタッフ名クリック → 参照のみ +- StaffEditModalを参照専用に変更(編集ボタン・保存機能を除外) +- 対象ファイル: + - `src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx` + - `src/components/features/Staff/StaffEditModal/index.tsx`(or 新しい参照専用コンポーネント) + +## 参考ファイル + +- `src/components/features/Shift/ShiftForm/types.ts` — 全ドメイン型定義 +- `src/components/features/Shift/ShiftForm/stores.ts` — Jotai atoms(状態管理の中心) +- `src/components/features/Shift/ShiftForm/constants.ts` — 時間軸定数、色定義 +- `src/components/features/Shift/ShiftForm/utils/shiftOperations.ts` — シフト操作ロジック(paint, resize, normalize等) +- `src/components/features/Shift/ShiftForm/utils/calculations.ts` — 充足度計算ロジック +- `src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx` — DailyViewメインコンポーネント +- `src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx` — グリッド本体 +- `src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx` — OverviewViewメインコンポーネント +- `convex/schema.ts` — DBスキーマ(requiredStaffing等) + +## 議論で出た懸念点・注意事項 + +- **将来のAI自動割当**: 直近1-2ヶ月の実績ベースでAIがシフト案を生成する構想あり。今回の設計はこれと矛盾しない(DailyView=微調整、OverviewView=全体確認のフローが共通) +- **ピーク帯設定のカスタマイズ性**: 業態によってピーク帯の数・時間帯が異なる(カフェ: モーニング/ランチ/ティータイム等)。店舗ごとにカスタム可能にする +- **selectモード廃止後のD&D**: 将来「シフトを掴んで別の時間帯に移動」が必要になった場合、その時点で改めてUI設計する(YAGNIの原則) +- **undo/redo廃止のリスク**: 大量の割当を間違えて消した場合の救済手段がなくなる。ただし、Convexへの保存は明示的操作なので、保存前ならリロードで復元可能 +- **休憩自動挿入のエッジケース**: 30分の空き(=timeRange.unit)でも休憩として自動挿入する。意図しない挿入が起きないか要テスト From 3b41eb5f1050f6b20803089b269ae323dabde2a7 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 09:30:30 +0900 Subject: [PATCH 010/176] =?UTF-8?q?refactor:=20undo/redo=E3=83=BBtoolMode?= =?UTF-8?q?=E3=83=BBscrollDrag=E3=81=AE=E5=BB=83=E6=AD=A2=E3=81=A8Position?= =?UTF-8?q?Toolbar=E7=B0=A1=E7=B4=A0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 情報設計改善プランStep1: selectモード・undo/redo・スクロールドラッグを削除し、 ポジションボタンのみのシンプルなツールバーに変更。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/Shift/ShiftForm/constants.ts | 3 - .../Shift/ShiftForm/hooks/useShiftFormInit.ts | 14 +- .../Shift/ShiftForm/hooks/useUndoRedo.ts | 32 ----- .../features/Shift/ShiftForm/index.tsx | 21 +-- .../pc/DailyView/PositionToolbar.tsx | 96 +------------ .../pc/DailyView/ShiftGrid/StaffRow.tsx | 4 +- .../pc/DailyView/ShiftGrid/index.tsx | 52 +++---- .../ShiftForm/pc/DailyView/hooks/useDrag.ts | 130 +++++++----------- .../pc/DailyView/hooks/useScrollDrag.ts | 34 ----- .../features/Shift/ShiftForm/stores.ts | 65 +-------- .../features/Shift/ShiftForm/types.ts | 18 ++- 11 files changed, 102 insertions(+), 367 deletions(-) delete mode 100644 src/components/features/Shift/ShiftForm/hooks/useUndoRedo.ts delete mode 100644 src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useScrollDrag.ts diff --git a/src/components/features/Shift/ShiftForm/constants.ts b/src/components/features/Shift/ShiftForm/constants.ts index e39f43d5..418e7958 100644 --- a/src/components/features/Shift/ShiftForm/constants.ts +++ b/src/components/features/Shift/ShiftForm/constants.ts @@ -33,9 +33,6 @@ export const ROW_HEIGHT = 48; // ビジネスルール // ========================================== -// Undo/Redo 履歴上限 -export const UNDO_REDO_HISTORY_LIMIT = 50; - // アラート閾値 export const WEEK_HOURS_LIMIT = 40 * 60; // 週40時間(分) export const CONSECUTIVE_DAYS_LIMIT = 6; // 連勤アラート閾値(日) diff --git a/src/components/features/Shift/ShiftForm/hooks/useShiftFormInit.ts b/src/components/features/Shift/ShiftForm/hooks/useShiftFormInit.ts index 57c8da55..eeb6cec9 100644 --- a/src/components/features/Shift/ShiftForm/hooks/useShiftFormInit.ts +++ b/src/components/features/Shift/ShiftForm/hooks/useShiftFormInit.ts @@ -1,6 +1,6 @@ import { useSetAtom } from "jotai"; import { useEffect, useRef } from "react"; -import { selectedDateAtom, shiftConfigAtom, shiftsHistoryAtom, sortModeAtom, viewModeAtom } from "../stores"; +import { selectedDateAtom, shiftConfigAtom, shiftsAtom, sortModeAtom, viewModeAtom } from "../stores"; import type { PositionType, RequiredStaffingData, ShiftData, SortMode, StaffType, TimeRange, ViewMode } from "../types"; type UseShiftFormInitParams = { @@ -35,22 +35,18 @@ export const useShiftFormInit = ({ initialSortMode, }: UseShiftFormInitParams) => { const setConfig = useSetAtom(shiftConfigAtom); - const setHistory = useSetAtom(shiftsHistoryAtom); + const setShifts = useSetAtom(shiftsAtom); const setSelectedDate = useSetAtom(selectedDateAtom); const setViewMode = useSetAtom(viewModeAtom); const setSortMode = useSetAtom(sortModeAtom); const isInitialized = useRef(false); - // 初回マウント時: 履歴を初期化 + 選択日を設定 + // 初回マウント時: シフトデータ初期化 + 選択日を設定 useEffect(() => { if (isInitialized.current) return; isInitialized.current = true; - setHistory({ - past: [], - present: initialShifts, - future: [], - }); + setShifts(initialShifts); setSelectedDate(dates[0] ?? ""); if (initialViewMode) { setViewMode(initialViewMode); @@ -58,7 +54,7 @@ export const useShiftFormInit = ({ if (initialSortMode) { setSortMode(initialSortMode); } - }, [initialShifts, dates, setHistory, setSelectedDate, initialViewMode, setViewMode, initialSortMode, setSortMode]); + }, [initialShifts, dates, setShifts, setSelectedDate, initialViewMode, setViewMode, initialSortMode, setSortMode]); // 外部設定の同期(props変更時に shiftConfigAtom を更新) useEffect(() => { diff --git a/src/components/features/Shift/ShiftForm/hooks/useUndoRedo.ts b/src/components/features/Shift/ShiftForm/hooks/useUndoRedo.ts deleted file mode 100644 index db3fb0ef..00000000 --- a/src/components/features/Shift/ShiftForm/hooks/useUndoRedo.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import { canRedoAtom, canUndoAtom, redoAtom, shiftsAtom, undoAtom } from "../stores"; -import type { ShiftData } from "../types"; - -type UseUndoRedoReturn = { - shifts: ShiftData[]; - setShifts: (newShifts: ShiftData[]) => void; - undo: () => void; - redo: () => void; - canUndo: boolean; - canRedo: boolean; -}; - -// atom ベースの undo/redo ラッパー -// 既存の useUndoRedo (useState ベース) と同じインターフェースを提供 -export const useUndoRedo = (): UseUndoRedoReturn => { - const shifts = useAtomValue(shiftsAtom); - const setShifts = useSetAtom(shiftsAtom); - const undo = useSetAtom(undoAtom); - const redo = useSetAtom(redoAtom); - const canUndo = useAtomValue(canUndoAtom); - const canRedo = useAtomValue(canRedoAtom); - - return { - shifts, - setShifts, - undo, - redo, - canUndo, - canRedo, - }; -}; diff --git a/src/components/features/Shift/ShiftForm/index.tsx b/src/components/features/Shift/ShiftForm/index.tsx index 4b8e6367..2fe69def 100644 --- a/src/components/features/Shift/ShiftForm/index.tsx +++ b/src/components/features/Shift/ShiftForm/index.tsx @@ -1,9 +1,7 @@ -import { Box, Flex, HStack, IconButton, SegmentGroup } from "@chakra-ui/react"; +import { Box, Flex, SegmentGroup } from "@chakra-ui/react"; 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"; import { DailyView } from "./pc/DailyView"; import { OverviewView } from "./pc/OverviewView"; import { SPDailyView } from "./sp/DailyView"; @@ -83,24 +81,13 @@ const ShiftFormInner = ({ }, [shifts]); const [viewMode, setViewMode] = useAtom(viewModeAtom); - const { undo, redo, canUndo, canRedo } = useUndoRedo(); return ( - {/* SP ヘッダー: Undo/Redo + SegmentGroup */} + {/* SP ヘッダー: SegmentGroup */} {!hideViewSwitcher && ( - - {!isReadOnly && ( - - - - - - - - - )} + setViewMode(e.value as ViewMode)}> @@ -123,7 +110,7 @@ const ShiftFormInner = ({ {/* PC */} - + {/* SP */} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx index 11f8105c..be3dbfbb 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx @@ -1,98 +1,16 @@ -import { Box, Button, Flex, Icon, IconButton, Text } from "@chakra-ui/react"; -import { LuMousePointer2, LuPaintbrush, LuRedo2, LuUndo2 } from "react-icons/lu"; -import type { PositionType, ToolMode } from "../../types"; - -type ToolButtonProps = { - icon: React.ElementType; - label: string; - isActive: boolean; - onClick: () => void; - activeColorPalette?: string; -}; - -const ToolButton = ({ icon, label, isActive, onClick, activeColorPalette }: ToolButtonProps) => ( - -); +import { Box, Button, Flex, Text } from "@chakra-ui/react"; +import type { PositionType } from "../../types"; type PositionToolbarProps = { - toolMode: ToolMode; - onToolModeChange: (mode: ToolMode) => void; positions: PositionType[]; selectedPositionId: string | null; onPositionSelect: (positionId: string) => void; - onUndo: () => void; - onRedo: () => void; - canUndo: boolean; - canRedo: boolean; }; -export const PositionToolbar = ({ - toolMode, - onToolModeChange, - positions, - selectedPositionId, - onPositionSelect, - onUndo, - onRedo, - canUndo, - canRedo, -}: PositionToolbarProps) => { +export const PositionToolbar = ({ positions, selectedPositionId, onPositionSelect }: PositionToolbarProps) => { return ( - {/* 履歴・ツール・ポジション */} - {/* グループ1: 履歴 */} - - - 履歴 - - - - - - - - - - - - {/* セパレータ */} - - - {/* グループ2: ツール */} - - - ツール - - - onToolModeChange("select")} - /> - onToolModeChange("assign")} - /> - - - - {/* セパレータ */} - - - {/* グループ3: ポジション */} ポジション @@ -108,13 +26,7 @@ export const PositionToolbar = ({ bg={isSelected ? position.color : "transparent"} borderColor={position.color} color={isSelected ? "white" : "gray.700"} - onClick={() => { - onPositionSelect(position.id); - // ポジション選択時に自動でシフト割当モードに切替 - if (toolMode !== "assign") { - onToolModeChange("assign"); - } - }} + onClick={() => onPositionSelect(position.id)} _hover={{ bg: isSelected ? position.color : `${position.color}20`, }} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx index 0594acaf..8bf96239 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx @@ -28,7 +28,6 @@ type StaffRowProps = { onStaffNameClick: (staffId: string) => void; dragState: DragState; isDragging: boolean; - isScrollDragging: boolean; cursorStyle: string; rowRef: (el: HTMLDivElement | null) => void; paintClickAnchorRef: React.MutableRefObject; @@ -49,7 +48,6 @@ export const StaffRow = ({ onStaffNameClick, dragState, isDragging, - isScrollDragging, cursorStyle, rowRef, paintClickAnchorRef, @@ -119,7 +117,7 @@ export const StaffRow = ({ onMouseLeave={() => { // カーソルスタイルのリセットは親で処理 }} - cursor={isScrollDragging ? "grabbing" : cursorStyle} + cursor={cursorStyle} userSelect="none" > {/* グリッドライン(最背面) */} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx index f1152e99..c9185e91 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx @@ -11,13 +11,12 @@ import { sortModeAtom, summaryDisplayModeAtom, summaryExpandedAtom, - toolModeAtom, } from "../../../stores"; import type { ShiftData, StaffType } from "../../../types"; import { getTimeAxisWidth } from "../../../utils/timeConversion"; import { useAutoScroll } from "../hooks/useAutoScroll"; import { useDrag } from "../hooks/useDrag"; -import { useScrollDrag } from "../hooks/useScrollDrag"; +import { PeakBandAlert } from "../PeakBandAlert"; import { SummaryRow } from "../SummaryRow"; import { TimeHeader } from "../TimeHeader"; import { StaffRow } from "./StaffRow"; @@ -46,15 +45,11 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover const [sortMode, setSortMode] = [useAtomValue(sortModeAtom), useAtom(sortModeAtom)[1]]; const [isSummaryExpanded, setIsSummaryExpanded] = useAtom(summaryExpandedAtom); const [summaryDisplayMode, setSummaryDisplayMode] = useAtom(summaryDisplayModeAtom); - const toolMode = useAtomValue(toolModeAtom); - const { timeRange, positions, isReadOnly, currentStaffId } = config; + const { timeRange, positions, isReadOnly, currentStaffId, requiredStaffing } = config; // === ドラッグ管理 === const { dragState, isDragging, handleMouseDown, handleMouseMove, handleMouseUp, getCursor } = useDrag(); - // === スクロールドラッグ === - const { isScrollDragging, startScrollDrag, handleScrollDragMove, stopScrollDrag, isScrolling } = useScrollDrag(); - // === ref管理 === const tableContainerRef = useRef(null); const rowContainerRefs = useRef>({}); @@ -95,27 +90,21 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover if (dragStarted) { dragRowRectRef.current = rect; } - - // 選択モードでドラッグ未開始 → 横スクロール開始 - if (toolMode === "select" && !dragStarted && tableContainerRef.current) { - startScrollDrag(e, tableContainerRef.current); - dragRowRectRef.current = rect; - } }, - [handleMouseDown, toolMode, startScrollDrag], + [handleMouseDown], ); // カーソル更新 const handleRowMouseMoveForCursor = useCallback( (e: React.MouseEvent, staffId: string) => { - if (!isDragging && !isScrolling()) { + if (!isDragging) { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const cursor = getCursor(staffId, x); setCursorStyles((prev) => ({ ...prev, [staffId]: cursor })); } }, - [getCursor, isDragging, isScrolling], + [getCursor, isDragging], ); // paintクリック時のポップオーバー表示 @@ -149,13 +138,7 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover const handleDocumentMouseMove = (e: MouseEvent) => { mouseClientXRef.current = e.clientX; - // 横スクロール中 - if (isScrollDragging && tableContainerRef.current) { - handleScrollDragMove(e, tableContainerRef.current); - return; - } - - // ドラッグ中(ペイント/消去/リサイズ) + // ドラッグ中(ペイント/リサイズ) if (isDragging && dragRowRectRef.current) { handleMouseMove(e as unknown as React.MouseEvent, dragRowRectRef.current); } @@ -166,13 +149,9 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover handleMouseUp(); dragRowRectRef.current = null; } - if (isScrollDragging) { - stopScrollDrag(); - dragRowRectRef.current = null; - } }; - if (isDragging || isScrollDragging) { + if (isDragging) { document.addEventListener("mousemove", handleDocumentMouseMove); document.addEventListener("mouseup", handleDocumentMouseUp); } @@ -181,13 +160,20 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover document.removeEventListener("mousemove", handleDocumentMouseMove); document.removeEventListener("mouseup", handleDocumentMouseUp); }; - }, [isDragging, isScrollDragging, handleMouseMove, handleMouseUp, stopScrollDrag, handleScrollDragMove]); + }, [isDragging, handleMouseMove, handleMouseUp]); // 空状態判定: 選択日にポジション割当が1つもない const hasAnyPositions = useMemo(() => { return shifts.some((s) => s.date === selectedDate && s.positions.length > 0); }, [shifts, selectedDate]); + // 選択日の曜日に対応するピーク帯設定を取得 + const currentDayStaffing = useMemo(() => { + if (!requiredStaffing || !selectedDate) return undefined; + const dayOfWeek = new Date(selectedDate).getDay(); + return requiredStaffing.find((rs) => rs.dayOfWeek === dayOfWeek); + }, [requiredStaffing, selectedDate]); + return ( {/* 空状態ガイド */} @@ -248,6 +234,13 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover )} + {/* ピーク帯充足度アラート */} + @@ -278,7 +271,6 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover onStaffNameClick={onStaffNameClick} dragState={dragState} isDragging={isDragging} - isScrollDragging={isScrollDragging} cursorStyle={cursorStyles[staff.id] ?? "default"} rowRef={(el: HTMLDivElement | null) => { rowContainerRefs.current[staff.id] = el; diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts index f7755e0a..c54072f8 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts @@ -1,7 +1,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useRef, useState } from "react"; import { RESIZE_EDGE_THRESHOLD } from "../../../constants"; -import { selectedDateAtom, selectedPositionAtom, shiftConfigAtom, shiftsAtom, toolModeAtom } from "../../../stores"; +import { selectedDateAtom, selectedPositionAtom, shiftConfigAtom, shiftsAtom } from "../../../stores"; import type { DragMode, LinkedResizeTarget, ShiftData } from "../../../types"; import { detectLinkedResizeEdge, @@ -49,7 +49,6 @@ export const useDrag = (): UseDragReturn => { const shifts = useAtomValue(shiftsAtom); const setShifts = useSetAtom(shiftsAtom); const selectedPosition = useAtomValue(selectedPositionAtom); - const toolMode = useAtomValue(toolModeAtom); const selectedDate = useAtomValue(selectedDateAtom); const config = useAtomValue(shiftConfigAtom); const timeRange = config.timeRange; @@ -112,60 +111,48 @@ export const useDrag = (): UseDragReturn => { const x = e.clientX - containerRect.left; const minutes = pixelToMinutes({ x, timeRange }); - // === 選択モード: リサイズのみ === - if (toolMode === "select") { - if (tryDetectResize(staffId, x, minutes)) return true; - // リサイズ端でなければドラッグなし(スクロールはindex.tsx側で処理) - return false; - } + // まずリサイズエッジを判定(既存バーの端をドラッグした場合) + if (tryDetectResize(staffId, x, minutes)) return true; - // === 割当モード: リサイズ or 塗り === - if (toolMode === "assign") { - // まずリサイズエッジを判定(既存バーの端をドラッグした場合) - if (tryDetectResize(staffId, x, minutes)) return true; + // リサイズエッジでなければ塗りモード + if (!selectedPosition) return false; - // リサイズエッジでなければ塗りモード - if (!selectedPosition) return false; + let targetShift = findShiftAtPosition({ + shifts, + staffId, + date: selectedDate, + minutes, + }); - let targetShift = findShiftAtPosition({ - shifts, + // シフトがなければ新規作成(未提出者対応) + if (!targetShift) { + const newShiftId = generateId(); + const newShift: ShiftData = { + id: newShiftId, staffId, + staffName: getStaffName(staffId), date: selectedDate, - minutes, - }); - - // シフトがなければ新規作成(未提出者対応) - if (!targetShift) { - const newShiftId = generateId(); - const newShift: ShiftData = { - id: newShiftId, - staffId, - staffName: getStaffName(staffId), - date: selectedDate, - requestedTime: null, - positions: [], - }; - setShifts([...shifts, newShift]); - targetShift = newShift; - } - - setDragState({ - mode: "paint", - staffId, - startMinutes: minutes, - currentMinutes: minutes, - targetShiftId: targetShift.id, - targetPositionId: null, - positionColor: selectedPosition.color, - resizeEdge: null, - linkedTarget: null, - }); - return true; + requestedTime: null, + positions: [], + }; + setShifts([...shifts, newShift]); + targetShift = newShift; } - return false; + setDragState({ + mode: "paint", + staffId, + startMinutes: minutes, + currentMinutes: minutes, + targetShiftId: targetShift.id, + targetPositionId: null, + positionColor: selectedPosition.color, + resizeEdge: null, + linkedTarget: null, + }); + return true; }, - [shifts, setShifts, selectedPosition, toolMode, selectedDate, timeRange, generateId, getStaffName, tryDetectResize], + [shifts, setShifts, selectedPosition, selectedDate, timeRange, generateId, getStaffName, tryDetectResize], ); // === ドラッグ中 === @@ -256,44 +243,27 @@ export const useDrag = (): UseDragReturn => { return "default"; } - // 選択モード - if (toolMode === "select") { - const linkedResizeInfo = detectLinkedResizeEdge({ - shifts, - staffId, - date: selectedDate, - x, - timeRange, - threshold: RESIZE_EDGE_THRESHOLD, - }); - if (linkedResizeInfo) { - return "ew-resize"; - } - return "grab"; + // リサイズ端の検出 + const linkedResizeInfo = detectLinkedResizeEdge({ + shifts, + staffId, + date: selectedDate, + x, + timeRange, + threshold: RESIZE_EDGE_THRESHOLD, + }); + if (linkedResizeInfo) { + return "ew-resize"; } - // 割当モード - if (toolMode === "assign") { - const linkedResizeInfo = detectLinkedResizeEdge({ - shifts, - staffId, - date: selectedDate, - x, - timeRange, - threshold: RESIZE_EDGE_THRESHOLD, - }); - if (linkedResizeInfo) { - return "ew-resize"; - } - if (selectedPosition) { - return "crosshair"; - } - return "default"; + // ポジション選択中 + if (selectedPosition) { + return "crosshair"; } return "default"; }, - [isDragging, dragState.mode, shifts, selectedDate, timeRange, selectedPosition, toolMode], + [isDragging, dragState.mode, shifts, selectedDate, timeRange, selectedPosition], ); return { diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useScrollDrag.ts b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useScrollDrag.ts deleted file mode 100644 index 3a03cc90..00000000 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useScrollDrag.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useCallback, useRef, useState } from "react"; - -export const useScrollDrag = () => { - const scrollDragRef = useRef({ isScrolling: false, startX: 0, startScrollLeft: 0 }); - const [isScrollDragging, setIsScrollDragging] = useState(false); - - const startScrollDrag = useCallback((e: React.MouseEvent, tableContainer: HTMLDivElement) => { - scrollDragRef.current = { - isScrolling: true, - startX: e.clientX, - startScrollLeft: tableContainer.scrollLeft, - }; - setIsScrollDragging(true); - }, []); - - const handleScrollDragMove = useCallback((e: MouseEvent, tableContainer: HTMLDivElement) => { - if (!scrollDragRef.current.isScrolling) return; - const dx = e.clientX - scrollDragRef.current.startX; - tableContainer.scrollLeft = scrollDragRef.current.startScrollLeft - dx; - }, []); - - const stopScrollDrag = useCallback(() => { - scrollDragRef.current.isScrolling = false; - setIsScrollDragging(false); - }, []); - - return { - isScrollDragging, - startScrollDrag, - handleScrollDragMove, - stopScrollDrag, - isScrolling: () => scrollDragRef.current.isScrolling, - }; -}; diff --git a/src/components/features/Shift/ShiftForm/stores.ts b/src/components/features/Shift/ShiftForm/stores.ts index c8eeaf32..5cb2fe5e 100644 --- a/src/components/features/Shift/ShiftForm/stores.ts +++ b/src/components/features/Shift/ShiftForm/stores.ts @@ -1,5 +1,4 @@ import { atom } from "jotai"; -import { UNDO_REDO_HISTORY_LIMIT } from "./constants"; import type { PositionType, RequiredStaffingData, @@ -8,10 +7,8 @@ import type { StaffType, SummaryDisplayMode, TimeRange, - ToolMode, ViewMode, } from "./types"; -import { normalizePositions } from "./utils/shiftOperations"; import { sortStaffs } from "./utils/sortStaffs"; // ========================================== @@ -46,67 +43,13 @@ export const selectedDateAtom = atom(""); export const sortModeAtom = atom("default"); // ========================================== -// シフトデータ + Undo/Redo 履歴 +// シフトデータ // ========================================== -export const shiftsHistoryAtom = atom<{ - past: ShiftData[][]; - present: ShiftData[]; - future: ShiftData[][]; -}>({ past: [], present: [], future: [] }); - -// 読み書きatom: 書き込み時に自動正規化 + 履歴追加 -export const shiftsAtom = atom( - (get) => get(shiftsHistoryAtom).present, - (get, set, newShifts: ShiftData[]) => { - const breakPos = get(breakPositionAtom); - const normalized = breakPos - ? newShifts.map((s) => ({ - ...s, - positions: normalizePositions({ - positions: s.positions, - breakPosition: breakPos, - }), - })) - : newShifts; - const history = get(shiftsHistoryAtom); - set(shiftsHistoryAtom, { - past: [...history.past.slice(-(UNDO_REDO_HISTORY_LIMIT - 1)), history.present], - present: normalized, - future: [], - }); - }, -); - -// Undo/Redo 状態 -export const canUndoAtom = atom((get) => get(shiftsHistoryAtom).past.length > 0); -export const canRedoAtom = atom((get) => get(shiftsHistoryAtom).future.length > 0); - -// Undo アクション (write-only atom) -export const undoAtom = atom(null, (get, set) => { - const history = get(shiftsHistoryAtom); - if (history.past.length === 0) return; - set(shiftsHistoryAtom, { - past: history.past.slice(0, -1), - present: history.past[history.past.length - 1], - future: [history.present, ...history.future], - }); -}); - -// Redo アクション (write-only atom) -export const redoAtom = atom(null, (get, set) => { - const history = get(shiftsHistoryAtom); - if (history.future.length === 0) return; - set(shiftsHistoryAtom, { - past: [...history.past, history.present], - present: history.future[0], - future: history.future.slice(1), - }); -}); +export const shiftsAtom = atom([]); // ========================================== // PC日別ビュー専用 // ========================================== -export const toolModeAtom = atom("select"); export const selectedPositionIdAtom = atom(null); export const summaryExpandedAtom = atom(false); export const summaryDisplayModeAtom = atom("color"); @@ -127,7 +70,3 @@ export const selectedPositionAtom = atom((get) => { const id = get(selectedPositionIdAtom); return id ? (config.positions.find((p) => p.id === id) ?? null) : null; }); - -export const breakPositionAtom = atom((get) => { - return get(shiftConfigAtom).positions.find((p) => p.name === "休憩") ?? null; -}); diff --git a/src/components/features/Shift/ShiftForm/types.ts b/src/components/features/Shift/ShiftForm/types.ts index 975fb165..ddd46035 100644 --- a/src/components/features/Shift/ShiftForm/types.ts +++ b/src/components/features/Shift/ShiftForm/types.ts @@ -55,10 +55,7 @@ export type TimeRange = { export type ViewMode = "daily" | "overview"; // ドラッグモード(希望シフトバーは編集不可のため、ポジション関連のみ) -export type DragMode = "position-resize-start" | "position-resize-end" | "paint" | "scroll" | null; - -// ツールモード(常にどれか1つが選択される) -export type ToolMode = "select" | "assign"; +export type DragMode = "position-resize-start" | "position-resize-end" | "paint" | null; // サマリー行の表示モード export type SummaryDisplayMode = "color" | "number"; @@ -84,6 +81,14 @@ export type LinkedResizeTarget = { // 一覧ビュー型 // ========================================== +// ピーク帯定義 +export type PeakBand = { + name: string; // "ランチ", "ディナー" + startTime: string; // "11:00" + endTime: string; // "14:00" + requiredCount: number; // 必要人数 +}; + // 必要人員設定データ(convex/requiredStaffing テーブルの1レコードに対応) export type RequiredStaffingData = { dayOfWeek: number; // 0=日, 1=月, ..., 6=土 @@ -92,6 +97,8 @@ export type RequiredStaffingData = { position: string; requiredCount: number; }[]; + peakBands?: PeakBand[]; + minimumStaff?: number; }; // スタッフ行表示用データ @@ -125,12 +132,15 @@ export type StaffAlert = { // ========================================== // 日付ヘッダー Props +export type DayStatus = "none" | "warning" | "ok"; + export type OverviewHeaderProps = { dates: string[]; months: string[]; // ["2026-01", "2026-02"] holidays: string[]; sortMode: SortMode | null; onSortModeChange: (mode: SortMode) => void; + dateStatuses?: Map; }; // スタッフ行 Props From 8e7ec64eb58f82ccb5aa786c3662c0438423fefc Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 09:30:39 +0900 Subject: [PATCH 011/176] =?UTF-8?q?feat:=20=E3=83=9D=E3=82=B8=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E9=96=93=E3=82=AE=E3=83=A3=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=81=AE=E4=BC=91=E6=86=A9=E3=82=B9=E3=83=88=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=97=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 情報設計改善プランStep2: computeVisualBreaksでポジション間の空き時間を検出し、 ストライプパターンで休憩を可視化。PC・SP両対応。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pc/DailyView/ShiftGrid/ShiftBar.tsx | 28 ++++++++++++++ .../ShiftForm/sp/DailyView/MiniShiftBar.tsx | 22 ++++++++++- .../ShiftForm/utils/shiftOperations.test.ts | 37 +++++++++++++++++++ .../Shift/ShiftForm/utils/shiftOperations.ts | 23 ++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx index 98f2676b..454d9680 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "@chakra-ui/react"; import type { LinkedResizeTarget, ShiftData, TimeRange } from "../../../types"; +import { computeVisualBreaks } from "../../../utils/shiftOperations"; import { minutesToPixel, timeToMinutes } from "../../../utils/timeConversion"; type ShiftBarProps = { @@ -175,6 +176,33 @@ export const ShiftBar = ({ }); })()} + {/* ビジュアル休憩バー(ポジション間のギャップをストライプ表示) */} + {shift.positions.length >= 2 && + !linkedTarget && + computeVisualBreaks(shift.positions).map((gap) => { + const gapStartPx = minutesToPixel(timeToMinutes(gap.start), timeRange); + const gapEndPx = minutesToPixel(timeToMinutes(gap.end), timeRange); + const relativeLeft = gapStartPx - barLeft; + const relativeWidth = gapEndPx - gapStartPx; + + return ( + + ); + })} + {/* 時刻ラベル(ポジションがある場合のみ表示、リサイズ中は非表示) */} {shift.positions.length > 0 && !linkedTarget && diff --git a/src/components/features/Shift/ShiftForm/sp/DailyView/MiniShiftBar.tsx b/src/components/features/Shift/ShiftForm/sp/DailyView/MiniShiftBar.tsx index bc97799b..40ec7c22 100644 --- a/src/components/features/Shift/ShiftForm/sp/DailyView/MiniShiftBar.tsx +++ b/src/components/features/Shift/ShiftForm/sp/DailyView/MiniShiftBar.tsx @@ -1,5 +1,6 @@ import { Box, Flex, Text } from "@chakra-ui/react"; import type { PositionSegment, TimeRange } from "../../types"; +import { computeVisualBreaks } from "../../utils/shiftOperations"; import { timeToMinutes } from "../../utils/timeConversion"; type MiniShiftBarProps = { @@ -44,7 +45,26 @@ export const MiniShiftBar = ({ positions, timeRange }: MiniShiftBarProps) => { width={`${width}%`} h="100%" bg={seg.color} - opacity={seg.positionName === "休憩" ? 0.3 : 0.8} + opacity={0.8} + /> + ); + })} + {/* ビジュアル休憩バー(ポジション間のギャップをストライプ表示) */} + {computeVisualBreaks(positions).map((gap) => { + const startMin = timeToMinutes(gap.start) - timeRange.start * 60; + const endMin = timeToMinutes(gap.end) - timeRange.start * 60; + const left = (startMin / totalMinutes) * 100; + const width = ((endMin - startMin) / totalMinutes) * 100; + + return ( + ); })} diff --git a/src/components/features/Shift/ShiftForm/utils/shiftOperations.test.ts b/src/components/features/Shift/ShiftForm/utils/shiftOperations.test.ts index 5f1dc005..82975074 100644 --- a/src/components/features/Shift/ShiftForm/utils/shiftOperations.test.ts +++ b/src/components/features/Shift/ShiftForm/utils/shiftOperations.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import type { LinkedResizeTarget, PositionSegment, ShiftData } from "../types"; import { + computeVisualBreaks, deletePositionFromShift, fillGapsWithBreak, mergeAdjacentPositions, @@ -337,3 +338,39 @@ describe("resizeLinkedPositions", () => { expect(result.positions.find((p) => p.id === "b")).toBeUndefined(); }); }); + +describe("computeVisualBreaks", () => { + test("2つのポジション間のギャップが休憩として返される", () => { + const positions = [seg({ id: "a", start: "10:00", end: "12:00" }), seg({ id: "b", start: "14:00", end: "18:00" })]; + const result = computeVisualBreaks(positions); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ start: "12:00", end: "14:00" }); + }); + + test("隣接するポジション間にはギャップなし", () => { + const positions = [seg({ id: "a", start: "10:00", end: "12:00" }), seg({ id: "b", start: "12:00", end: "14:00" })]; + const result = computeVisualBreaks(positions); + expect(result).toHaveLength(0); + }); + + test("ポジション1つのみ → 空配列", () => { + const positions = [seg({ id: "a", start: "10:00", end: "14:00" })]; + expect(computeVisualBreaks(positions)).toHaveLength(0); + }); + + test("空配列 → 空配列", () => { + expect(computeVisualBreaks([])).toHaveLength(0); + }); + + test("3つのポジション間に複数ギャップ", () => { + const positions = [ + seg({ id: "a", start: "10:00", end: "12:00" }), + seg({ id: "b", start: "13:00", end: "14:00" }), + seg({ id: "c", start: "16:00", end: "18:00" }), + ]; + const result = computeVisualBreaks(positions); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ start: "12:00", end: "13:00" }); + expect(result[1]).toEqual({ start: "14:00", end: "16:00" }); + }); +}); diff --git a/src/components/features/Shift/ShiftForm/utils/shiftOperations.ts b/src/components/features/Shift/ShiftForm/utils/shiftOperations.ts index 3e46eb57..373c72c8 100644 --- a/src/components/features/Shift/ShiftForm/utils/shiftOperations.ts +++ b/src/components/features/Shift/ShiftForm/utils/shiftOperations.ts @@ -414,6 +414,29 @@ export const mergeAdjacentPositions = (positions: PositionSegment[]): PositionSe return merged; }; +// ポジション間のギャップを休憩として計算(UI表示専用、DB保存しない) +// 最初のstartから最後のendの間のみ対象(出勤前・退勤後は対象外) +export const computeVisualBreaks = (positions: PositionSegment[]): { start: string; end: string }[] => { + if (positions.length <= 1) return []; + + const sorted = [...positions].sort((a, b) => timeToMinutes(a.start) - timeToMinutes(b.start)); + const breaks: { start: string; end: string }[] = []; + + for (let i = 1; i < sorted.length; i++) { + const prevEnd = timeToMinutes(sorted[i - 1].end); + const currentStart = timeToMinutes(sorted[i].start); + + if (currentStart > prevEnd) { + breaks.push({ + start: minutesToTime(prevEnd), + end: minutesToTime(currentStart), + }); + } + } + + return breaks; +}; + // バー間の空白を休憩で埋める(端は埋めない) export const fillGapsWithBreak = (params: { positions: PositionSegment[]; From 72cb6e3bbefe1cf05dc93ac793c1eced3a40d7f4 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 09:30:54 +0900 Subject: [PATCH 012/176] =?UTF-8?q?feat:=20=E3=83=94=E3=83=BC=E3=82=AF?= =?UTF-8?q?=E5=B8=AF=E8=A8=AD=E5=AE=9A=E3=83=BB=E5=85=85=E8=B6=B3=E5=BA=A6?= =?UTF-8?q?=E3=82=A2=E3=83=A9=E3=83=BC=E3=83=88=E3=83=BBDateTabs=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=B8=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 情報設計改善プランStep3-5: ピーク帯定義+最低人員の簡易入力モード、 DailyView/OverviewViewの充足度アラート表示、DateTabsの曜日色分け・⚠️/✅バッジ。 dateStatuses計算をuseDateStatuses hookに共通化。 Co-Authored-By: Claude Opus 4.6 (1M context) --- convex/requiredStaffing/mutations.ts | 57 ++++ convex/schema.ts | 13 + .../features/Shift/PeakBandSettings/index.tsx | 273 ++++++++++++++++++ .../Shift/ShiftForm/hooks/useDateStatuses.ts | 27 ++ .../Shift/ShiftForm/pc/DailyView/DateTabs.tsx | 43 ++- .../ShiftForm/pc/DailyView/PeakBandAlert.tsx | 78 +++++ .../Shift/ShiftForm/pc/DailyView/index.tsx | 57 ++-- .../pc/OverviewView/OverviewHeader.tsx | 12 +- .../Shift/ShiftForm/pc/OverviewView/index.tsx | 5 + .../ShiftForm/utils/staffingAlerts.test.ts | 82 ++++++ .../Shift/ShiftForm/utils/staffingAlerts.ts | 118 ++++++++ .../Shops/StaffingSettingsPage/index.tsx | 154 +--------- 12 files changed, 732 insertions(+), 187 deletions(-) create mode 100644 src/components/features/Shift/PeakBandSettings/index.tsx create mode 100644 src/components/features/Shift/ShiftForm/hooks/useDateStatuses.ts create mode 100644 src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx create mode 100644 src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts create mode 100644 src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts diff --git a/convex/requiredStaffing/mutations.ts b/convex/requiredStaffing/mutations.ts index d890e341..c23f4912 100644 --- a/convex/requiredStaffing/mutations.ts +++ b/convex/requiredStaffing/mutations.ts @@ -102,6 +102,8 @@ export const copyToMultipleDays = mutation({ if (existing) { await ctx.db.patch(existing._id, { staffing: source.staffing, + peakBands: source.peakBands, + minimumStaff: source.minimumStaff, updatedAt: now, }); results.push({ dayOfWeek: targetDay, id: existing._id }); @@ -110,6 +112,8 @@ export const copyToMultipleDays = mutation({ shopId: args.shopId, dayOfWeek: targetDay, staffing: source.staffing, + peakBands: source.peakBands, + minimumStaff: source.minimumStaff, aiInput: source.aiInput, createdAt: now, updatedAt: now, @@ -122,6 +126,59 @@ export const copyToMultipleDays = mutation({ }, }); +// ピーク帯設定を保存・更新(曜日単位) +export const upsertPeakBands = mutation({ + args: { + shopId: v.id("shops"), + dayOfWeek: v.number(), + peakBands: v.array( + v.object({ + name: v.string(), + startTime: v.string(), + endTime: v.string(), + requiredCount: v.number(), + }), + ), + minimumStaff: v.number(), + }, + handler: async (ctx, args) => { + await requireShop(ctx, args.shopId); + + if (args.dayOfWeek < 0 || args.dayOfWeek > 7) { + throw new ConvexError({ message: "曜日の値が不正です", code: "INVALID_DAY_OF_WEEK" }); + } + + const existing = await ctx.db + .query("requiredStaffing") + .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) + .collect() + .then((list) => list.find((s) => s.dayOfWeek === args.dayOfWeek)); + + const now = Date.now(); + + if (existing) { + await ctx.db.patch(existing._id, { + peakBands: args.peakBands, + minimumStaff: args.minimumStaff, + updatedAt: now, + }); + return { success: true, id: existing._id, isNew: false }; + } + + const id = await ctx.db.insert("requiredStaffing", { + shopId: args.shopId, + dayOfWeek: args.dayOfWeek, + staffing: [], + peakBands: args.peakBands, + minimumStaff: args.minimumStaff, + createdAt: now, + updatedAt: now, + }); + + return { success: true, id, isNew: true }; + }, +}); + // 全曜日分をまとめて保存(初回設定用) export const saveAll = mutation({ args: { diff --git a/convex/schema.ts b/convex/schema.ts index 97783f5d..a8a88476 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -105,6 +105,19 @@ const requiredStaffing = defineTable({ requiredCount: v.number(), }), ), + // ピーク帯定義(簡易入力モード) + peakBands: v.optional( + v.array( + v.object({ + name: v.string(), // "ランチ", "ディナー" + startTime: v.string(), // "11:00" + endTime: v.string(), // "14:00" + requiredCount: v.number(), // 必要人数 + }), + ), + ), + // 最低人員(常時必要な最低人数) + minimumStaff: v.optional(v.number()), // AI生成時の入力情報(作り直し用) aiInput: v.optional( v.object({ diff --git a/src/components/features/Shift/PeakBandSettings/index.tsx b/src/components/features/Shift/PeakBandSettings/index.tsx new file mode 100644 index 00000000..ba4856e8 --- /dev/null +++ b/src/components/features/Shift/PeakBandSettings/index.tsx @@ -0,0 +1,273 @@ +import { Box, Button, Container, Flex, Heading, Icon, IconButton, Input, Text, VStack } from "@chakra-ui/react"; +import { useCallback, useState } from "react"; +import { LuPlus, LuSave, LuSettings, LuTrash2 } from "react-icons/lu"; +import { Title } from "@/src/components/ui/Title"; +import { DayTabs } from "../StaffingRequirement/DayTabs"; + +type PeakBand = { + name: string; + startTime: string; + endTime: string; + requiredCount: number; +}; + +type DaySettings = { + peakBands: PeakBand[]; + minimumStaff: number; +}; + +type PeakBandSettingsProps = { + shopId: string; + shopName: string; + onSave: (params: { dayOfWeek: number; peakBands: PeakBand[]; minimumStaff: number }) => Promise; + isSaving?: boolean; +}; + +const DEFAULT_PEAK_BAND: PeakBand = { name: "", startTime: "11:00", endTime: "14:00", requiredCount: 3 }; +const DEFAULT_DAY_SETTINGS: DaySettings = { peakBands: [], minimumStaff: 1 }; + +export const PeakBandSettings = ({ shopId, shopName, onSave, isSaving = false }: PeakBandSettingsProps) => { + const [selectedDay, setSelectedDay] = useState(1); + const [daySettingsMap, setDaySettingsMap] = useState>({}); + const [hasChanges, setHasChanges] = useState(false); + + const currentSettings = daySettingsMap[selectedDay] ?? DEFAULT_DAY_SETTINGS; + + const updateCurrentDay = useCallback( + (updater: (prev: DaySettings) => DaySettings) => { + setDaySettingsMap((prev) => ({ + ...prev, + [selectedDay]: updater(prev[selectedDay] ?? DEFAULT_DAY_SETTINGS), + })); + setHasChanges(true); + }, + [selectedDay], + ); + + // ピーク帯追加 + const handleAddBand = useCallback(() => { + updateCurrentDay((prev) => ({ + ...prev, + peakBands: [...prev.peakBands, { ...DEFAULT_PEAK_BAND }], + })); + }, [updateCurrentDay]); + + // ピーク帯削除 + const handleRemoveBand = useCallback( + (index: number) => { + updateCurrentDay((prev) => ({ + ...prev, + peakBands: prev.peakBands.filter((_, i) => i !== index), + })); + }, + [updateCurrentDay], + ); + + // ピーク帯フィールド更新 + const handleBandChange = useCallback( + (index: number, field: keyof PeakBand, value: string | number) => { + updateCurrentDay((prev) => ({ + ...prev, + peakBands: prev.peakBands.map((band, i) => (i === index ? { ...band, [field]: value } : band)), + })); + }, + [updateCurrentDay], + ); + + // 最低人員更新 + const handleMinimumStaffChange = useCallback( + (value: number) => { + updateCurrentDay((prev) => ({ ...prev, minimumStaff: value })); + }, + [updateCurrentDay], + ); + + // 保存 + const handleSave = useCallback(async () => { + try { + await onSave({ + dayOfWeek: selectedDay, + peakBands: currentSettings.peakBands, + minimumStaff: currentSettings.minimumStaff, + }); + setHasChanges(false); + } catch { + // エラーは親でハンドリング + } + }, [selectedDay, currentSettings, onSave]); + + // 設定済み曜日の一覧 + const configuredDays = Object.keys(daySettingsMap) + .map(Number) + .filter((day) => { + const settings = daySettingsMap[day]; + return settings && settings.peakBands.length > 0; + }); + + return ( + + {/* ヘッダー */} + + <Flex align="center" gap={3}> + <Flex p={{ base: 2, md: 3 }} bg="purple.50" borderRadius="lg"> + <Icon as={LuSettings} boxSize={6} color="purple.600" /> + </Flex> + <Box> + <Heading as="h2" size="xl" color="gray.900"> + 必要人員設定 + </Heading> + <Text color="gray.500" fontSize="sm"> + {shopName} + </Text> + </Box> + </Flex> + + + {/* 曜日タブ */} + + + + + {/* ピーク帯設定 */} + + {/* ピーク帯リスト */} + + + + ピーク帯 + + + + + {currentSettings.peakBands.length === 0 ? ( + + + ピーク帯が設定されていません + + + 「追加」ボタンからランチ帯・ディナー帯などを設定してください + + + ) : ( + + {currentSettings.peakBands.map((band, index) => ( + + handleBandChange(index, "name", e.target.value)} + w={{ base: "100%", md: "140px" }} + /> + + handleBandChange(index, "startTime", e.target.value)} + w="120px" + /> + + 〜 + + handleBandChange(index, "endTime", e.target.value)} + w="120px" + /> + + + handleBandChange(index, "requiredCount", Number(e.target.value))} + w="70px" + textAlign="center" + /> + + 人 + + + handleRemoveBand(index)} + > + + + + ))} + + )} + + + {/* 最低人員 */} + + + 最低人員 + + + + 常に最低 + + handleMinimumStaffChange(Number(e.target.value))} + w="70px" + textAlign="center" + /> + + 人を配置 + + + + + {/* 保存バー */} + + {hasChanges && ( + + 未保存の変更があります + + )} + + + + + ); +}; diff --git a/src/components/features/Shift/ShiftForm/hooks/useDateStatuses.ts b/src/components/features/Shift/ShiftForm/hooks/useDateStatuses.ts new file mode 100644 index 00000000..f5627d7f --- /dev/null +++ b/src/components/features/Shift/ShiftForm/hooks/useDateStatuses.ts @@ -0,0 +1,27 @@ +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { shiftConfigAtom, shiftsAtom } from "../stores"; +import type { DayStatus } from "../types"; +import { calculateDayStaffingStatus, getDayStatus } from "../utils/staffingAlerts"; + +export const useDateStatuses = (): Map | undefined => { + const { dates, requiredStaffing } = useAtomValue(shiftConfigAtom); + const shifts = useAtomValue(shiftsAtom); + + return useMemo(() => { + if (!requiredStaffing || requiredStaffing.length === 0) return undefined; + const map = new Map(); + for (const date of dates) { + const dayOfWeek = new Date(date).getDay(); + const dayStaffing = requiredStaffing.find((rs) => rs.dayOfWeek === dayOfWeek); + const status = calculateDayStaffingStatus({ + shifts, + date, + peakBands: dayStaffing?.peakBands, + minimumStaff: dayStaffing?.minimumStaff, + }); + map.set(date, getDayStatus(status)); + } + return map; + }, [dates, shifts, requiredStaffing]); +}; diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx index a825e9b8..bb267f26 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx @@ -1,10 +1,13 @@ -import { Tabs } from "@chakra-ui/react"; +import { Flex, Tabs, Text } from "@chakra-ui/react"; import dayjs from "dayjs"; +import type { DayStatus } from "../../types"; type DateTabsProps = { dates: string[]; selectedDate: string; onSelect: (date: string) => void; + holidays?: string[]; + dateStatuses?: Map; }; // 日付をフォーマット (M/D(曜日)) @@ -12,7 +15,25 @@ const formatDate = (dateStr: string) => { return dayjs(dateStr).format("M/D(ddd)"); }; -export const DateTabs = ({ dates, selectedDate, onSelect }: DateTabsProps) => { +// 曜日に応じた色を返す +const getDayColor = (dateStr: string, holidays: string[]): string | undefined => { + const day = dayjs(dateStr).day(); + if (day === 0 || holidays.includes(dateStr)) return "red.500"; // 日曜・祝日 + if (day === 6) return "blue.500"; // 土曜 + return undefined; // 平日はデフォルト +}; + +// バッジ表示 +const StatusBadge = ({ status }: { status: DayStatus }) => { + if (status === "none") return null; + return ( + + {status === "warning" ? "⚠️" : "✅"} + + ); +}; + +export const DateTabs = ({ dates, selectedDate, onSelect, holidays = [], dateStatuses }: DateTabsProps) => { return ( { scrollbarWidth: "none", }} > - {dates.map((date) => ( - - {formatDate(date)} - - ))} + {dates.map((date) => { + const dayColor = getDayColor(date, holidays); + const status = dateStatuses?.get(date) ?? "none"; + + return ( + + + {formatDate(date)} + + + + ); + })} ); diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx new file mode 100644 index 00000000..1e136079 --- /dev/null +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx @@ -0,0 +1,78 @@ +import { Flex, Icon, Text } from "@chakra-ui/react"; +import { useMemo } from "react"; +import { LuCircleCheck, LuTriangleAlert } from "react-icons/lu"; +import type { PeakBand, ShiftData } from "../../types"; +import { calculateDayStaffingStatus } from "../../utils/staffingAlerts"; + +type PeakBandAlertProps = { + shifts: ShiftData[]; + date: string; + peakBands?: PeakBand[]; + minimumStaff?: number; +}; + +export const PeakBandAlert = ({ shifts, date, peakBands, minimumStaff }: PeakBandAlertProps) => { + const status = useMemo( + () => calculateDayStaffingStatus({ shifts, date, peakBands, minimumStaff }), + [shifts, date, peakBands, minimumStaff], + ); + + // ピーク帯未設定なら何も表示しない + if (status.peakBandStatuses.length === 0 && status.minimumStaffStatus === null) return null; + + return ( + + {/* ピーク帯ステータス */} + {status.peakBandStatuses.map((band) => ( + + {band.isSatisfied ? ( + + ) : ( + + )} + + {band.name || `${band.startTime}〜${band.endTime}`} + + {!band.isSatisfied && ( + + あと{band.shortfall}人 + + )} + + ))} + + {/* 最低人員ステータス */} + {status.minimumStaffStatus && ( + + {status.minimumStaffStatus.isSatisfied ? ( + + ) : ( + + )} + + 最低人員 + + {!status.minimumStaffStatus.isSatisfied && ( + + あと{status.minimumStaffStatus.requiredCount - status.minimumStaffStatus.actualMinCount}人 + + )} + + )} + + ); +}; diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx index beaf1e86..1e6ced63 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx @@ -3,38 +3,23 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useState } from "react"; import { StaffEditModal } from "@/src/components/features/Staff/StaffEditModal"; import { useDialog } from "@/src/components/ui/Dialog"; -import { - breakPositionAtom, - selectedDateAtom, - selectedPositionIdAtom, - shiftConfigAtom, - shiftsAtom, - toolModeAtom, -} from "../../stores"; +import { useDateStatuses } from "../../hooks/useDateStatuses"; +import { selectedDateAtom, selectedPositionIdAtom, shiftConfigAtom, shiftsAtom } from "../../stores"; import type { ShiftData } from "../../types"; -import { deletePositionFromShift, normalizePositions } from "../../utils/shiftOperations"; import { DateTabs } from "./DateTabs"; import { PositionToolbar } from "./PositionToolbar"; import { ShiftGrid } from "./ShiftGrid"; import { ShiftPopover } from "./ShiftPopover"; -type UndoRedoHandlers = { - undo: () => void; - redo: () => void; - canUndo: boolean; - canRedo: boolean; -}; - -export const DailyView = ({ undo, redo, canUndo, canRedo }: UndoRedoHandlers) => { +export const DailyView = () => { const config = useAtomValue(shiftConfigAtom); const shifts = useAtomValue(shiftsAtom); const setShifts = useSetAtom(shiftsAtom); - const breakPosition = useAtomValue(breakPositionAtom); - const [toolMode, setToolMode] = useAtom(toolModeAtom); const [selectedPositionId, setSelectedPositionId] = useAtom(selectedPositionIdAtom); const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom); - const { positions, dates, isReadOnly, shopId } = config; + const { positions, dates, isReadOnly, shopId, holidays } = config; + const dateStatuses = useDateStatuses(); // === スタッフ編集モーダル === const staffEditModal = useDialog(); @@ -72,25 +57,16 @@ export const DailyView = ({ undo, redo, canUndo, canRedo }: UndoRedoHandlers) => const handleDeletePosition = useCallback( (positionId: string) => { if (!popoverShift) return; - const updatedShift = breakPosition - ? deletePositionFromShift({ - shift: popoverShift, - positionSegmentId: positionId, - breakPositionId: breakPosition.id, - }) - : { ...popoverShift, positions: popoverShift.positions.filter((p) => p.id !== positionId) }; + const updatedShift = { ...popoverShift, positions: popoverShift.positions.filter((p) => p.id !== positionId) }; const newShifts = shifts.map((s) => (s.id === popoverShift.id ? updatedShift : s)); setShifts(newShifts); - const normalizedPositions = breakPosition - ? normalizePositions({ positions: updatedShift.positions, breakPosition }) - : updatedShift.positions; - if (normalizedPositions.length === 0) { + if (updatedShift.positions.length === 0) { handlePopoverClose(); return; } - setPopoverShift({ ...updatedShift, positions: normalizedPositions }); + setPopoverShift(updatedShift); }, - [popoverShift, shifts, setShifts, breakPosition, handlePopoverClose], + [popoverShift, shifts, setShifts, handlePopoverClose], ); // 全ポジション削除 @@ -114,15 +90,9 @@ export const DailyView = ({ undo, redo, canUndo, canRedo }: UndoRedoHandlers) => {!isReadOnly && ( )} @@ -137,7 +107,13 @@ export const DailyView = ({ undo, redo, canUndo, canRedo }: UndoRedoHandlers) => borderRadius="lg" overflow="hidden" > - + isOpen={staffEditModal.isOpen} onOpenChange={staffEditModal.onOpenChange} onClose={staffEditModal.close} + viewOnly /> )} diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/OverviewHeader.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/OverviewHeader.tsx index 80ed6f39..543220cc 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/OverviewHeader.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/OverviewHeader.tsx @@ -4,6 +4,7 @@ import { Tooltip } from "@/src/components/ui/tooltip"; import { DATE_CELL_WIDTH, MONTH_TOTAL_CELL_WIDTH, ROW_HEIGHT, STAFF_NAME_CELL_WIDTH } from "../../constants"; import { SortMenu } from "../../shared/SortMenu"; import type { OverviewHeaderProps } from "../../types"; + import { formatDateShort, formatMonthLabel, @@ -26,7 +27,14 @@ const getDateCellColors = (date: string, holidays: string[]) => { return { bg: "white", color: "gray.700" }; }; -export const OverviewHeader = ({ dates, months, holidays, sortMode, onSortModeChange }: OverviewHeaderProps) => ( +export const OverviewHeader = ({ + dates, + months, + holidays, + sortMode, + onSortModeChange, + dateStatuses, +}: OverviewHeaderProps) => ( {/* 左上コーナーセル(ソートメニュー) */} @@ -63,6 +71,8 @@ export const OverviewHeader = ({ dates, months, holidays, sortMode, onSortModeCh {formatDateShort(date)} + {dateStatuses?.get(date) === "warning" && " ⚠️"} + {dateStatuses?.get(date) === "ok" && " ✅"} {getWeekdayLabel(date)} diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx index 25355d8c..467c23c7 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx @@ -3,6 +3,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useMemo, useState } from "react"; import { StaffEditModal } from "@/src/components/features/Staff/StaffEditModal"; import { useDialog } from "@/src/components/ui/Dialog"; +import { useDateStatuses } from "../../hooks/useDateStatuses"; import { selectedDateAtom, shiftConfigAtom, @@ -57,6 +58,8 @@ export const OverviewView = () => { // 月合計用のシフトデータ(allShiftsがあればそれを使用) const shiftsForMonthly = allShifts ?? shifts; + const dateStatuses = useDateStatuses(); + // スタッフごとのデータ整形(sortedStaffs の順序を維持) const staffRowDataList = useMemo( () => prepareStaffRowData(sortedStaffs, shifts, shiftsForMonthly, dates, months), @@ -73,6 +76,7 @@ export const OverviewView = () => { holidays={holidays} sortMode={sortMode} onSortModeChange={setSortMode} + dateStatuses={dateStatuses} /> {staffRowDataList.map((staffData) => ( @@ -100,6 +104,7 @@ export const OverviewView = () => { isOpen={staffEditModal.isOpen} onOpenChange={staffEditModal.onOpenChange} onClose={staffEditModal.close} + viewOnly /> )} diff --git a/src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts b/src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts new file mode 100644 index 00000000..ee8b98a7 --- /dev/null +++ b/src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "vitest"; +import type { PeakBand, ShiftData } from "../types"; +import { calculateDayStaffingStatus, getDayStatus } from "./staffingAlerts"; + +const makeShift = (staffId: string, positions: { start: string; end: string }[]): ShiftData => ({ + id: `shift-${staffId}`, + staffId, + staffName: staffId, + date: "2026-03-24", + requestedTime: null, + positions: positions.map((p, i) => ({ + id: `pos-${staffId}-${i}`, + positionId: "pos1", + positionName: "ホール", + color: "#3b82f6", + start: p.start, + end: p.end, + })), +}); + +describe("calculateDayStaffingStatus", () => { + const peakBands: PeakBand[] = [ + { name: "ランチ", startTime: "11:00", endTime: "14:00", requiredCount: 3 }, + { name: "ディナー", startTime: "17:00", endTime: "21:00", requiredCount: 5 }, + ]; + + test("ピーク帯が充足している場合", () => { + const shifts = [ + makeShift("a", [{ start: "10:00", end: "15:00" }]), + makeShift("b", [{ start: "10:00", end: "15:00" }]), + makeShift("c", [{ start: "10:00", end: "15:00" }]), + makeShift("d", [{ start: "16:00", end: "22:00" }]), + makeShift("e", [{ start: "16:00", end: "22:00" }]), + makeShift("f", [{ start: "16:00", end: "22:00" }]), + makeShift("g", [{ start: "16:00", end: "22:00" }]), + makeShift("h", [{ start: "16:00", end: "22:00" }]), + ]; + const result = calculateDayStaffingStatus({ shifts, date: "2026-03-24", peakBands }); + expect(result.peakBandStatuses[0].isSatisfied).toBe(true); + expect(result.peakBandStatuses[1].isSatisfied).toBe(true); + expect(result.isFullySatisfied).toBe(true); + }); + + test("ランチ帯が不足している場合", () => { + const shifts = [ + makeShift("a", [{ start: "10:00", end: "15:00" }]), + makeShift("b", [{ start: "10:00", end: "15:00" }]), + ]; + const result = calculateDayStaffingStatus({ shifts, date: "2026-03-24", peakBands }); + expect(result.peakBandStatuses[0].isSatisfied).toBe(false); + expect(result.peakBandStatuses[0].shortfall).toBe(1); + expect(result.isFullySatisfied).toBe(false); + }); + + test("ピーク帯未設定 → 全て none", () => { + const result = calculateDayStaffingStatus({ shifts: [], date: "2026-03-24" }); + expect(result.peakBandStatuses).toHaveLength(0); + expect(result.isFullySatisfied).toBe(true); + expect(getDayStatus(result)).toBe("none"); + }); + + test("最低人員が不足している場合", () => { + const shifts = [makeShift("a", [{ start: "10:00", end: "18:00" }])]; + const result = calculateDayStaffingStatus({ shifts, date: "2026-03-24", minimumStaff: 2 }); + expect(result.minimumStaffStatus?.isSatisfied).toBe(false); + expect(result.isFullySatisfied).toBe(false); + }); + + test("getDayStatus - 充足", () => { + const shifts = [ + makeShift("a", [{ start: "10:00", end: "15:00" }]), + makeShift("b", [{ start: "10:00", end: "15:00" }]), + makeShift("c", [{ start: "10:00", end: "15:00" }]), + ]; + const result = calculateDayStaffingStatus({ + shifts, + date: "2026-03-24", + peakBands: [{ name: "ランチ", startTime: "11:00", endTime: "14:00", requiredCount: 3 }], + }); + expect(getDayStatus(result)).toBe("ok"); + }); +}); diff --git a/src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts b/src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts new file mode 100644 index 00000000..65414dd5 --- /dev/null +++ b/src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts @@ -0,0 +1,118 @@ +import type { PeakBand, ShiftData } from "../types"; +import { timeToMinutes } from "./timeConversion"; + +export type PeakBandStatus = { + name: string; + startTime: string; + endTime: string; + requiredCount: number; + actualCount: number; + shortfall: number; + isSatisfied: boolean; +}; + +export type DayStaffingStatus = { + peakBandStatuses: PeakBandStatus[]; + minimumStaffStatus: { + requiredCount: number; + actualMinCount: number; + isSatisfied: boolean; + } | null; + isFullySatisfied: boolean; +}; + +// 特定時刻に稼働しているスタッフ数を計算 +const countStaffAtMinute = (shifts: ShiftData[], date: string, minute: number): number => { + let count = 0; + for (const shift of shifts) { + if (shift.date !== date) continue; + for (const pos of shift.positions) { + const posStart = timeToMinutes(pos.start); + const posEnd = timeToMinutes(pos.end); + if (minute >= posStart && minute < posEnd) { + count++; + break; // 1スタッフにつき1カウント(複数ポジション持ちでも) + } + } + } + return count; +}; + +// ピーク帯内の最小稼働人数を計算(30分刻みでサンプリング) +const getMinStaffInBand = (shifts: ShiftData[], date: string, startTime: string, endTime: string): number => { + const startMin = timeToMinutes(startTime); + const endMin = timeToMinutes(endTime); + + if (startMin >= endMin) return 0; + + let minCount = Number.POSITIVE_INFINITY; + // 30分刻みでチェック + for (let m = startMin; m < endMin; m += 30) { + const count = countStaffAtMinute(shifts, date, m); + minCount = Math.min(minCount, count); + } + + return minCount === Number.POSITIVE_INFINITY ? 0 : minCount; +}; + +// 選択日のピーク帯ごとの充足度を計算 +export const calculateDayStaffingStatus = (params: { + shifts: ShiftData[]; + date: string; + peakBands?: PeakBand[]; + minimumStaff?: number; +}): DayStaffingStatus => { + const { shifts, date, peakBands, minimumStaff } = params; + + // ピーク帯ステータス + const peakBandStatuses: PeakBandStatus[] = (peakBands ?? []).map((band) => { + const actualCount = getMinStaffInBand(shifts, date, band.startTime, band.endTime); + const shortfall = Math.max(0, band.requiredCount - actualCount); + return { + name: band.name, + startTime: band.startTime, + endTime: band.endTime, + requiredCount: band.requiredCount, + actualCount, + shortfall, + isSatisfied: shortfall === 0, + }; + }); + + // 最低人員ステータス(全営業時間帯で最低人員を満たしているか) + let minimumStaffStatus: DayStaffingStatus["minimumStaffStatus"] = null; + if (minimumStaff !== undefined && minimumStaff > 0) { + // シフトが割り当てられている時間帯のみチェック + const allShiftsForDate = shifts.filter((s) => s.date === date && s.positions.length > 0); + if (allShiftsForDate.length > 0) { + // ポジションが存在する全時間帯で最低人員をチェック + const allPositions = allShiftsForDate.flatMap((s) => s.positions); + const minTime = Math.min(...allPositions.map((p) => timeToMinutes(p.start))); + const maxTime = Math.max(...allPositions.map((p) => timeToMinutes(p.end))); + + let actualMinCount = Number.POSITIVE_INFINITY; + for (let m = minTime; m < maxTime; m += 30) { + const count = countStaffAtMinute(shifts, date, m); + actualMinCount = Math.min(actualMinCount, count); + } + actualMinCount = actualMinCount === Number.POSITIVE_INFINITY ? 0 : actualMinCount; + + minimumStaffStatus = { + requiredCount: minimumStaff, + actualMinCount, + isSatisfied: actualMinCount >= minimumStaff, + }; + } + } + + const isFullySatisfied = + peakBandStatuses.every((s) => s.isSatisfied) && (minimumStaffStatus === null || minimumStaffStatus.isSatisfied); + + return { peakBandStatuses, minimumStaffStatus, isFullySatisfied }; +}; + +// 日単位の充足判定(バッジ用) +export const getDayStatus = (status: DayStaffingStatus): "none" | "warning" | "ok" => { + if (status.peakBandStatuses.length === 0 && status.minimumStaffStatus === null) return "none"; + return status.isFullySatisfied ? "ok" : "warning"; +}; diff --git a/src/components/pages/Shops/StaffingSettingsPage/index.tsx b/src/components/pages/Shops/StaffingSettingsPage/index.tsx index f9b71068..f30dc99b 100644 --- a/src/components/pages/Shops/StaffingSettingsPage/index.tsx +++ b/src/components/pages/Shops/StaffingSettingsPage/index.tsx @@ -1,10 +1,8 @@ import { useMutation, useQuery } from "convex/react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; -import { StaffingRequirement } from "@/src/components/features/Shift/StaffingRequirement"; -import { SetupWizard } from "@/src/components/features/Shift/StaffingRequirement/SetupWizard"; -import type { AIInput, PatternType, StaffingEntry } from "@/src/components/features/Shift/StaffingRequirement/types"; +import { PeakBandSettings } from "@/src/components/features/Shift/PeakBandSettings"; import { LazyShow } from "@/src/components/ui/LazyShow"; import { LoadingState } from "@/src/components/ui/LoadingState"; import { toaster } from "@/src/components/ui/toaster"; @@ -15,51 +13,27 @@ type Props = { export const StaffingSettingsPage = ({ shopId }: Props) => { const shop = useQuery(api.shop.queries.getById, { shopId: shopId as Id<"shops"> }); - const positions = useQuery(api.position.queries.listByShop, { shopId: shopId as Id<"shops"> }); - // TODO: pnpm convex:dev 実行後に以下を有効化 - // const requiredStaffing = useQuery(api.requiredStaffing.queries.getByShopId, { shopId: shopId as Id<"shops"> }); - const requiredStaffing: never[] = []; // 一時的にモック // Mutations - const upsertMutation = useMutation(api.requiredStaffing.mutations.upsert); - const copyMutation = useMutation(api.requiredStaffing.mutations.copyToMultipleDays); - const saveAllMutation = useMutation(api.requiredStaffing.mutations.saveAll); + const upsertPeakBandsMutation = useMutation(api.requiredStaffing.mutations.upsertPeakBands); // UI状態 const [isSaving, setIsSaving] = useState(false); - const [isCopying, setIsCopying] = useState(false); - const [showWizard, setShowWizard] = useState(false); - // requiredStaffingをフラット化(StaffingRequirement用) - // TODO: requiredStaffingクエリ有効化後、依存配列にrequiredStaffingを追加 - const flattenedStaffing = useMemo(() => { - if (!requiredStaffing) return []; - return requiredStaffing.flatMap( - (dayRecord: { _id: string; shopId: string; dayOfWeek: number; staffing: StaffingEntry[] }) => - dayRecord.staffing.map((entry) => ({ - _id: dayRecord._id, - shopId: dayRecord.shopId, - dayOfWeek: dayRecord.dayOfWeek, - hour: entry.hour, - position: entry.position, - requiredCount: entry.requiredCount, - })), - ); - }, []); - - // データ未設定かどうか - const hasNoData = requiredStaffing !== undefined && requiredStaffing.length === 0; - - // 保存処理(曜日単位) + // 保存処理 const handleSave = useCallback( - async (params: { dayOfWeek: number; staffing: StaffingEntry[]; aiInput?: AIInput }) => { + async (params: { + dayOfWeek: number; + peakBands: { name: string; startTime: string; endTime: string; requiredCount: number }[]; + minimumStaff: number; + }) => { setIsSaving(true); try { - await upsertMutation({ + await upsertPeakBandsMutation({ shopId: shopId as Id<"shops">, dayOfWeek: params.dayOfWeek, - staffing: params.staffing, - aiInput: params.aiInput, + peakBands: params.peakBands, + minimumStaff: params.minimumStaff, }); toaster.create({ description: "必要人員設定を保存しました", @@ -76,75 +50,11 @@ export const StaffingSettingsPage = ({ shopId }: Props) => { setIsSaving(false); } }, - [shopId, upsertMutation], - ); - - // コピー処理 - const handleCopy = useCallback( - async (params: { sourceDayOfWeek: number; targetDaysOfWeek: number[] }) => { - setIsCopying(true); - try { - await copyMutation({ - shopId: shopId as Id<"shops">, - sourceDayOfWeek: params.sourceDayOfWeek, - targetDaysOfWeek: params.targetDaysOfWeek, - }); - toaster.create({ - description: "設定をコピーしました", - type: "success", - }); - } catch (error) { - toaster.create({ - description: "コピーに失敗しました", - type: "error", - }); - console.error("コピーエラー:", error); - throw error; - } finally { - setIsCopying(false); - } - }, - [shopId, copyMutation], - ); - - // SetupWizard保存処理(全曜日一括) - const handleWizardSave = useCallback( - async (patterns: PatternType[], aiInput?: AIInput) => { - setIsSaving(true); - try { - const settings = patterns.flatMap((pattern) => - pattern.appliedDays.map((dayOfWeek) => ({ - dayOfWeek, - staffing: pattern.staffing, - })), - ); - - await saveAllMutation({ - shopId: shopId as Id<"shops">, - settings, - aiInput, - }); - - setShowWizard(false); - toaster.create({ - description: "初期設定を保存しました", - type: "success", - }); - } catch (error) { - toaster.create({ - description: "保存に失敗しました", - type: "error", - }); - console.error("初期設定保存エラー:", error); - } finally { - setIsSaving(false); - } - }, - [shopId, saveAllMutation], + [shopId, upsertPeakBandsMutation], ); // ローディング - if (shop === undefined || positions === undefined) { + if (shop === undefined) { return ( @@ -157,39 +67,5 @@ export const StaffingSettingsPage = ({ shopId }: Props) => { return null; } - const shopData = { - _id: shop._id, - shopName: shop.shopName, - openTime: shop.openTime, - closeTime: shop.closeTime, - }; - - const positionData = positions.map((p) => ({ _id: p._id, name: p.name })); - - // SetupWizard表示(初回 or やり直し) - if (hasNoData || showWizard) { - return ( - setShowWizard(false)} - /> - ); - } - - return ( - setShowWizard(true)} - isSaving={isSaving} - isCopying={isCopying} - /> - ); + return ; }; From 3f692bcdef149974b12b4ca577ed5891ff45b5b3 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 09:31:00 +0900 Subject: [PATCH 013/176] =?UTF-8?q?feat:=20StaffEditModal=E3=81=AE?= =?UTF-8?q?=E5=8F=82=E7=85=A7=E5=B0=82=E7=94=A8=E3=83=A2=E3=83=BC=E3=83=89?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 情報設計改善プランStep7: viewOnly propでShiftFormからの スタッフ名クリック時は参照のみ表示に変更。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/Staff/StaffEditModal/index.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/components/features/Staff/StaffEditModal/index.tsx b/src/components/features/Staff/StaffEditModal/index.tsx index 175ca21c..f02bb2a0 100644 --- a/src/components/features/Staff/StaffEditModal/index.tsx +++ b/src/components/features/Staff/StaffEditModal/index.tsx @@ -21,9 +21,18 @@ type StaffEditModalProps = { onOpenChange: (details: { open: boolean }) => void; onClose: () => void; onSave?: () => void; + viewOnly?: boolean; }; -export const StaffEditModal = ({ staffId, shopId, isOpen, onOpenChange, onClose, onSave }: StaffEditModalProps) => { +export const StaffEditModal = ({ + staffId, + shopId, + isOpen, + onOpenChange, + onClose, + onSave, + viewOnly = false, +}: StaffEditModalProps) => { const user = useAtomValue(userAtom); const [mode, setMode] = useState("view"); const [isSubmitting, setIsSubmitting] = useState(false); @@ -137,9 +146,11 @@ export const StaffEditModal = ({ staffId, shopId, isOpen, onOpenChange, onClose, - + {!viewOnly && ( + + )} ) : ( From 8019c45a96b115db9b2e812f48ce416559b65df9 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 09:31:05 +0900 Subject: [PATCH 014/176] =?UTF-8?q?chore:=20=E5=AE=9F=E8=A3=85=E3=83=AB?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lint・type-check・test実行後の/simplify実行を必須化。 Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d9b39e11..ef706563 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,10 @@ pnpm e2e e2e/path/to/file.spec.ts # 特定E2Eファイル - `logic`プロジェクト: `src/**/*.test.ts` のユニットテスト - `ui`プロジェクト: Storybook + Playwright(ブラウザモード)でのインタラクションテスト +## 実装のルール +- 実装完了後、`pnpm lint`, `pnpm type-check`, `pnpm test` を実行すること +- 上記完了後、`/simplify`を実行してリファクタを行うこと + ### 環境変数 - `.env`ファイルはGoogle Drive(`/g/マイドライブ/80_環境変数/yps-crispy-carnival/`)にシンボリックリンク From 3ea4ef900a5233753183842937f7d461ffdd4845 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 14:15:44 +0900 Subject: [PATCH 015/176] =?UTF-8?q?fix:=20ShiftForm=E3=81=AE=E3=83=89?= =?UTF-8?q?=E3=83=A9=E3=83=83=E3=82=B0=E6=93=8D=E4=BD=9C=E6=99=82=E3=81=AB?= =?UTF-8?q?=E3=82=AB=E3=83=BC=E3=82=BD=E3=83=AB=E3=81=8Ccrosshair=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E3=82=8F=E3=82=8B=E6=8C=99=E5=8B=95=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts index c54072f8..b63f16ce 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts @@ -238,7 +238,7 @@ export const useDrag = (): UseDragReturn => { return "ew-resize"; } if (dragState.mode === "paint") { - return "crosshair"; + return "default"; } return "default"; } @@ -258,7 +258,7 @@ export const useDrag = (): UseDragReturn => { // ポジション選択中 if (selectedPosition) { - return "crosshair"; + return "default"; } return "default"; From 3ad799d8005b738408871c0bb0207286c345bc3f Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 14:36:24 +0900 Subject: [PATCH 016/176] =?UTF-8?q?feat:=20=E3=83=9D=E3=82=B8=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=83=90=E3=83=BC=E3=81=AE=E3=83=9B=E3=83=90?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=83=BC=E3=82=BD=E3=83=AB=E3=81=A8Popover?= =?UTF-8?q?=E3=81=AE=E6=99=82=E9=96=93=E9=A0=86=E3=82=BD=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pc/DailyView/ShiftGrid/ShiftBar.tsx | 2 +- .../ShiftForm/pc/DailyView/ShiftPopover.tsx | 51 ++++++++++--------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx index 454d9680..674e994d 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx @@ -80,7 +80,7 @@ export const ShiftBar = ({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={(e) => onClick(shift.id, null, e)} - cursor="inherit" + cursor="pointer" transition="all 0.15s" _hover={{ borderColor: "gray.500" }} zIndex={1} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftPopover.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftPopover.tsx index fd98b601..516e9d2d 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftPopover.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftPopover.tsx @@ -2,6 +2,7 @@ import { Box, Button, Flex, IconButton, Popover, Portal, Text } from "@chakra-ui import { useEffect } from "react"; import { LuMinus, LuTrash2, LuX } from "react-icons/lu"; import type { ShiftData } from "../../types"; +import { timeToMinutes } from "../../utils/timeConversion"; type ShiftPopoverProps = { shift: ShiftData | null; @@ -106,31 +107,33 @@ export const ShiftPopover = ({ maxH="200px" overflowY="auto" > - {shift.positions.map((pos) => ( - - - - - {pos.positionName} - - - {pos.start}-{pos.end} - + {[...shift.positions] + .sort((a, b) => timeToMinutes(a.start) - timeToMinutes(b.start)) + .map((pos) => ( + + + + + {pos.positionName} + + + {pos.start}-{pos.end} + + + {!isReadOnly && ( + onDeletePosition(pos.id)} + _hover={{ color: "red.500" }} + > + + + )} - {!isReadOnly && ( - onDeletePosition(pos.id)} - _hover={{ color: "red.500" }} - > - - - )} - - ))} + ))} )} From d99278e6d928f840ebcac87fbeaf33dd676a532a Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 14:36:32 +0900 Subject: [PATCH 017/176] =?UTF-8?q?fix:=20=E3=83=89=E3=83=A9=E3=83=83?= =?UTF-8?q?=E3=82=B0=E6=93=8D=E4=BD=9C=E5=BE=8C=E3=81=AE=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E3=83=9D=E3=82=B8=E3=82=B7=E3=83=A7=E3=83=B3=E9=9A=A3=E6=8E=A5?= =?UTF-8?q?=E3=82=BB=E3=82=B0=E3=83=A1=E3=83=B3=E3=83=88=E3=81=8C=E7=B5=B1?= =?UTF-8?q?=E5=90=88=E3=81=95=E3=82=8C=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts | 13 +++++++------ .../features/Shift/ShiftForm/pc/DailyView/index.tsx | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts index b63f16ce..9cfdeecc 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/hooks/useDrag.ts @@ -6,6 +6,7 @@ import type { DragMode, LinkedResizeTarget, ShiftData } from "../../../types"; import { detectLinkedResizeEdge, findShiftAtPosition, + mergeAdjacentPositions, paintPosition, resizeLinkedPositions, resizePosition, @@ -190,8 +191,8 @@ export const useDrag = (): UseDragReturn => { newMinutes: currentMinutes, minDuration: timeRange.unit, }); - - const updatedShifts = shifts.map((s) => (s.id === targetShiftId ? resizedShift : s)); + const mergedShift = { ...resizedShift, positions: mergeAdjacentPositions(resizedShift.positions) }; + const updatedShifts = shifts.map((s) => (s.id === targetShiftId ? mergedShift : s)); setShifts(updatedShifts); } else if (targetShift && targetPositionId && resizeEdge) { const resizedShift = resizePosition({ @@ -201,8 +202,8 @@ export const useDrag = (): UseDragReturn => { newMinutes: currentMinutes, minDuration: timeRange.unit, }); - - const updatedShifts = shifts.map((s) => (s.id === targetShiftId ? resizedShift : s)); + const mergedShift = { ...resizedShift, positions: mergeAdjacentPositions(resizedShift.positions) }; + const updatedShifts = shifts.map((s) => (s.id === targetShiftId ? mergedShift : s)); setShifts(updatedShifts); } } @@ -220,8 +221,8 @@ export const useDrag = (): UseDragReturn => { endMinutes: currentMinutes, segmentId: generateId(), }); - - const updatedShifts = shifts.map((s) => (s.id === targetShiftId ? paintedShift : s)); + const mergedShift = { ...paintedShift, positions: mergeAdjacentPositions(paintedShift.positions) }; + const updatedShifts = shifts.map((s) => (s.id === targetShiftId ? mergedShift : s)); setShifts(updatedShifts); } } diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx index 1e6ced63..c7b0a109 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx @@ -6,6 +6,7 @@ import { useDialog } from "@/src/components/ui/Dialog"; import { useDateStatuses } from "../../hooks/useDateStatuses"; import { selectedDateAtom, selectedPositionIdAtom, shiftConfigAtom, shiftsAtom } from "../../stores"; import type { ShiftData } from "../../types"; +import { mergeAdjacentPositions } from "../../utils/shiftOperations"; import { DateTabs } from "./DateTabs"; import { PositionToolbar } from "./PositionToolbar"; import { ShiftGrid } from "./ShiftGrid"; @@ -57,7 +58,8 @@ export const DailyView = () => { const handleDeletePosition = useCallback( (positionId: string) => { if (!popoverShift) return; - const updatedShift = { ...popoverShift, positions: popoverShift.positions.filter((p) => p.id !== positionId) }; + const filteredPositions = popoverShift.positions.filter((p) => p.id !== positionId); + const updatedShift = { ...popoverShift, positions: mergeAdjacentPositions(filteredPositions) }; const newShifts = shifts.map((s) => (s.id === popoverShift.id ? updatedShift : s)); setShifts(newShifts); if (updatedShift.positions.length === 0) { From b40e956318ff5fa1e1c84f8f32c09e227e8254b6 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 14:37:05 +0900 Subject: [PATCH 018/176] =?UTF-8?q?chore:=20SummaryFooterRow=E3=81=AE?= =?UTF-8?q?=E3=83=A9=E3=83=99=E3=83=AB=E3=82=92=E3=80=8C=E5=87=BA=E5=8B=A4?= =?UTF-8?q?=E6=95=B0=E3=80=8D=E3=81=8B=E3=82=89=E3=80=8C=E4=BA=BA=E6=95=B0?= =?UTF-8?q?=E3=80=8D=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx index 2522de27..b38d6fa2 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx @@ -89,7 +89,7 @@ export const SummaryFooterRow = ({ shifts, dates, months, requiredStaffing }: Su > - 出勤数 + 人数 {hasRequiredStaffing && ( Date: Tue, 24 Mar 2026 14:44:56 +0900 Subject: [PATCH 019/176] =?UTF-8?q?fix:=20=E3=83=9D=E3=82=B8=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E8=89=B2=E3=83=90=E3=83=BC=E3=81=AE=E3=83=9B?= =?UTF-8?q?=E3=83=90=E3=83=BC=E6=99=82=E3=81=ABcursor:=20pointer=E3=81=8C?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=95=E3=82=8C=E3=81=AA=E3=81=84=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx index 674e994d..65397094 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx @@ -166,7 +166,7 @@ export const ShiftBar = ({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={(e) => onClick(shift.id, pos.id, e)} - cursor="inherit" + cursor="pointer" transition={isResizing ? "width 0.05s ease-out, left 0.05s ease-out" : "all 0.15s"} opacity={0.9} _hover={{ opacity: 1 }} From 801b4baf99e4f159d0c90106e1c6544968c34bc5 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 14:51:56 +0900 Subject: [PATCH 020/176] =?UTF-8?q?feat:=20ShiftForm=E6=97=A5=E5=88=A5?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AEUI=E6=94=B9=E5=96=84?= =?UTF-8?q?=EF=BC=88=E4=BA=8C=E9=87=8D=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88?= =?UTF-8?q?=E5=89=8A=E9=99=A4=E3=83=BB=E3=83=9B=E3=83=90=E3=83=BC=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=E3=83=BB=E6=99=82=E5=88=BB=E3=83=A9=E3=83=99=E3=83=AB?= =?UTF-8?q?=E8=A6=96=E8=AA=8D=E6=80=A7=E3=83=BB=E3=82=BF=E3=83=96=E5=BC=B7?= =?UTF-8?q?=E8=AA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Shift/ShiftForm/pc/DailyView/DateTabs.tsx | 8 +++++++- .../ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx | 14 +++++++------- .../ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx | 11 +---------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx index bb267f26..b9594dc6 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx @@ -58,7 +58,13 @@ export const DateTabs = ({ dates, selectedDate, onSelect, holidays = [], dateSta const status = dateStatuses?.get(date) ?? "none"; return ( - + {formatDate(date)} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx index 65397094..32ff8ac7 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx @@ -169,7 +169,7 @@ export const ShiftBar = ({ cursor="pointer" transition={isResizing ? "width 0.05s ease-out, left 0.05s ease-out" : "all 0.15s"} opacity={0.9} - _hover={{ opacity: 1 }} + _hover={{ opacity: 1, boxShadow: "0 0 0 2px rgba(0,0,0,0.15)" }} zIndex={2} /> ); @@ -231,11 +231,11 @@ export const ShiftBar = ({ top="50%" transform="translateY(-50%)" fontSize="xs" - color="gray.600" - fontWeight="medium" + color="gray.700" + fontWeight="semibold" zIndex={3} pointerEvents="none" - textShadow="0 0 2px white, 0 0 2px white" + textShadow="0 0 3px white, 0 0 3px white, 0 0 6px white" > {earliestStart} @@ -245,11 +245,11 @@ export const ShiftBar = ({ top="50%" transform="translate(-100%, -50%)" fontSize="xs" - color="gray.600" - fontWeight="medium" + color="gray.700" + fontWeight="semibold" zIndex={3} pointerEvents="none" - textShadow="0 0 2px white, 0 0 2px white" + textShadow="0 0 3px white, 0 0 3px white, 0 0 6px white" > {latestEnd} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx index 8bf96239..c7509b8d 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/StaffRow.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, Table, Text } from "@chakra-ui/react"; +import { Box, Table, Text } from "@chakra-ui/react"; import type { DragMode, LinkedResizeTarget, ShiftData, StaffType, TimeRange } from "../../../types"; import { DragPreview } from "./DragPreview"; import { GridLines } from "./GridLines"; @@ -137,15 +137,6 @@ export const StaffRow = ({ /> ))} - {/* 空状態テキスト */} - {status !== "has_request" && staffShifts.every((s) => s.positions.length === 0) && ( - - - {status === "no_request" ? "希望なし" : "未提出"} - - - )} - {/* ドラッグプレビュー */} {isDragging && dragState.staffId === staff.id && From cfb1b73f0340a415b727cc695105f66dde59f52e Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 15:08:58 +0900 Subject: [PATCH 021/176] =?UTF-8?q?feat:=20ShiftForm=E3=81=AEUI=E5=85=A8?= =?UTF-8?q?=E4=BD=93=E6=94=B9=E5=96=84=EF=BC=88=E5=85=85=E8=B6=B3=E5=BA=A6?= =?UTF-8?q?=E3=83=90=E3=83=BC=E6=9C=AA=E7=9D=80=E6=89=8B=E3=82=B0=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E5=8C=96=E3=83=BB=E4=B8=80=E8=A6=A7=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=83=BC=E3=83=B3=E3=83=80=E3=82=A6=E3=83=B3?= =?UTF-8?q?=E3=83=BB=E3=82=AC=E3=82=A4=E3=83=89=E3=83=90=E3=83=BC=E3=82=A2?= =?UTF-8?q?=E3=83=8B=E3=83=A1=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=BB?= =?UTF-8?q?=E3=83=84=E3=83=BC=E3=83=AB=E3=83=90=E3=83=BC=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E8=89=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx | 2 +- .../Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx | 11 ++++++++--- .../Shift/ShiftForm/pc/DailyView/SummaryRow.tsx | 3 +++ .../Shift/ShiftForm/pc/OverviewView/StaffRow.tsx | 10 +++++----- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx index be3dbfbb..498dfff3 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx @@ -9,7 +9,7 @@ type PositionToolbarProps = { export const PositionToolbar = ({ positions, selectedPositionId, onPositionSelect }: PositionToolbarProps) => { return ( - + diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx index c9185e91..06985b88 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/index.tsx @@ -176,17 +176,22 @@ export const ShiftGrid = ({ onShiftClick, onStaffNameClick, onPaintClickPopover return ( - {/* 空状態ガイド */} - {!isReadOnly && !hasAnyPositions && ( + {/* 空状態ガイド(レイアウトシフト防止のため常にレンダリング) */} + {!isReadOnly && ( diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx index 1cf5fafa..c9d730d6 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx @@ -47,8 +47,11 @@ const generateTimeSlots = (timeRange: TimeRange): string[] => { return slots; }; +const UNASSIGNED_COLOR = { bg: "hsl(0, 0%, 85%)", text: "hsl(0, 0%, 55%)" } as const; + const getFillRateColor = (count: number, required: number) => { if (required === 0) return FILL_RATE_COLORS[4]; + if (count === 0) return UNASSIGNED_COLOR; const ratio = count / required; if (ratio > 1) return FILL_RATE_COLORS[5]; if (ratio > 0.8) return FILL_RATE_COLORS[4]; diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/StaffRow.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/StaffRow.tsx index 74e5d05e..06dfc427 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/StaffRow.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/StaffRow.tsx @@ -10,7 +10,7 @@ import { MonthSummaryCell } from "./MonthSummaryCell"; * 未提出スタッフは薄い赤背景で強調 */ const getDateCellBg = (date: string, holidays: string[], isUnsubmitted: boolean, hasShift: boolean) => { - if (isUnsubmitted && !hasShift) return "red.50"; + if (isUnsubmitted && !hasShift) return "gray.50"; if (isSunday(date) || isHoliday(date, holidays)) return "red.50"; if (isSaturday(date)) return "blue.50"; return "white"; @@ -25,7 +25,7 @@ const getDateCellBg = (date: string, holidays: string[], isUnsubmitted: boolean, const getShiftCellDisplay = (shift: DailyShift | null | undefined, isSubmitted: boolean) => { if (!isSubmitted) { if (shift) return { label: `${shift.start}-${shift.end}`, color: "orange.400" }; - return { label: "未", color: "orange.400" }; + return { label: "-", color: "gray.300" }; } if (shift) return { label: `${shift.start}-${shift.end}`, color: "gray.800" }; return { label: "休", color: "gray.400" }; @@ -43,7 +43,7 @@ export const StaffRow = ({ const { staffId, staffName, isSubmitted, dailyShifts, monthlyTotals, alerts } = data; const isUnsubmitted = !isSubmitted; - const staffCellBg = isHighlighted ? "blue.50" : isUnsubmitted ? "red.50" : "white"; + const staffCellBg = isHighlighted ? "blue.50" : "white"; return ( onDateClick?.(date)} > From c321351acb319ab9d61c55d5b56754d51b1dd5b2 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 15:53:15 +0900 Subject: [PATCH 022/176] =?UTF-8?q?feat:=20ShiftForm=E3=81=AE=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E5=88=87=E6=9B=BF=E3=81=A8=E3=83=9D=E3=82=B8?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E9=81=B8=E6=8A=9E=E3=82=92=E7=B5=B1?= =?UTF-8?q?=E5=90=88=E3=83=84=E3=83=BC=E3=83=AB=E3=83=90=E3=83=BC=E3=81=AB?= =?UTF-8?q?=E9=9B=86=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ビュー切替(日別/一覧)とポジションボタンを1本のツールバーにまとめ、 視覚的な散漫さを解消。日付タブのbold削除・ツールバー高さ固定で レイアウトシフトも防止。一覧ビューのborderRadiusもlgに統一。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/Shift/ShiftForm/index.tsx | 49 +++++++++++----- .../Shift/ShiftForm/pc/DailyView/DateTabs.tsx | 8 +-- .../pc/DailyView/PositionToolbar.tsx | 57 ++++++++----------- .../Shift/ShiftForm/pc/DailyView/index.tsx | 19 +------ .../Shift/ShiftForm/pc/OverviewView/index.tsx | 2 +- 5 files changed, 65 insertions(+), 70 deletions(-) diff --git a/src/components/features/Shift/ShiftForm/index.tsx b/src/components/features/Shift/ShiftForm/index.tsx index 2fe69def..efe41b06 100644 --- a/src/components/features/Shift/ShiftForm/index.tsx +++ b/src/components/features/Shift/ShiftForm/index.tsx @@ -3,10 +3,11 @@ import { Provider, useAtom, useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; import { useShiftFormInit } from "./hooks/useShiftFormInit"; import { DailyView } from "./pc/DailyView"; +import { PositionToolbar } from "./pc/DailyView/PositionToolbar"; import { OverviewView } from "./pc/OverviewView"; import { SPDailyView } from "./sp/DailyView"; import { SPOverviewView } from "./sp/OverviewView"; -import { shiftsAtom, viewModeAtom } from "./stores"; +import { selectedPositionIdAtom, shiftConfigAtom, shiftsAtom, viewModeAtom } from "./stores"; import type { PositionType, RequiredStaffingData, ShiftData, SortMode, StaffType, TimeRange, ViewMode } from "./types"; const VIEW_OPTIONS = [ @@ -14,11 +15,6 @@ const VIEW_OPTIONS = [ { value: "overview", label: "一覧" }, ]; -const VIEW_OPTIONS_PC = [ - { value: "daily", label: "日別" }, - { value: "overview", label: "一覧" }, -]; - type ShiftFormProps = { shopId: string; staffs: StaffType[]; @@ -81,6 +77,12 @@ const ShiftFormInner = ({ }, [shifts]); const [viewMode, setViewMode] = useAtom(viewModeAtom); + const config = useAtomValue(shiftConfigAtom); + const [selectedPositionId, setSelectedPositionId] = useAtom(selectedPositionIdAtom); + + const showViewSwitcher = !hideViewSwitcher; + const showPositionButtons = !config.isReadOnly && viewMode === "daily"; + const showToolbar = showViewSwitcher || showPositionButtons; return ( @@ -96,13 +98,34 @@ const ShiftFormInner = ({ )} - {/* PC ヘッダー: SegmentGroup */} - {!hideViewSwitcher && ( - - setViewMode(e.value as ViewMode)}> - - - + {/* PC ツールバー: ビュー切替 + ポジション */} + {showToolbar && ( + + + {showViewSwitcher && ( + setViewMode(e.value as ViewMode)}> + + + + )} + {showPositionButtons && ( + + )} + )} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx index b9594dc6..724aed02 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/DateTabs.tsx @@ -58,13 +58,7 @@ export const DateTabs = ({ dates, selectedDate, onSelect, holidays = [], dateSta const status = dateStatuses?.get(date) ?? "none"; return ( - + {formatDate(date)} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx index 498dfff3..2acda686 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Flex, Text } from "@chakra-ui/react"; +import { Box, Button, Flex } from "@chakra-ui/react"; import type { PositionType } from "../../types"; type PositionToolbarProps = { @@ -9,37 +9,28 @@ type PositionToolbarProps = { export const PositionToolbar = ({ positions, selectedPositionId, onPositionSelect }: PositionToolbarProps) => { return ( - - - - - ポジション - - - {positions.map((position) => { - const isSelected = selectedPositionId === position.id; - return ( - - ); - })} - - - - + + {positions.map((position) => { + const isSelected = selectedPositionId === position.id; + return ( + + ); + })} + ); }; diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx index c7b0a109..6030875a 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/index.tsx @@ -1,14 +1,13 @@ -import { Box, Flex } from "@chakra-ui/react"; +import { Flex } from "@chakra-ui/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useState } from "react"; import { StaffEditModal } from "@/src/components/features/Staff/StaffEditModal"; import { useDialog } from "@/src/components/ui/Dialog"; import { useDateStatuses } from "../../hooks/useDateStatuses"; -import { selectedDateAtom, selectedPositionIdAtom, shiftConfigAtom, shiftsAtom } from "../../stores"; +import { selectedDateAtom, shiftConfigAtom, shiftsAtom } from "../../stores"; import type { ShiftData } from "../../types"; import { mergeAdjacentPositions } from "../../utils/shiftOperations"; import { DateTabs } from "./DateTabs"; -import { PositionToolbar } from "./PositionToolbar"; import { ShiftGrid } from "./ShiftGrid"; import { ShiftPopover } from "./ShiftPopover"; @@ -16,10 +15,9 @@ export const DailyView = () => { const config = useAtomValue(shiftConfigAtom); const shifts = useAtomValue(shiftsAtom); const setShifts = useSetAtom(shiftsAtom); - const [selectedPositionId, setSelectedPositionId] = useAtom(selectedPositionIdAtom); const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom); - const { positions, dates, isReadOnly, shopId, holidays } = config; + const { dates, isReadOnly, shopId, holidays } = config; const dateStatuses = useDateStatuses(); // === スタッフ編集モーダル === @@ -88,17 +86,6 @@ export const DailyView = () => { return ( - {/* ポジションツールバー(閲覧専用時は非表示) */} - {!isReadOnly && ( - - - - )} - {/* 日付タブ + シフト表 */} { return ( <> - + Date: Tue, 24 Mar 2026 15:53:19 +0900 Subject: [PATCH 023/176] =?UTF-8?q?docs:=20CLAUDE.md=E3=81=AE=E3=83=87?= =?UTF-8?q?=E3=82=B6=E3=82=A4=E3=83=B3=E3=82=BB=E3=82=AF=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=82=92doc/design/=E6=A7=8B=E6=88=90=E3=81=AB?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef706563..1294df06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,8 +116,10 @@ import { bar } from "@/convex/..."; ## デザイン -- `design.pen`: UIデザインファイル。Pencil MCPツール経由で読み書きする(`Read`や`Grep`では読めない) +- `doc/design/`: デザインファイル格納ディレクトリ。`.pen`ファイルはPencil MCPツール経由で読み書きする(`Read`や`Grep`では読めない) - デザイン確認・編集には `batch_get`、`batch_design`、`get_screenshot` 等のPencil MCPツールを使用 +- `doc/design/INDEX.md`: .penファイルのフレームIDインデックス。参照時はまずここのIDで `batch_get(nodeIds=[...])` を使い、IDが無効な場合は `batch_get(patterns=[{name: "..."}])` でフォールバックする +- デザインフレームを追加・削除した際は `doc/design/INDEX.md` のIDを更新すること ## コーディング From 9e1a62667e48bd68b94e4147150f820531c5ff19 Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 20:39:47 +0900 Subject: [PATCH 024/176] f --- .claude/skills/design-prompt/SKILL.md | 112 + .gitignore | 2 +- convex/requiredStaffing/mutations.ts | 1 - convex/schema.ts | 1 - doc/design/INDEX.md | 34 + doc/design/StuffingRequirements.pen | 5221 +++++++++++++++ doc/design/common.pen | 5952 +++++++++++++++++ ...44\343\203\263\344\276\235\351\240\274.md" | 106 + .../PeakBandSettings/ModeConfirmDialog.tsx | 44 + .../Shift/PeakBandSettings/index.stories.tsx | 86 + .../features/Shift/PeakBandSettings/index.tsx | 610 +- .../ShiftForm/pc/DailyView/PeakBandAlert.tsx | 4 +- .../pc/DailyView/ShiftGrid/ShiftBar.tsx | 9 +- .../ShiftForm/pc/DailyView/ShiftPopover.tsx | 21 +- .../sp/DailyView/ShiftDetailSheet.tsx | 23 +- .../ShiftForm/sp/DailyView/ShiftEditSheet.tsx | 24 +- .../features/Shift/ShiftForm/types.ts | 1 - .../ShiftForm/utils/staffingAlerts.test.ts | 6 +- .../Shift/ShiftForm/utils/staffingAlerts.ts | 2 - .../Shift/StaffingRequirement/constants.ts | 4 + .../Shops/StaffingSettingsPage/index.tsx | 44 +- 21 files changed, 12070 insertions(+), 237 deletions(-) create mode 100644 .claude/skills/design-prompt/SKILL.md create mode 100644 doc/design/INDEX.md create mode 100644 doc/design/StuffingRequirements.pen create mode 100644 doc/design/common.pen create mode 100644 "doc/design/\345\277\205\350\246\201\344\272\272\345\223\241\350\250\255\345\256\232_\343\203\207\343\202\266\343\202\244\343\203\263\344\276\235\351\240\274.md" create mode 100644 src/components/features/Shift/PeakBandSettings/ModeConfirmDialog.tsx create mode 100644 src/components/features/Shift/PeakBandSettings/index.stories.tsx diff --git a/.claude/skills/design-prompt/SKILL.md b/.claude/skills/design-prompt/SKILL.md new file mode 100644 index 00000000..f544ff6a --- /dev/null +++ b/.claude/skills/design-prompt/SKILL.md @@ -0,0 +1,112 @@ +--- +name: design-prompt +description: pencil.dev向けのデザインプロンプトを生成するスキル。画面の要件・機能仕様を整理し、デザインツールに投げるための構造化されたプロンプトを日本語で出力する。「デザイン依頼」「デザインプロンプト作って」「pencilに投げたい」「画面デザイン作りたい」「UIデザインのプロンプト」などのトリガーで発動。新しい画面を作るとき、既存画面を改善するとき、デザイナーに要件を伝えるときにも使える。 +--- + +# Design Prompt Generator for pencil.dev + +pencil.devに投げるための構造化されたデザインプロンプトを生成する。 +ユーザーの口頭説明・既存コード・仕様ドキュメントからインプットを収集し、pencil.devが理解しやすい形式で出力する。 + +## ワークフロー + +### Step 1: インプットの収集 + +以下の3つのソースから情報を集める。すべて揃う必要はなく、利用可能なものを使う。 + +1. **ユーザーの説明**: どんな画面か、何ができるか、誰が使うか +2. **既存コード**: 関連するコンポーネントがあれば読んで、要素・状態・インタラクションを抽出する +3. **仕様ドキュメント**: `doc/features/` 配下に関連するドキュメントがあれば読む + +情報が足りない場合は、1つずつ質問して補完する。特に以下は必ず確認する: +- **画面の目的**: この画面で何を達成するのか +- **対象デバイス**: PC / SP / 両方 +- **ユーザー像**: 誰が使うのか(ITリテラシーの想定含む) + +### Step 2: デザイントークンの取得 + +`doc/design/INDEX.md` を読み、利用可能なデザイントークン・レイアウトパターンのIDを確認する。 +該当する.penファイル(通常は `doc/design/common.pen`)がエディタで開かれていれば、`batch_get` でデザイントークンを取得してプロンプトに含める。 + +開かれていない場合は、INDEX.mdの情報をベースにトークンの参照先を記載する。 + +### Step 3: プロンプトの生成 + +以下のテンプレート構造に沿ってプロンプトを生成する。 + +--- + +## 出力テンプレート + +```markdown +# [画面名] デザイン依頼 + +## コンポーネントフレームワーク +Chakra UI V3 + +## この画面の目的 +[1〜2文で画面の目的を簡潔に記述] + +## 前提情報 +- **ユーザー**: [誰が使うか + ITリテラシーの想定] +- **対応デバイス**: [PC / SP / 両方] +- **関連する既存画面**: [あれば記載] +- [その他、デザインに影響する前提条件] + +## 画面構成 + +### [セクション名1] +- [要素と配置の説明] +- [操作方法] + +### [セクション名2] +... + +## 補助機能 +[メインの画面構成以外の補助的な機能があれば記載] + +## データの具体例 +[抽象的な「テキスト」ではなく、リアルなサンプルデータを記載] +[テーブルやリストがある場合はコードブロックで具体例を示す] + +## 状態バリエーション +- **通常時**: [デフォルトの状態] +- **空の状態**: [データがないとき] +- **ローディング**: [読み込み中] +- **エラー時**: [エラー発生時] +[必要に応じて追加: 選択中、編集中、確認ダイアログ等] + +## SP版での考慮点 +[SP対応が必要な場合のみ記載] +- [レイアウトの違い] +- [操作方法の違い(タップ、スワイプ等)] +- [コンポーネントの置き換え(Dialog → BottomSheet等)] + +## デザイントークン +[INDEX.mdまたはcommon.penから取得した情報を記載] +- カラー: [プライマリ、セカンダリ等] +- タイポグラフィ: [見出し、本文等] +- スペーシング: [余白の基本単位] + +## 共通レイアウトパターン +[該当するパターンを記載] +- [SideMenu / BottomMenu / FormCard / Dialog / BottomSheet 等] + +## デザインで特に考えてほしいポイント +1. [最も重要なデザイン課題] +2. [次に重要な課題] +3. [その他の考慮点] +``` + +--- + +## テンプレート運用のガイドライン + +- **すべてのセクションを埋める必要はない**: 画面に該当しないセクション(例: SP対応不要なら「SP版での考慮点」)は省略する +- **具体例を重視する**: 抽象的な説明より、実際のデータ例(名前、時刻、数値)を入れる方がデザインの精度が上がる +- **「考えてほしいポイント」が肝**: ここでデザイナー(AI)に判断を委ねる部分を明示する。単なる配置指示ではなく「なぜ悩んでいるか」を伝えると良い結果が出やすい +- **ユーザー像を具体的に**: 「ITリテラシーは高くない想定」のような一文があるだけで、デザインの複雑さの判断基準になる + +## 出力方法 + +生成したプロンプトはマークダウンのコードブロックで囲んで出力する。ユーザーがそのままコピペしてpencil.devに貼れるようにする。 diff --git a/.gitignore b/.gitignore index 92843f90..defe839d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ convex-seeds/backup .serena -.claude/settings.local.json +settings.local.json .mcp.json CLAUDE.local.md diff --git a/convex/requiredStaffing/mutations.ts b/convex/requiredStaffing/mutations.ts index c23f4912..068ed431 100644 --- a/convex/requiredStaffing/mutations.ts +++ b/convex/requiredStaffing/mutations.ts @@ -133,7 +133,6 @@ export const upsertPeakBands = mutation({ dayOfWeek: v.number(), peakBands: v.array( v.object({ - name: v.string(), startTime: v.string(), endTime: v.string(), requiredCount: v.number(), diff --git a/convex/schema.ts b/convex/schema.ts index a8a88476..dd7315ee 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -109,7 +109,6 @@ const requiredStaffing = defineTable({ peakBands: v.optional( v.array( v.object({ - name: v.string(), // "ランチ", "ディナー" startTime: v.string(), // "11:00" endTime: v.string(), // "14:00" requiredCount: v.number(), // 必要人数 diff --git a/doc/design/INDEX.md b/doc/design/INDEX.md new file mode 100644 index 00000000..62d09f8b --- /dev/null +++ b/doc/design/INDEX.md @@ -0,0 +1,34 @@ +# Design File Index + +.pen ファイルのフレームIDインデックス。Pencil MCP の `batch_get(nodeIds=[...])` で直接参照するために使用。 + +## ShiftEditForm.pen + +| フレーム名 | ID | 説明 | +|---|---|---| +| Design Tokens | M4J3C | カラー、タイポグラフィ、スペーシング等のデザイントークン | +| Layout Patterns | zFJId | SideMenu, BottomMenu, FormCard等の共通レイアウトパターン | + +### Layout Patterns 内の主要セクション + +| セクション名 | ID | 説明 | +|---|---|---| +| SideMenu Pattern | pdu7B | PCサイドバーナビゲーション | +| BottomMenu Pattern | 5HE5z | モバイルボトムナビゲーション | +| FormCard Pattern | 5XHpj | フォームセクションカード | +| Dialog Pattern | 12uf9 | モーダルダイアログ | +| BottomSheet Pattern | 3nUkp | ボトムシート | +| Title Patterns | B86lz | ページタイトル + パンくずリスト | +| State Patterns | jxFEP | Empty State / Loading State | +| Select & Toast Patterns | beZeb | フォーム要素とフィードバック | +| ColorPicker Pattern | zReVL | ポジション色選択 | + +## StuffingRequirements.pen + +| フレーム名 | ID | 説明 | +|---|---|---| +| PC版 - かんたんモード | 2gNWx | 必要人員設定 かんたんモード(平日タブ選択) | +| PC版 - 詳細モード | 9QbAs | 必要人員設定 詳細モード(金曜タブ選択) | +| SP版 - かんたんモード | ChpMV | 必要人員設定 SP版 かんたんモード | +| SP版 - 詳細モード | uPbj7 | 必要人員設定 SP版 詳細モード | +| 確認ダイアログ | Y3Dxe | 詳細→かんたんモード切替時の確認ダイアログ | diff --git a/doc/design/StuffingRequirements.pen b/doc/design/StuffingRequirements.pen new file mode 100644 index 00000000..1b61653d --- /dev/null +++ b/doc/design/StuffingRequirements.pen @@ -0,0 +1,5221 @@ +{ + "version": "2.9", + "children": [ + { + "type": "frame", + "id": "2gNWx", + "x": 0, + "y": 0, + "name": "PC版 - かんたんモード", + "clip": true, + "width": 960, + "height": 745, + "fill": "$gray-50", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "6ugQd", + "name": "contentWrap1", + "width": "fill_container", + "fill": "$gray-50", + "layout": "vertical", + "gap": 24, + "padding": [ + 32, + 40 + ], + "children": [ + { + "type": "text", + "id": "Q6TEi", + "name": "breadcrumb1", + "fill": "$gray-500", + "content": "シフト管理 > 必要人員設定", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "J0KC9", + "name": "headerRow1", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "jv1vy", + "name": "title1", + "fill": "$gray-900", + "content": "必要人員設定", + "fontFamily": "Inter", + "fontSize": "$font-2xl", + "fontWeight": "700" + }, + { + "type": "text", + "id": "YvqKz", + "name": "subtitle1", + "fill": "$gray-500", + "content": "渋谷センター店", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "2emdU", + "name": "modeSection1", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "N7wcB", + "name": "modeToggle1", + "height": 40, + "fill": "$gray-100", + "cornerRadius": "$radius-lg", + "padding": 4, + "children": [ + { + "type": "frame", + "id": "RPRYu", + "name": "modeActive1", + "height": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-md", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 4 + }, + "padding": [ + 0, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sKxzF", + "name": "modeActiveText1", + "fill": "$teal-700", + "content": "かんたんモード", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "rNKuQ", + "name": "modeInactive1", + "height": "fill_container", + "cornerRadius": "$radius-md", + "padding": [ + 0, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pU3Gb", + "name": "modeInactiveText1", + "fill": "$gray-500", + "content": "詳細モード", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "P9rAT", + "name": "modeHint1", + "fill": "$gray-400", + "content": "平日・休日の2パターンでかんたんに設定できます", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sJ9BP", + "name": "tabRow1", + "width": "fill_container", + "height": 40, + "children": [ + { + "type": "frame", + "id": "mwY55", + "name": "tabActive1", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 2 + }, + "fill": "$teal-600" + }, + "padding": [ + 0, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "puA6W", + "name": "tabActiveText1", + "fill": "$teal-600", + "content": "平日", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "Ebqmn", + "name": "tabInactive1", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "padding": [ + 0, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "1xb2i", + "name": "tabInactiveText1", + "fill": "$gray-500", + "content": "休日", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Ce0AJ", + "name": "tabDivider1", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + } + } + ] + }, + { + "type": "frame", + "id": "dNgDz", + "name": "cardsSection1", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "MhJrb", + "name": "lunchCard1", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-xl", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RXlOo", + "name": "lunchTimeWrap1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YgY3w", + "name": "lunchTimeLabel1", + "fill": "$gray-400", + "content": "時間帯", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "fIwrE", + "name": "lunchTimeRow1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "igHyC", + "name": "lunchStartInput1", + "width": 80, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UW94q", + "name": "lunchStartText1", + "fill": "$gray-900", + "content": "11:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "Jn2Gb", + "name": "lunchTilde1", + "fill": "$gray-500", + "content": "〜", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "uCvkd", + "name": "lunchEndInput1", + "width": 80, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HQ269", + "name": "lunchEndText1", + "fill": "$gray-900", + "content": "14:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "CQPG9", + "name": "lunchStaffWrap1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WNDLj", + "name": "lunchStaffLabel1", + "fill": "$gray-400", + "content": "必要人数", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "3fWnp", + "name": "lunchStaffRow1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rRnGJ", + "name": "lunchStaffInput1", + "width": 60, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "4wvcs", + "name": "lunchStaffNum1", + "fill": "$gray-900", + "content": "3", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "XhzBj", + "name": "lunchStaffUnit1", + "fill": "$gray-500", + "content": "人", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "aeEhE", + "name": "lunchSpacer1", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "ldwRe", + "name": "lunchDeleteBtn1", + "width": 36, + "height": 36, + "cornerRadius": "$radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "vO3NO", + "name": "lunchDeleteIcon1", + "width": 18, + "height": 18, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$red-500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "hhT8V", + "name": "dinnerCard1", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-xl", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "WSQga", + "name": "lunchTimeWrap1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ctIKt", + "name": "lunchTimeLabel1", + "fill": "$gray-400", + "content": "時間帯", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ZsjsF", + "name": "lunchTimeRow1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "SxFZW", + "name": "lunchStartInput1", + "width": 80, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "xSzzz", + "name": "lunchStartText1", + "fill": "$gray-900", + "content": "17:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "ZQnnY", + "name": "lunchTilde1", + "fill": "$gray-500", + "content": "〜", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "PK2lK", + "name": "lunchEndInput1", + "width": 80, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WQnes", + "name": "lunchEndText1", + "fill": "$gray-900", + "content": "21:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "xZ7aZ", + "name": "lunchStaffWrap1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "uLjAd", + "name": "lunchStaffLabel1", + "fill": "$gray-400", + "content": "必要人数", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "3KEuf", + "name": "lunchStaffRow1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "pEifk", + "name": "lunchStaffInput1", + "width": 60, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rRKfO", + "name": "lunchStaffNum1", + "fill": "$gray-900", + "content": "4", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "uZIkj", + "name": "lunchStaffUnit1", + "fill": "$gray-500", + "content": "人", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Gj6gs", + "name": "lunchSpacer1", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "ZepXg", + "name": "lunchDeleteBtn1", + "width": 36, + "height": 36, + "cornerRadius": "$radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "vWmW9", + "name": "lunchDeleteIcon1", + "width": 18, + "height": 18, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$red-500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dN94l", + "name": "addBtnRow1", + "width": "fill_container", + "padding": [ + 8, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "s5Tu6", + "name": "addBtn1", + "height": 40, + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "gap": 8, + "padding": [ + 0, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "BQ7Cr", + "name": "addBtnIcon1", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "xW9i3", + "name": "addBtnText1", + "fill": "$teal-600", + "content": "ピーク帯を追加", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Pok1s", + "name": "minStaffSection1", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-xl", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "gap": 12, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "otUZI", + "name": "minStaffLabel1", + "fill": "$gray-700", + "content": "常に最低", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "oe4aB", + "name": "minStaffInput1", + "width": 60, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rZYqG", + "name": "minStaffNum1", + "fill": "$gray-900", + "content": "2", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "0jjXg", + "name": "minStaffUnit1", + "fill": "$gray-700", + "content": "人を配置", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VVH9b", + "name": "saveBar1", + "width": "fill_container", + "height": 64, + "fill": "$white", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000D", + "offset": { + "x": 0, + "y": -2 + }, + "blur": 12 + }, + "gap": 16, + "padding": [ + 0, + 40 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cwU2c", + "name": "saveWarning1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "2d2Uc", + "name": "saveWarningIcon1", + "width": 16, + "height": 16, + "iconFontName": "triangle-alert", + "iconFontFamily": "lucide", + "fill": "$orange-500" + }, + { + "type": "text", + "id": "tTIS7", + "name": "saveWarningText1", + "fill": "$orange-600", + "content": "未保存の変更があります", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZlXWV", + "name": "saveSpacer1", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "dLU8A", + "name": "saveBtn1", + "height": 40, + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "padding": [ + 0, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Veckw", + "name": "saveBtnText1", + "fill": "$white", + "content": "保存", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "9QbAs", + "x": 1060, + "y": 0, + "name": "PC版 - 詳細モード", + "clip": true, + "width": 960, + "fill": "$gray-50", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "ukcV3", + "name": "contentWrap2", + "width": "fill_container", + "fill": "$gray-50", + "layout": "vertical", + "gap": 24, + "padding": [ + 32, + 40 + ], + "children": [ + { + "type": "text", + "id": "WBvQg", + "name": "breadcrumb2", + "fill": "$gray-500", + "content": "シフト管理 > 必要人員設定", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "DY3fn", + "name": "headerRow2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "dCEJd", + "name": "title2", + "fill": "$gray-900", + "content": "必要人員設定", + "fontFamily": "Inter", + "fontSize": "$font-2xl", + "fontWeight": "700" + }, + { + "type": "text", + "id": "KNLrm", + "name": "subtitle2", + "fill": "$gray-500", + "content": "渋谷センター店", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "47Wzg", + "name": "modeSection2", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "I0ivc", + "name": "modeToggle2", + "height": 40, + "fill": "$gray-100", + "cornerRadius": "$radius-lg", + "padding": 4, + "children": [ + { + "type": "frame", + "id": "ZszRG", + "name": "modeInactive2", + "height": "fill_container", + "cornerRadius": "$radius-md", + "padding": [ + 0, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qfV1g", + "name": "modeInactiveText2", + "fill": "$gray-500", + "content": "かんたんモード", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "WXbmj", + "name": "modeActive2", + "height": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-md", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 4 + }, + "padding": [ + 0, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "viNFD", + "name": "modeActiveText2", + "fill": "$teal-700", + "content": "詳細モード", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "aJ1nQ", + "name": "modeHint2", + "fill": "$gray-400", + "content": "曜日ごとに細かくピーク帯と人数を設定できます", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "1B0mo", + "name": "tabRow2", + "width": "fill_container", + "height": 40, + "children": [ + { + "type": "frame", + "id": "0vfr3", + "name": "tabMon", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "gap": 4, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "Zsovn", + "name": "tabMonDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "cJX7A", + "name": "tabMonText", + "fill": "$gray-500", + "content": "月", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FmuWq", + "name": "tabTue", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "gap": 4, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "8ZW43", + "name": "tabTueDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "srQLl", + "name": "tabTueText", + "fill": "$gray-500", + "content": "火", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "aLNxt", + "name": "tabWed", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "gap": 4, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "IEwzY", + "name": "tabWedDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "aQpYz", + "name": "tabWedText", + "fill": "$gray-500", + "content": "水", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "V3Orp", + "name": "tabThu", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "gap": 4, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "LSgKL", + "name": "tabThuDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "dAUcw", + "name": "tabThuText", + "fill": "$gray-500", + "content": "木", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Oa53v", + "name": "tabFri", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 2 + }, + "fill": "$teal-600" + }, + "gap": 4, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "sZhsE", + "name": "tabFriDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "slQWK", + "name": "tabFriText", + "fill": "$teal-600", + "content": "金", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "MrEpI", + "name": "tabSat", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "gap": 4, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "U4v0v", + "name": "tabSatText", + "fill": "$gray-500", + "content": "土", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JJznK", + "name": "tabSun", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "gap": 4, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "uT0Cg", + "name": "tabSunText", + "fill": "$gray-500", + "content": "日", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GfJ4s", + "name": "tabHol", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "gap": 4, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "E2d6D", + "name": "tabHolText", + "fill": "$gray-500", + "content": "祝", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "3sOK3", + "name": "tabDiv2", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + } + } + ] + }, + { + "type": "frame", + "id": "M1C3K", + "name": "cardsSection2", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "F15ns", + "name": "lunchCard2", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-xl", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "AYSgz", + "name": "lunchTimeWrap1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "fMmO9", + "name": "lunchTimeLabel1", + "fill": "$gray-400", + "content": "時間帯", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "YE7Mt", + "name": "lunchTimeRow1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RCKEk", + "name": "lunchStartInput1", + "width": 80, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NkWB9", + "name": "lunchStartText1", + "fill": "$gray-900", + "content": "11:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "D5DkO", + "name": "lunchTilde1", + "fill": "$gray-500", + "content": "〜", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "7GU86", + "name": "lunchEndInput1", + "width": 80, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Zm73J", + "name": "lunchEndText1", + "fill": "$gray-900", + "content": "14:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "tAj2L", + "name": "lunchStaffWrap1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iW6QX", + "name": "lunchStaffLabel1", + "fill": "$gray-400", + "content": "必要人数", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "knpI7", + "name": "lunchStaffRow1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vzyzC", + "name": "lunchStaffInput1", + "width": 60, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ccGIN", + "name": "lunchStaffNum1", + "fill": "$gray-900", + "content": "4", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "ntdWe", + "name": "lunchStaffUnit1", + "fill": "$gray-500", + "content": "人", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dtczc", + "name": "lunchSpacer1", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "yIZOx", + "name": "lunchDeleteBtn1", + "width": 36, + "height": 36, + "cornerRadius": "$radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "AeHeR", + "name": "lunchDeleteIcon1", + "width": 18, + "height": 18, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$red-500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ByHIW", + "name": "dinnerCard2", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-xl", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "mp5Lx", + "name": "lunchTimeWrap1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ZfXA7", + "name": "lunchTimeLabel1", + "fill": "$gray-400", + "content": "時間帯", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "9RTiK", + "name": "lunchTimeRow1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "xOAiw", + "name": "lunchStartInput1", + "width": 80, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JNeyw", + "name": "lunchStartText1", + "fill": "$gray-900", + "content": "17:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "ARJSp", + "name": "lunchTilde1", + "fill": "$gray-500", + "content": "〜", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "4PToS", + "name": "lunchEndInput1", + "width": 80, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "7b7DW", + "name": "lunchEndText1", + "fill": "$gray-900", + "content": "22:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "208eg", + "name": "lunchStaffWrap1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OOfD6", + "name": "lunchStaffLabel1", + "fill": "$gray-400", + "content": "必要人数", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "688Zx", + "name": "lunchStaffRow1", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fvR9x", + "name": "lunchStaffInput1", + "width": 60, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "oPgFI", + "name": "lunchStaffNum1", + "fill": "$gray-900", + "content": "5", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "Gi4Yk", + "name": "lunchStaffUnit1", + "fill": "$gray-500", + "content": "人", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "wIg5P", + "name": "lunchSpacer1", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "C6qDM", + "name": "lunchDeleteBtn1", + "width": 36, + "height": 36, + "cornerRadius": "$radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "EgN2J", + "name": "lunchDeleteIcon1", + "width": 18, + "height": 18, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$red-500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "9M9dr", + "name": "addBtnRow2", + "width": "fill_container", + "padding": [ + 8, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "U5ADe", + "name": "addBtn2", + "height": 40, + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "gap": 8, + "padding": [ + 0, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "mvw09", + "name": "addBtnIcon2", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "Fe15A", + "name": "addBtnText2", + "fill": "$teal-600", + "content": "ピーク帯を追加", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "sjSXi", + "name": "minStaffSection2", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-xl", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "gap": 12, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "9K32y", + "name": "minStaffLabel2", + "fill": "$gray-700", + "content": "常に最低", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "rH24s", + "name": "minStaffInput2", + "width": 60, + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-300" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "BaJ5a", + "name": "minStaffNum2", + "fill": "$gray-900", + "content": "2", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "uXoO7", + "name": "minStaffUnit2", + "fill": "$gray-700", + "content": "人を配置", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "YCCWg", + "name": "saveBar2", + "width": "fill_container", + "height": 64, + "fill": "$white", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000D", + "offset": { + "x": 0, + "y": -2 + }, + "blur": 12 + }, + "gap": 16, + "padding": [ + 0, + 40 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CeXUA", + "name": "saveWarning2", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "twZnb", + "name": "saveWarningIcon2", + "width": 16, + "height": 16, + "iconFontName": "triangle-alert", + "iconFontFamily": "lucide", + "fill": "$orange-500" + }, + { + "type": "text", + "id": "mg4DV", + "name": "saveWarningText2", + "fill": "$orange-600", + "content": "未保存の変更があります", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "KVgQc", + "name": "saveSpacer2", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "wCcfB", + "name": "saveBtn2", + "height": 40, + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "padding": [ + 0, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PbJiI", + "name": "saveBtnText2", + "fill": "$white", + "content": "保存", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ChpMV", + "x": 2120, + "y": 0, + "name": "SP版 - かんたんモード", + "clip": true, + "width": 390, + "height": 844, + "fill": "$gray-50", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "aMnCn", + "name": "SP Header", + "width": "fill_container", + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "OFdKd", + "name": "backRow", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "am0hj", + "name": "backIcon", + "width": 18, + "height": 18, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "LrGxo", + "name": "backText", + "fill": "$teal-600", + "content": "シフト管理", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "Arx9i", + "name": "titleText", + "fill": "$gray-900", + "content": "必要人員設定", + "fontFamily": "Inter", + "fontSize": "$font-xl", + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "wXWzQ", + "name": "SP Scroll Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "z6KAt", + "name": "Mode Toggle Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "ekVLz", + "name": "Mode Toggle", + "width": "fill_container", + "fill": "$gray-100", + "cornerRadius": "$radius-lg", + "gap": 4, + "padding": 4, + "children": [ + { + "type": "frame", + "id": "kJtsd", + "name": "Simple Mode Tab", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 4 + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wmPjj", + "name": "modeSimpleText", + "fill": "$teal-600", + "content": "かんたん", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "TBdRU", + "name": "Detail Mode Tab", + "width": "fill_container", + "height": 36, + "cornerRadius": "$radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CSyvq", + "name": "modeDetailText", + "fill": "$gray-500", + "content": "詳細", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "ziqeF", + "name": "modeHint", + "fill": "$gray-400", + "content": "平日・休日の2パターンでかんたん設定", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "H0HeB", + "name": "Day Tabs", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "XLVUZ", + "name": "Weekday Tab Active", + "width": "fill_container", + "height": 40, + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cdN2r", + "name": "weekdayText", + "fill": "$white", + "content": "平日", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "76bqH", + "name": "Holiday Tab", + "width": "fill_container", + "height": 40, + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WpepL", + "name": "holidayText", + "fill": "$gray-600", + "content": "休日", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "1YIzi", + "name": "peakLabel", + "fill": "$gray-800", + "content": "ピーク帯設定", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "600" + }, + { + "type": "frame", + "id": "iQTI4", + "name": "Peak Band Card - Lunch", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "jMNfR", + "name": "Time Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "VSYWq", + "name": "Time Inputs", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CT0Yy", + "name": "Start Time", + "width": 80, + "height": 36, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "50mli", + "name": "startTimeText", + "fill": "$gray-700", + "content": "11:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "Ynidk", + "name": "timeSep", + "fill": "$gray-400", + "content": "〜", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "V4twU", + "name": "End Time", + "width": 80, + "height": 36, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IqXqs", + "name": "endTimeText", + "fill": "$gray-700", + "content": "14:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "icon_font", + "id": "4EdLo", + "name": "del1", + "width": 18, + "height": 18, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$red-500" + } + ] + }, + { + "type": "frame", + "id": "hryuU", + "name": "Staff Count Row", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "AI8tC", + "name": "Staff Input", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "H04Se", + "name": "Minus Btn", + "width": 32, + "height": 32, + "fill": "$gray-100", + "cornerRadius": [ + 6, + 0, + 0, + 6 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kXBcP", + "name": "staffMinusIcon", + "fill": "$gray-600", + "content": "−", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "ZqcqK", + "name": "Staff Value", + "width": 44, + "height": 32, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Yach3", + "name": "staffValueText", + "fill": "$gray-800", + "content": "3", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "YjXyV", + "name": "Plus Btn", + "width": 32, + "height": 32, + "fill": "$teal-50", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "js9Ej", + "name": "staffPlusIcon", + "fill": "$teal-600", + "content": "+", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "G9XIY", + "name": "staffUnit", + "fill": "$gray-600", + "content": "人", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "5fhX6", + "name": "Peak Band Card - Dinner", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "6YWoz", + "name": "Time Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kXJC8", + "name": "Time Inputs", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "0wvnX", + "name": "Start Time", + "width": 80, + "height": 36, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dl9AE", + "name": "card2StartText", + "fill": "$gray-700", + "content": "17:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "t3F2X", + "name": "card2Sep", + "fill": "$gray-400", + "content": "〜", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "QGCAi", + "name": "End Time", + "width": 80, + "height": 36, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rr0ND", + "name": "card2EndText", + "fill": "$gray-700", + "content": "21:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "icon_font", + "id": "d1dRq", + "name": "del2", + "width": 18, + "height": 18, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$red-500" + } + ] + }, + { + "type": "frame", + "id": "OsX5j", + "name": "Staff Count Row", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "iiq6P", + "name": "Staff Input", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "inM3v", + "name": "Minus Btn", + "width": 32, + "height": 32, + "fill": "$gray-100", + "cornerRadius": [ + 6, + 0, + 0, + 6 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DZPKZ", + "name": "card2MinusText", + "fill": "$gray-600", + "content": "−", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "2WMa8", + "name": "Staff Value", + "width": 44, + "height": 32, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "SG6eW", + "name": "card2ValText", + "fill": "$gray-800", + "content": "4", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "iZRZu", + "name": "Plus Btn", + "width": 32, + "height": 32, + "fill": "$teal-50", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lBC4F", + "name": "card2PlusText", + "fill": "$teal-600", + "content": "+", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "9d8Ti", + "name": "card2Unit", + "fill": "$gray-600", + "content": "人", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "pNwmC", + "name": "Add Peak Band Button", + "width": "fill_container", + "height": 44, + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "gap": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VzmlR", + "name": "addIcon", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "D66zJ", + "name": "addText", + "fill": "$teal-600", + "content": "ピーク帯を追加", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "JTHP5", + "name": "Minimum Staff Section", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "HSylQ", + "name": "Min Header", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "UBAFR", + "name": "minIcon", + "width": 18, + "height": 18, + "iconFontName": "shield", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "R1nzH", + "name": "minTitle", + "fill": "$gray-800", + "content": "最低人員設定", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "ZFRYQ", + "name": "Min Body", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wSE6n", + "name": "minText1", + "fill": "$gray-600", + "content": "常に最低", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "1zgYb", + "name": "Min Input", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "9Hjtx", + "name": "minMinus", + "width": 32, + "height": 32, + "fill": "$gray-100", + "cornerRadius": [ + 6, + 0, + 0, + 6 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "0Znkm", + "name": "minMinusT", + "fill": "$gray-600", + "content": "−", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "LsOJ6", + "name": "minVal", + "width": 44, + "height": 32, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "hYyNG", + "name": "minValT", + "fill": "$gray-800", + "content": "2", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "9BBTa", + "name": "minPlus", + "width": 32, + "height": 32, + "fill": "$teal-50", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "inSpm", + "name": "minPlusT", + "fill": "$teal-600", + "content": "+", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "fcpNo", + "name": "minText2", + "fill": "$gray-600", + "content": "人を配置", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "DnmlP", + "name": "Save Bar", + "width": "fill_container", + "height": 60, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$gray-200" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": -2 + }, + "blur": 8 + }, + "padding": [ + 0, + 20 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "trbfk", + "name": "Warning", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "TlhK4", + "name": "warnIcon", + "width": 16, + "height": 16, + "iconFontName": "alert-triangle", + "iconFontFamily": "lucide", + "fill": "$orange-500" + }, + { + "type": "text", + "id": "OAHkd", + "name": "warnText", + "fill": "$orange-600", + "content": "未保存の変更があります", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "vPdgx", + "name": "Save Button", + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "gap": 6, + "padding": [ + 10, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bKlC2", + "name": "saveBtnText", + "fill": "$white", + "content": "保存", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "uPbj7", + "x": 2610, + "y": 0, + "name": "SP版 - 詳細モード", + "clip": true, + "width": 390, + "height": 844, + "fill": "$gray-50", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "LX5zA", + "name": "SP Header", + "width": "fill_container", + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$gray-200" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "kytBM", + "name": "backRow", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "IiTR2", + "name": "bkIco", + "width": 18, + "height": 18, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "OnvMi", + "name": "bkTxt", + "fill": "$teal-600", + "content": "シフト管理", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "woSj0", + "name": "ttl", + "fill": "$gray-900", + "content": "必要人員設定", + "fontFamily": "Inter", + "fontSize": "$font-xl", + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "O7Pi3", + "name": "SP Scroll Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "U8Y6s", + "name": "Mode Toggle Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "yqDj5", + "name": "Mode Toggle", + "width": "fill_container", + "fill": "$gray-100", + "cornerRadius": "$radius-lg", + "gap": 4, + "padding": 4, + "children": [ + { + "type": "frame", + "id": "wx5u6", + "name": "Simple Mode Tab", + "width": "fill_container", + "height": 36, + "cornerRadius": "$radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "gfQ3X", + "name": "simTxt", + "fill": "$gray-500", + "content": "かんたん", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "dQFB4", + "name": "Detail Mode Tab Active", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 4 + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OMkM9", + "name": "detTxt", + "fill": "$teal-600", + "content": "詳細", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "9MPq2", + "name": "modeHnt", + "fill": "$gray-400", + "content": "曜日ごとに個別設定(月〜日+祝)", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ANJ8k", + "name": "Day Tabs Wrap", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "aoaNv", + "name": "Day Row 1", + "width": "fill_container", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "XjtGu", + "name": "mon", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jeGsA", + "name": "monT", + "fill": "$gray-600", + "content": "月", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "ellipse", + "id": "VK6az", + "name": "monDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + } + ] + }, + { + "type": "frame", + "id": "proQm", + "name": "tue", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qR3Ow", + "name": "tueT", + "fill": "$gray-600", + "content": "火", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "ellipse", + "id": "Z2mUD", + "name": "tueDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + } + ] + }, + { + "type": "frame", + "id": "YFX6l", + "name": "wed", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ZfhHG", + "name": "wedT", + "fill": "$gray-600", + "content": "水", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "ellipse", + "id": "6Bqou", + "name": "wedDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + } + ] + }, + { + "type": "frame", + "id": "4y8cv", + "name": "thu", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "3spM6", + "name": "thuT", + "fill": "$gray-600", + "content": "木", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "ellipse", + "id": "Tl2AO", + "name": "thuDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + } + ] + } + ] + }, + { + "type": "frame", + "id": "Y6tMu", + "name": "Day Row 2", + "width": "fill_container", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "Aofmj", + "name": "fri", + "width": "fill_container", + "height": 36, + "fill": "$teal-600", + "cornerRadius": "$radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "H0YRd", + "name": "friT", + "fill": "$white", + "content": "金", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "o0App", + "name": "sat", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "0GCVw", + "name": "satT", + "fill": "$gray-600", + "content": "土", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "ellipse", + "id": "3621E", + "name": "satDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + } + ] + }, + { + "type": "frame", + "id": "q7rRJ", + "name": "sun", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Aq3Qd", + "name": "sunT", + "fill": "$gray-600", + "content": "日", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "ellipse", + "id": "K2QVr", + "name": "sunDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + } + ] + }, + { + "type": "frame", + "id": "J5Fxk", + "name": "hol", + "width": "fill_container", + "height": 36, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "3opXJ", + "name": "holT", + "fill": "$gray-600", + "content": "祝", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "ellipse", + "id": "8q5eP", + "name": "holDot", + "fill": "$teal-500", + "width": 6, + "height": 6 + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "U8IO1", + "name": "pkLabel", + "fill": "$gray-800", + "content": "ピーク帯設定", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "600" + }, + { + "type": "frame", + "id": "G4Len", + "name": "Peak Band Card - Lunch", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "FiZgO", + "name": "Time Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "HppIe", + "name": "lcTmIn", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "QGnpQ", + "name": "lcSt", + "width": 80, + "height": 36, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "2A7ge", + "name": "lcStT", + "fill": "$gray-700", + "content": "11:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "kPa13", + "name": "lcSep", + "fill": "$gray-400", + "content": "〜", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "uw3N0", + "name": "lcEd", + "width": 80, + "height": 36, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FhC2r", + "name": "lcEdT", + "fill": "$gray-700", + "content": "14:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "icon_font", + "id": "RoK2X", + "name": "ld1", + "width": 18, + "height": 18, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$red-500" + } + ] + }, + { + "type": "frame", + "id": "FXTjD", + "name": "Staff Count Row", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "EU4uV", + "name": "lcSIn", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "8Idu3", + "name": "lcM", + "width": 32, + "height": 32, + "fill": "$gray-100", + "cornerRadius": [ + 6, + 0, + 0, + 6 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MjVK3", + "name": "lcMT", + "fill": "$gray-600", + "content": "−", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "35K58", + "name": "lcV", + "width": 44, + "height": 32, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "U9sH9", + "name": "lcVT", + "fill": "$gray-800", + "content": "4", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "lPPNO", + "name": "lcP", + "width": 32, + "height": 32, + "fill": "$teal-50", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lMmWX", + "name": "lcPT", + "fill": "$teal-600", + "content": "+", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "DQCiQ", + "name": "lcU", + "fill": "$gray-600", + "content": "人", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ny3Gq", + "name": "Peak Band Card - Dinner", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "Ks8iI", + "name": "Time Row", + "width": "fill_container", + "gap": 8, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "9Zrwg", + "name": "dcTIn", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gqCK4", + "name": "dcS", + "width": 80, + "height": 36, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vd3Go", + "name": "dcST", + "fill": "$gray-700", + "content": "17:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "Ryvsv", + "name": "dcSep", + "fill": "$gray-400", + "content": "〜", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "2PZTq", + "name": "dcE", + "width": 80, + "height": 36, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HD1pn", + "name": "dcET", + "fill": "$gray-700", + "content": "22:00", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "icon_font", + "id": "YAq0g", + "name": "ld2", + "width": 18, + "height": 18, + "iconFontName": "trash-2", + "iconFontFamily": "lucide", + "fill": "$red-500" + } + ] + }, + { + "type": "frame", + "id": "sWiB3", + "name": "Staff Count Row", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "5NUtl", + "name": "dcSIn", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "8C9Yc", + "name": "dcMn", + "width": 32, + "height": 32, + "fill": "$gray-100", + "cornerRadius": [ + 6, + 0, + 0, + 6 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IQNmH", + "name": "dcMnT", + "fill": "$gray-600", + "content": "−", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "0Gtgv", + "name": "dcVl", + "width": 44, + "height": 32, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "9GnDc", + "name": "dcVlT", + "fill": "$gray-800", + "content": "5", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "jKedn", + "name": "dcPl", + "width": 32, + "height": 32, + "fill": "$teal-50", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vG4dn", + "name": "dcPlT", + "fill": "$teal-600", + "content": "+", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "s6PzW", + "name": "dcUn", + "fill": "$gray-600", + "content": "人", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "V5xGi", + "name": "Add Peak Band Button", + "width": "fill_container", + "height": 44, + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "gap": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "94SDv", + "name": "addI", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "rgkr0", + "name": "addT", + "fill": "$teal-600", + "content": "ピーク帯を追加", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "HcxBa", + "name": "Minimum Staff Section", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "XyrQA", + "name": "minH", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "9HJPJ", + "name": "minIc", + "width": 18, + "height": 18, + "iconFontName": "shield", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "5cueM", + "name": "minTt", + "fill": "$gray-800", + "content": "最低人員設定", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "WYuRV", + "name": "minB", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nwXfD", + "name": "minT1", + "fill": "$gray-600", + "content": "常に最低", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "lG0Aw", + "name": "minIn", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "NMCIv", + "name": "minMn", + "width": 32, + "height": 32, + "fill": "$gray-100", + "cornerRadius": [ + 6, + 0, + 0, + 6 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "9P50x", + "name": "minMT", + "fill": "$gray-600", + "content": "−", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "uwPri", + "name": "minVl", + "width": 44, + "height": 32, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "adlzd", + "name": "minVT", + "fill": "$gray-800", + "content": "2", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "17wkp", + "name": "minPl", + "width": 32, + "height": 32, + "fill": "$teal-50", + "cornerRadius": [ + 0, + 6, + 6, + 0 + ], + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "xFqDB", + "name": "minPT", + "fill": "$teal-600", + "content": "+", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "ApQzr", + "name": "minT2", + "fill": "$gray-600", + "content": "人を配置", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Cwinp", + "name": "Save Bar", + "width": "fill_container", + "height": 60, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$gray-200" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": -2 + }, + "blur": 8 + }, + "padding": [ + 0, + 20 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kjKz4", + "name": "svWrn", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "oJ1l5", + "name": "svWIc", + "width": 16, + "height": 16, + "iconFontName": "triangle-alert", + "iconFontFamily": "lucide", + "fill": "$orange-500" + }, + { + "type": "text", + "id": "rXwgz", + "name": "svWTx", + "fill": "$orange-600", + "content": "未保存の変更があります", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Im6aF", + "name": "svBtn", + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "padding": [ + 10, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ipO3t", + "name": "svBTx", + "fill": "$white", + "content": "保存", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Y3Dxe", + "x": 3100, + "y": 0, + "name": "確認ダイアログ", + "width": 480, + "height": 300, + "fill": "#00000033", + "cornerRadius": "$radius-lg", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "dnw5V", + "x": 40, + "y": 40, + "name": "Dialog Box", + "width": 400, + "fill": "$white", + "cornerRadius": "$radius-xl", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000033", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 20 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "dnXMQ", + "name": "Dialog Header", + "width": "fill_container", + "padding": [ + 20, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "lxJ8C", + "name": "dlgHdrLeft", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "tX9H9", + "name": "dlgHdrIco", + "width": 20, + "height": 20, + "iconFontName": "triangle-alert", + "iconFontFamily": "lucide", + "fill": "$orange-500" + }, + { + "type": "text", + "id": "VMQmZ", + "name": "dlgHdrTxt", + "fill": "$gray-900", + "content": "モード切替の確認", + "fontFamily": "Inter", + "fontSize": "$font-lg", + "fontWeight": "600" + } + ] + }, + { + "type": "icon_font", + "id": "oHTAw", + "name": "dlgClose", + "width": 20, + "height": 20, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$gray-400" + } + ] + }, + { + "type": "frame", + "id": "bZrca", + "name": "Dialog Body", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 0, + 24, + 20, + 24 + ], + "children": [ + { + "type": "text", + "id": "hdGxj", + "name": "dlgMsg", + "fill": "$gray-600", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "かんたんモードに切り替えると、曜日ごとの個別設定は「平日」「休日」の設定で上書きされます。", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ZwQ0O", + "name": "dlgWarn", + "width": "fill_container", + "fill": "$orange-50", + "cornerRadius": "$radius-md", + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ZLYZj", + "name": "dlgWIco", + "width": 16, + "height": 16, + "iconFontName": "info", + "iconFontFamily": "lucide", + "fill": "$orange-600" + }, + { + "type": "text", + "id": "OnoOd", + "name": "dlgWTxt", + "fill": "$orange-600", + "content": "この操作は取り消せません", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "snQAH", + "name": "dlgDiv", + "fill": "$gray-100", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "XDssI", + "name": "Dialog Footer", + "width": "fill_container", + "gap": 12, + "padding": [ + 12, + 24, + 16, + 24 + ], + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "KEM3g", + "name": "dlgCancel", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zD6VN", + "name": "dlgCancelTxt", + "fill": "$gray-700", + "content": "キャンセル", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "p2MYK", + "name": "dlgConfirm", + "fill": "$orange-500", + "cornerRadius": "$radius-lg", + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FOMWK", + "name": "dlgConfTxt", + "fill": "$white", + "content": "切り替える", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + } + ], + "variables": { + "black": { + "type": "color", + "value": [ + { + "value": "#000000" + }, + { + "value": "#000000" + } + ] + }, + "blue-100": { + "type": "color", + "value": "#dbeafe" + }, + "blue-50": { + "type": "color", + "value": [ + { + "value": "#eff6ff" + }, + { + "value": "#eff6ff" + } + ] + }, + "blue-500": { + "type": "color", + "value": [ + { + "value": "#3b82f6" + }, + { + "value": "#3b82f6" + } + ] + }, + "blue-600": { + "type": "color", + "value": "#2563eb" + }, + "blue-700": { + "type": "color", + "value": "#1d4ed8" + }, + "font-2xl": { + "type": "number", + "value": [ + { + "value": 24 + }, + { + "value": 24 + } + ] + }, + "font-4xl": { + "type": "number", + "value": [ + { + "value": 36 + }, + { + "value": 36 + } + ] + }, + "font-lg": { + "type": "number", + "value": [ + { + "value": 18 + }, + { + "value": 18 + } + ] + }, + "font-md": { + "type": "number", + "value": [ + { + "value": 16 + }, + { + "value": 16 + } + ] + }, + "font-sm": { + "type": "number", + "value": [ + { + "value": 14 + }, + { + "value": 14 + } + ] + }, + "font-xl": { + "type": "number", + "value": [ + { + "value": 20 + }, + { + "value": 20 + } + ] + }, + "font-xs": { + "type": "number", + "value": [ + { + "value": 12 + }, + { + "value": 12 + } + ] + }, + "gray-100": { + "type": "color", + "value": [ + { + "value": "#f3f4f6" + }, + { + "value": "#f3f4f6" + } + ] + }, + "gray-200": { + "type": "color", + "value": [ + { + "value": "#e5e7eb" + }, + { + "value": "#e5e7eb" + } + ] + }, + "gray-300": { + "type": "color", + "value": [ + { + "value": "#d1d5db" + }, + { + "value": "#d1d5db" + } + ] + }, + "gray-400": { + "type": "color", + "value": [ + { + "value": "#9ca3af" + }, + { + "value": "#9ca3af" + } + ] + }, + "gray-50": { + "type": "color", + "value": [ + { + "value": "#f9fafb" + }, + { + "value": "#f9fafb" + } + ] + }, + "gray-500": { + "type": "color", + "value": [ + { + "value": "#6b7280" + }, + { + "value": "#6b7280" + } + ] + }, + "gray-600": { + "type": "color", + "value": [ + { + "value": "#4b5563" + }, + { + "value": "#4b5563" + } + ] + }, + "gray-700": { + "type": "color", + "value": [ + { + "value": "#374151" + }, + { + "value": "#374151" + } + ] + }, + "gray-800": { + "type": "color", + "value": [ + { + "value": "#1f2937" + }, + { + "value": "#1f2937" + } + ] + }, + "gray-900": { + "type": "color", + "value": [ + { + "value": "#111827" + }, + { + "value": "#111827" + } + ] + }, + "green-100": { + "type": "color", + "value": [ + { + "value": "#dcfce7" + }, + { + "value": "#dcfce7" + } + ] + }, + "green-50": { + "type": "color", + "value": [ + { + "value": "#f0fdf4" + }, + { + "value": "#f0fdf4" + } + ] + }, + "green-500": { + "type": "color", + "value": [ + { + "value": "#22c55e" + }, + { + "value": "#22c55e" + } + ] + }, + "green-600": { + "type": "color", + "value": [ + { + "value": "#16a34a" + }, + { + "value": "#16a34a" + } + ] + }, + "heat-0": { + "type": "color", + "value": "#f9fafb" + }, + "heat-1": { + "type": "color", + "value": "#ccfbf1" + }, + "heat-2": { + "type": "color", + "value": "#99f6e4" + }, + "heat-3": { + "type": "color", + "value": "#5eead4" + }, + "heat-4": { + "type": "color", + "value": "#2dd4bf" + }, + "heat-5": { + "type": "color", + "value": "#0d9488" + }, + "orange-100": { + "type": "color", + "value": [ + { + "value": "#ffedd5" + }, + { + "value": "#ffedd5" + } + ] + }, + "orange-50": { + "type": "color", + "value": [ + { + "value": "#fff7ed" + }, + { + "value": "#fff7ed" + } + ] + }, + "orange-500": { + "type": "color", + "value": [ + { + "value": "#f97316" + }, + { + "value": "#f97316" + } + ] + }, + "orange-600": { + "type": "color", + "value": [ + { + "value": "#ea580c" + }, + { + "value": "#ea580c" + } + ] + }, + "purple-50": { + "type": "color", + "value": [ + { + "value": "#faf5ff" + }, + { + "value": "#faf5ff" + } + ] + }, + "purple-600": { + "type": "color", + "value": [ + { + "value": "#9333ea" + }, + { + "value": "#9333ea" + } + ] + }, + "radius-2xl": { + "type": "number", + "value": [ + { + "value": 16 + }, + { + "value": 16 + } + ] + }, + "radius-full": { + "type": "number", + "value": [ + { + "value": 9999 + }, + { + "value": 9999 + } + ] + }, + "radius-lg": { + "type": "number", + "value": [ + { + "value": 8 + }, + { + "value": 8 + } + ] + }, + "radius-md": { + "type": "number", + "value": [ + { + "value": 6 + }, + { + "value": 6 + } + ] + }, + "radius-sm": { + "type": "number", + "value": [ + { + "value": 4 + }, + { + "value": 4 + } + ] + }, + "radius-xl": { + "type": "number", + "value": [ + { + "value": 12 + }, + { + "value": 12 + } + ] + }, + "red-100": { + "type": "color", + "value": [ + { + "value": "#fee2e2" + }, + { + "value": "#fee2e2" + } + ] + }, + "red-50": { + "type": "color", + "value": [ + { + "value": "#fef2f2" + }, + { + "value": "#fef2f2" + } + ] + }, + "red-500": { + "type": "color", + "value": [ + { + "value": "#ef4444" + }, + { + "value": "#ef4444" + } + ] + }, + "red-600": { + "type": "color", + "value": [ + { + "value": "#dc2626" + }, + { + "value": "#dc2626" + } + ] + }, + "red-700": { + "type": "color", + "value": "#b91c1c" + }, + "spacing-1": { + "type": "number", + "value": [ + { + "value": 4 + }, + { + "value": 4 + } + ] + }, + "spacing-12": { + "type": "number", + "value": [ + { + "value": 48 + }, + { + "value": 48 + } + ] + }, + "spacing-2": { + "type": "number", + "value": [ + { + "value": 8 + }, + { + "value": 8 + } + ] + }, + "spacing-3": { + "type": "number", + "value": [ + { + "value": 12 + }, + { + "value": 12 + } + ] + }, + "spacing-4": { + "type": "number", + "value": [ + { + "value": 16 + }, + { + "value": 16 + } + ] + }, + "spacing-6": { + "type": "number", + "value": [ + { + "value": 24 + }, + { + "value": 24 + } + ] + }, + "spacing-8": { + "type": "number", + "value": [ + { + "value": 32 + }, + { + "value": 32 + } + ] + }, + "teal-100": { + "type": "color", + "value": [ + { + "value": "#ccfbf1" + }, + { + "value": "#ccfbf1" + } + ] + }, + "teal-200": { + "type": "color", + "value": "#99f6e4" + }, + "teal-300": { + "type": "color", + "value": "#5eead4" + }, + "teal-400": { + "type": "color", + "value": "#2dd4bf" + }, + "teal-50": { + "type": "color", + "value": [ + { + "value": "#f0fdfa" + }, + { + "value": "#f0fdfa" + } + ] + }, + "teal-500": { + "type": "color", + "value": [ + { + "value": "#14b8a6" + }, + { + "value": "#14b8a6" + } + ] + }, + "teal-600": { + "type": "color", + "value": [ + { + "value": "#0d9488" + }, + { + "value": "#0d9488" + } + ] + }, + "teal-700": { + "type": "color", + "value": [ + { + "value": "#0f766e" + }, + { + "value": "#0f766e" + } + ] + }, + "teal-800": { + "type": "color", + "value": "#115e59" + }, + "teal-900": { + "type": "color", + "value": "#134e4a" + }, + "transparent": { + "type": "color", + "value": [ + { + "value": "#00000000" + }, + { + "value": "#00000000" + } + ] + }, + "white": { + "type": "color", + "value": [ + { + "value": "#ffffff" + }, + { + "value": "#ffffff" + } + ] + } + } +} \ No newline at end of file diff --git a/doc/design/common.pen b/doc/design/common.pen new file mode 100644 index 00000000..f4a63580 --- /dev/null +++ b/doc/design/common.pen @@ -0,0 +1,5952 @@ +{ + "version": "2.9", + "children": [ + { + "type": "frame", + "id": "M4J3C", + "x": 0, + "y": 0, + "name": "Design Tokens", + "width": 1600, + "fill": "$white", + "layout": "vertical", + "gap": 48, + "padding": 48, + "children": [ + { + "type": "text", + "id": "Bw9kr", + "name": "title", + "fill": "$gray-900", + "content": "Design Tokens", + "fontFamily": "Inter", + "fontSize": 36, + "fontWeight": "700" + }, + { + "type": "text", + "id": "ipjkq", + "name": "subtitle", + "fill": "$gray-500", + "content": "Chakra UI V3 — Shift Management SaaS — Light Mode", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "rectangle", + "id": "MnQTv", + "name": "divider0", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "dgjBu", + "name": "Color Palette Section", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "text", + "id": "nyvvQ", + "name": "secTitle1", + "fill": "$gray-800", + "content": "Color Palette", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "AnbOE", + "name": "tealLabel", + "fill": "$gray-600", + "content": "Teal (Primary) — Actions, active states, spinners", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "s1gKy", + "name": "tealRow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "foQea", + "name": "t50", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "wXTcY", + "name": "t50r", + "fill": "$teal-50", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "UnsCy", + "name": "t50l", + "fill": "$gray-700", + "content": "50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "5Xolc", + "name": "t50v", + "fill": "$gray-400", + "content": "#f0fdfa", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xnEwT", + "name": "t100", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "EpoTE", + "name": "t100r", + "fill": "$teal-100", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "1eGVK", + "name": "t100l", + "fill": "$gray-700", + "content": "100", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ct1XG", + "name": "t100v", + "fill": "$gray-400", + "content": "#ccfbf1", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "9ImH8", + "name": "t200", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "QgMC4", + "name": "t200r", + "fill": "$teal-200", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "ez1f2", + "name": "t200l", + "fill": "$gray-700", + "content": "200", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "rjWsr", + "name": "t200v", + "fill": "$gray-400", + "content": "#99f6e4", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rPGXH", + "name": "t300", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "ZVCVG", + "name": "t300r", + "fill": "$teal-300", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "igY5v", + "name": "t300l", + "fill": "$gray-700", + "content": "300", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "5d9PS", + "name": "t300v", + "fill": "$gray-400", + "content": "#5eead4", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Rd24x", + "name": "t400", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "dvQEK", + "name": "t400r", + "fill": "$teal-400", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "y3FAE", + "name": "t400l", + "fill": "$gray-700", + "content": "400", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "N0djB", + "name": "t400v", + "fill": "$gray-400", + "content": "#2dd4bf", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "2hhvP", + "name": "t500", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "DiA9B", + "name": "t500r", + "fill": "$teal-500", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "jzvvq", + "name": "t500l", + "fill": "$gray-700", + "content": "500", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "y5IDb", + "name": "t500v", + "fill": "$gray-400", + "content": "#14b8a6", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MFTlq", + "name": "t600", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "wkZ8P", + "name": "t600r", + "fill": "$teal-600", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "ezUNZ", + "name": "t600l", + "fill": "$white", + "content": "600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Aubbj", + "name": "t600v", + "fill": "$gray-400", + "content": "#0d9488", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IaJUw", + "name": "t700", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "DzL5S", + "name": "t700r", + "fill": "$teal-700", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "kpqmT", + "name": "t700l", + "fill": "$white", + "content": "700", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "tB1WJ", + "name": "t700v", + "fill": "$gray-400", + "content": "#0f766e", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Vv6RT", + "name": "t800", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "h0ZD7", + "name": "t800r", + "fill": "$teal-800", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "kofyo", + "name": "t800l", + "fill": "$white", + "content": "800", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "L99fT", + "name": "t800v", + "fill": "$gray-400", + "content": "#115e59", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QZxGn", + "name": "t900", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "3bGcB", + "name": "t900r", + "fill": "$teal-900", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "5iheo", + "name": "t900l", + "fill": "$white", + "content": "900", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ncP2y", + "name": "t900v", + "fill": "$gray-400", + "content": "#134e4a", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "6TlZh", + "name": "grayLabel", + "fill": "$gray-600", + "content": "Gray (Neutral) — Text, backgrounds, borders, disabled states", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "f51Ww", + "name": "grayRow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "1quPR", + "name": "g50", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "tBHZB", + "name": "g50r", + "fill": "$gray-50", + "width": 64, + "height": 40, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + } + }, + { + "type": "text", + "id": "CBWST", + "name": "g50l", + "fill": "$gray-700", + "content": "50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "9qcNa", + "name": "g50v", + "fill": "$gray-400", + "content": "#f9fafb", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "R1BWf", + "name": "g100", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "PFyLM", + "name": "g100r", + "fill": "$gray-100", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "FKKOr", + "name": "g100l", + "fill": "$gray-700", + "content": "100", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "cqtSx", + "name": "g100v", + "fill": "$gray-400", + "content": "#f3f4f6", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jY8CU", + "name": "g200", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "KAGVA", + "name": "g200r", + "fill": "$gray-200", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "rSoqZ", + "name": "g200l", + "fill": "$gray-700", + "content": "200", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "vK45w", + "name": "g200v", + "fill": "$gray-400", + "content": "#e5e7eb", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Ae6iU", + "name": "g300", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "gNhQR", + "name": "g300r", + "fill": "$gray-300", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "IP8HD", + "name": "g300l", + "fill": "$gray-700", + "content": "300", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ssk6k", + "name": "g300v", + "fill": "$gray-400", + "content": "#d1d5db", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mzu1b", + "name": "g400", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "CNXV6", + "name": "g400r", + "fill": "$gray-400", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "aXnNE", + "name": "g400l", + "fill": "$gray-700", + "content": "400", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "MDEDM", + "name": "g400v", + "fill": "$gray-400", + "content": "#9ca3af", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "u6oOT", + "name": "g500", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "varjP", + "name": "g500r", + "fill": "$gray-500", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "JPwht", + "name": "g500l", + "fill": "$white", + "content": "500", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "odoi3", + "name": "g500v", + "fill": "$gray-400", + "content": "#6b7280", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cSIau", + "name": "g600", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "jGm97", + "name": "g600r", + "fill": "$gray-600", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "5VKnb", + "name": "g600l", + "fill": "$white", + "content": "600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "09YKG", + "name": "g600v", + "fill": "$gray-400", + "content": "#4b5563", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "F2hjT", + "name": "g700", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "DdCqJ", + "name": "g700r", + "fill": "$gray-700", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "pTc3Z", + "name": "g700l", + "fill": "$white", + "content": "700", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "9G871", + "name": "g700v", + "fill": "$gray-400", + "content": "#374151", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "stqM3", + "name": "g800", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "nqTX8", + "name": "g800r", + "fill": "$gray-800", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "E6zj6", + "name": "g800l", + "fill": "$white", + "content": "800", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "HbUJX", + "name": "g800v", + "fill": "$gray-400", + "content": "#1f2937", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "VwyfK", + "name": "g900", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "i5Kk0", + "name": "g900r", + "fill": "$gray-900", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "KXTRd", + "name": "g900l", + "fill": "$white", + "content": "900", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "4Dyg6", + "name": "g900v", + "fill": "$gray-400", + "content": "#111827", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "A6r5I", + "name": "redLabel", + "fill": "$gray-600", + "content": "Red (Error / Destructive)", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "Oyd0P", + "name": "redRow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "iRaLU", + "name": "r50", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "m50kL", + "name": "r50r", + "fill": "$red-50", + "width": 64, + "height": 40, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + } + }, + { + "type": "text", + "id": "RuaH9", + "name": "r50l", + "fill": "$gray-700", + "content": "50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "mFH54", + "name": "r50v", + "fill": "$gray-400", + "content": "#fef2f2", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mnlsO", + "name": "r100", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "7aZXz", + "name": "r100r", + "fill": "$red-100", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "Cptzu", + "name": "r100l", + "fill": "$gray-700", + "content": "100", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "jB0zz", + "name": "r100v", + "fill": "$gray-400", + "content": "#fee2e2", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TOXSJ", + "name": "r500", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "oHQ8U", + "name": "r500r", + "fill": "$red-500", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "o2VhK", + "name": "r500l", + "fill": "$white", + "content": "500", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "dczEO", + "name": "r500v", + "fill": "$gray-400", + "content": "#ef4444", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "k5F95", + "name": "r600", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "MztIX", + "name": "r600r", + "fill": "$red-600", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "uGpGL", + "name": "r600l", + "fill": "$white", + "content": "600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "b1u7N", + "name": "r600v", + "fill": "$gray-400", + "content": "#dc2626", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "w36YQ", + "name": "r700", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "K4Rmt", + "name": "r700r", + "fill": "$red-700", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "JNJN1", + "name": "r700l", + "fill": "$white", + "content": "700", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "v1Qp1", + "name": "r700v", + "fill": "$gray-400", + "content": "#b91c1c", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "1V7Ce", + "name": "orangeLabel", + "fill": "$gray-600", + "content": "Orange (Warning)", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "qBRB5", + "name": "orangeRow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "V1fYc", + "name": "o50", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "Az5ix", + "name": "o50r", + "fill": "$orange-50", + "width": 64, + "height": 40, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + } + }, + { + "type": "text", + "id": "JZLZK", + "name": "o50l", + "fill": "$gray-700", + "content": "50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "NEgFr", + "name": "o50v", + "fill": "$gray-400", + "content": "#fff7ed", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kTAOV", + "name": "o100", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "rOg96", + "name": "o100r", + "fill": "$orange-100", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "Qt446", + "name": "o100l", + "fill": "$gray-700", + "content": "100", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "VfOmg", + "name": "o100v", + "fill": "$gray-400", + "content": "#ffedd5", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "iVzU8", + "name": "o500", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "9lD8Y", + "name": "o500r", + "fill": "$orange-500", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "uYvwj", + "name": "o500l", + "fill": "$white", + "content": "500", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "crmLi", + "name": "o500v", + "fill": "$gray-400", + "content": "#f97316", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PAP7c", + "name": "o600", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "cE3Ic", + "name": "o600r", + "fill": "$orange-600", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "HtqHi", + "name": "o600l", + "fill": "$white", + "content": "600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "TQlne", + "name": "o600v", + "fill": "$gray-400", + "content": "#ea580c", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "O3LwE", + "name": "blueLabel", + "fill": "$gray-600", + "content": "Blue (Secondary)", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "QZHwN", + "name": "blueRow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "rAKGB", + "name": "b50", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "HA6Eb", + "name": "b50r", + "fill": "$blue-50", + "width": 64, + "height": 40, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + } + }, + { + "type": "text", + "id": "8ER7B", + "name": "b50l", + "fill": "$gray-700", + "content": "50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "05Rc0", + "name": "b50v", + "fill": "$gray-400", + "content": "#eff6ff", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "RRRfj", + "name": "b100", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "5fRK9", + "name": "b100r", + "fill": "$blue-100", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "dNEmD", + "name": "b100l", + "fill": "$gray-700", + "content": "100", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "BK9Pk", + "name": "b100v", + "fill": "$gray-400", + "content": "#dbeafe", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "zn8xY", + "name": "b500", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "LwupI", + "name": "b500r", + "fill": "$blue-500", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "TXKhE", + "name": "b500l", + "fill": "$white", + "content": "500", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "1Qf3w", + "name": "b500v", + "fill": "$gray-400", + "content": "#3b82f6", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PPwbY", + "name": "b600", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "99m3m", + "name": "b600r", + "fill": "$blue-600", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "6puaL", + "name": "b600l", + "fill": "$white", + "content": "600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "SGEES", + "name": "b600v", + "fill": "$gray-400", + "content": "#2563eb", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xxKu7", + "name": "b700", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "vwcu8", + "name": "b700r", + "fill": "$blue-700", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "AMJv9", + "name": "b700l", + "fill": "$white", + "content": "700", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "W5iPk", + "name": "b700v", + "fill": "$gray-400", + "content": "#1d4ed8", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "xnpiK", + "name": "greenLabel", + "fill": "$gray-600", + "content": "Green (Success)", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "swWEZ", + "name": "greenRow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "IaBg4", + "name": "gn50", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "Z55so", + "name": "gn50r", + "fill": "$green-50", + "width": 64, + "height": 40, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + } + }, + { + "type": "text", + "id": "xtvq8", + "name": "gn50l", + "fill": "$gray-700", + "content": "50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ux3fN", + "name": "gn50v", + "fill": "$gray-400", + "content": "#f0fdf4", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bwiLw", + "name": "gn100", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "oLTuz", + "name": "gn100r", + "fill": "$green-100", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "BUonC", + "name": "gn100l", + "fill": "$gray-700", + "content": "100", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ey8IC", + "name": "gn100v", + "fill": "$gray-400", + "content": "#dcfce7", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "4LMAF", + "name": "gn500", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "CcQyj", + "name": "gn500r", + "fill": "$green-500", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "ajMl9", + "name": "gn500l", + "fill": "$white", + "content": "500", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "VGpJI", + "name": "gn500v", + "fill": "$gray-400", + "content": "#22c55e", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "vRq9K", + "name": "gn600", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "bLgMs", + "name": "gn600r", + "fill": "$green-600", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "pTqIL", + "name": "gn600l", + "fill": "$white", + "content": "600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "5ZpJn", + "name": "gn600v", + "fill": "$gray-400", + "content": "#16a34a", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "OGLVt", + "name": "purpleLabel", + "fill": "$gray-600", + "content": "Purple (Accent)", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "gZPuJ", + "name": "purpleRow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "n4Erb", + "name": "p50", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "pKgUH", + "name": "p50r", + "fill": "$purple-50", + "width": 64, + "height": 40, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + } + }, + { + "type": "text", + "id": "JeQlP", + "name": "p50l", + "fill": "$gray-700", + "content": "50", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "C5cvH", + "name": "p50v", + "fill": "$gray-400", + "content": "#faf5ff", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dGxw1", + "name": "p600", + "layout": "vertical", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "OOzDm", + "name": "p600r", + "fill": "$purple-600", + "width": 64, + "height": 40 + }, + { + "type": "text", + "id": "MEiZ7", + "name": "p600l", + "fill": "$white", + "content": "600", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "x6XeL", + "name": "p600v", + "fill": "$gray-400", + "content": "#9333ea", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "RaIhO", + "name": "posLabel", + "fill": "$gray-600", + "content": "Position Colors (Domain-Specific)", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "I1anQ", + "name": "posRow", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "4RZ7q", + "name": "posBlue", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "IfY22", + "name": "posBlueR", + "fill": "$position-blue", + "width": 40, + "height": 40 + }, + { + "type": "frame", + "id": "dUZ5C", + "name": "posBlueInfo", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "CBvbx", + "name": "posBlueName", + "fill": "$gray-700", + "content": "Hall", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "SAMmH", + "name": "posBlueHex", + "fill": "$gray-400", + "content": "#3b82f6", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "tohz5", + "name": "posOrange", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "fGkSV", + "name": "posOrangeR", + "fill": "$position-orange", + "width": 40, + "height": 40 + }, + { + "type": "frame", + "id": "R2jHd", + "name": "posOrangeInfo", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "vcYbF", + "name": "posOrangeName", + "fill": "$gray-700", + "content": "Kitchen", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "9TAwB", + "name": "posOrangeHex", + "fill": "$gray-400", + "content": "#f97316", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "FWcGm", + "name": "posGreen", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "CskeZ", + "name": "posGreenR", + "fill": "$position-green", + "width": 40, + "height": 40 + }, + { + "type": "frame", + "id": "smwJb", + "name": "posGreenInfo", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "2yFLt", + "name": "posGreenName", + "fill": "$gray-700", + "content": "Register", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "99YaF", + "name": "posGreenHex", + "fill": "$gray-400", + "content": "#10b981", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "FrI9v", + "name": "posGray", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "10Q1d", + "name": "posGrayR", + "fill": "$position-gray", + "width": 40, + "height": 40 + }, + { + "type": "frame", + "id": "fMUqN", + "name": "posGrayInfo", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "0anyE", + "name": "posGrayName", + "fill": "$gray-700", + "content": "Break", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "fxLbr", + "name": "posGrayHex", + "fill": "$gray-400", + "content": "#6b7280", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "a8Yul", + "name": "divider1", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + } + ] + }, + { + "type": "frame", + "id": "KxGaY", + "name": "Typography Scale Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "4oX6h", + "name": "typoTitle", + "fill": "$gray-800", + "content": "Typography Scale", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "SwtH0", + "name": "typoSizeLabel", + "fill": "$gray-600", + "content": "Font Sizes", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "vD8Rv", + "name": "typoSizes", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "z5kRO", + "name": "xs", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wcIxY", + "name": "xsLabel", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 100, + "content": "xs (12px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "nBfhE", + "name": "xsSample", + "fill": "$gray-800", + "content": "The quick brown fox jumps over the lazy dog", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ATcWO", + "name": "sm", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "eC82a", + "name": "smLabel", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 100, + "content": "sm (14px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "arONh", + "name": "smSample", + "fill": "$gray-800", + "content": "The quick brown fox jumps over the lazy dog", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "YqBo5", + "name": "md", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "orvhF", + "name": "mdLabel", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 100, + "content": "md (16px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "iZ5Lp", + "name": "mdSample", + "fill": "$gray-800", + "content": "The quick brown fox jumps over the lazy dog", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "fT4LX", + "name": "lg", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ryZm8", + "name": "lgLabel", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 100, + "content": "lg (18px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "xG68r", + "name": "lgSample", + "fill": "$gray-800", + "content": "The quick brown fox jumps over the lazy dog", + "fontFamily": "Inter", + "fontSize": "$font-lg", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oIcan", + "name": "xl", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "SApoL", + "name": "xlLabel", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 100, + "content": "xl (20px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "Q1HRq", + "name": "xlSample", + "fill": "$gray-800", + "content": "The quick brown fox jumps over the lazy dog", + "fontFamily": "Inter", + "fontSize": "$font-xl", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cEP3F", + "name": "xxl", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "imnZn", + "name": "xxlLabel", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 100, + "content": "2xl (24px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "r6U7l", + "name": "xxlSample", + "fill": "$gray-800", + "content": "The quick brown fox jumps over the lazy dog", + "fontFamily": "Inter", + "fontSize": "$font-2xl", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TokOJ", + "name": "xxxxl", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sGtp6", + "name": "xxxxlLabel", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 100, + "content": "4xl (36px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "v3V9m", + "name": "xxxxlSample", + "fill": "$gray-800", + "content": "The quick brown fox", + "fontFamily": "Inter", + "fontSize": "$font-4xl", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "seElz", + "name": "weightLabel", + "fill": "$gray-600", + "content": "Font Weights", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "YTWPR", + "name": "weightRow", + "width": "fill_container", + "gap": 32, + "children": [ + { + "type": "frame", + "id": "cr5Kx", + "name": "w400", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "WGHV2", + "name": "w400sample", + "fill": "$gray-800", + "content": "Normal (400)", + "fontFamily": "Inter", + "fontSize": 16 + }, + { + "type": "text", + "id": "fZFir", + "name": "w400sub", + "fill": "$gray-400", + "content": "Body text, descriptions", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bYKdO", + "name": "w500", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "6FcFq", + "name": "w500sample", + "fill": "$gray-800", + "content": "Medium (500)", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "500" + }, + { + "type": "text", + "id": "Goeu6", + "name": "w500sub", + "fill": "$gray-400", + "content": "Labels, navigation", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "obuVP", + "name": "w600", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "xItp3", + "name": "w600sample", + "fill": "$gray-800", + "content": "Semibold (600)", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "oFRCF", + "name": "w600sub", + "fill": "$gray-400", + "content": "Subheadings, buttons", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "G06TB", + "name": "w700", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "4frr5", + "name": "w700sample", + "fill": "$gray-800", + "content": "Bold (700)", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "q51Y7", + "name": "w700sub", + "fill": "$gray-400", + "content": "Headings, emphasis", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "BIsjz", + "name": "Spacing Scale Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "CnvL4", + "name": "spacingTitle", + "fill": "$gray-800", + "content": "Spacing Scale", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "aJSJB", + "name": "spacingList", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "9N0p8", + "name": "s1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "u6gRT", + "name": "s1label", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 140, + "content": "spacing-1 (4px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "aknTh", + "name": "s1bar", + "fill": "$teal-400", + "width": 4, + "height": 24 + } + ] + }, + { + "type": "frame", + "id": "Xode7", + "name": "s2", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nPLms", + "name": "s2label", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 140, + "content": "spacing-2 (8px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "Pzuj7", + "name": "s2bar", + "fill": "$teal-400", + "width": 8, + "height": 24 + } + ] + }, + { + "type": "frame", + "id": "k4Ebu", + "name": "s3", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "33yhI", + "name": "s3label", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 140, + "content": "spacing-3 (12px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "1Uk0e", + "name": "s3bar", + "fill": "$teal-400", + "width": 12, + "height": 24 + } + ] + }, + { + "type": "frame", + "id": "zcjun", + "name": "s4", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iO3ga", + "name": "s4label", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 140, + "content": "spacing-4 (16px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "8QZxT", + "name": "s4bar", + "fill": "$teal-400", + "width": 16, + "height": 24 + } + ] + }, + { + "type": "frame", + "id": "8Sgt9", + "name": "s6", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "0Gqde", + "name": "s6label", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 140, + "content": "spacing-6 (24px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "kugiQ", + "name": "s6bar", + "fill": "$teal-400", + "width": 24, + "height": 24 + } + ] + }, + { + "type": "frame", + "id": "0SQdz", + "name": "s8", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "7H1pr", + "name": "s8label", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 140, + "content": "spacing-8 (32px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "RCWdy", + "name": "s8bar", + "fill": "$teal-400", + "width": 32, + "height": 24 + } + ] + }, + { + "type": "frame", + "id": "rsu7Y", + "name": "s12", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sgg5Z", + "name": "s12label", + "fill": "$gray-500", + "textGrowth": "fixed-width", + "width": 140, + "content": "spacing-12 (48px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "ug7mc", + "name": "s12bar", + "fill": "$teal-400", + "width": 48, + "height": 24 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "hZcc7", + "name": "Border Radius Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "D8etJ", + "name": "radiusTitle", + "fill": "$gray-800", + "content": "Border Radius", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "1nCqS", + "name": "radiusRow", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "CKveD", + "name": "rsm", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-sm", + "id": "xRvlw", + "name": "rsmRect", + "fill": "$teal-100", + "width": 80, + "height": 56, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$teal-500" + } + }, + { + "type": "text", + "id": "b1RH3", + "name": "rsmLabel", + "fill": "$gray-600", + "content": "sm (4px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Zxclp", + "name": "rmd", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-md", + "id": "K0x9U", + "name": "rmdRect", + "fill": "$teal-100", + "width": 80, + "height": 56, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$teal-500" + } + }, + { + "type": "text", + "id": "ArSsD", + "name": "rmdLabel", + "fill": "$gray-600", + "content": "md (6px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "G63aO", + "name": "rlg", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-lg", + "id": "GOMNk", + "name": "rlgRect", + "fill": "$teal-100", + "width": 80, + "height": 56, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$teal-500" + } + }, + { + "type": "text", + "id": "qORqD", + "name": "rlgLabel", + "fill": "$gray-600", + "content": "lg (8px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "NCAE1", + "name": "rxl", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-xl", + "id": "q4Qye", + "name": "rxlRect", + "fill": "$teal-100", + "width": 80, + "height": 56, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$teal-500" + } + }, + { + "type": "text", + "id": "2VZyX", + "name": "rxlLabel", + "fill": "$gray-600", + "content": "xl (12px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Hzs6v", + "name": "r2xl", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-2xl", + "id": "acfAE", + "name": "r2xlRect", + "fill": "$teal-100", + "width": 80, + "height": 56, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$teal-500" + } + }, + { + "type": "text", + "id": "kEqH7", + "name": "r2xlLabel", + "fill": "$gray-600", + "content": "2xl (16px)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Jg4ib", + "name": "rfull", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-full", + "id": "JVAe0", + "name": "rfullRect", + "fill": "$teal-100", + "width": 80, + "height": 56, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$teal-500" + } + }, + { + "type": "text", + "id": "1QHxf", + "name": "rfullLabel", + "fill": "$gray-600", + "content": "full (pill)", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "LjzGa", + "name": "Shadows Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "a8Prk", + "name": "shadowTitle", + "fill": "$gray-800", + "content": "Shadows", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "khBxx", + "name": "shadowRow", + "width": "fill_container", + "fill": "$gray-50", + "cornerRadius": "$radius-lg", + "gap": 32, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "8vEoY", + "name": "sxs", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-lg", + "id": "LRnTn", + "name": "sxsRect", + "fill": "$white", + "width": 140, + "height": 80, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000D", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 2 + } + }, + { + "type": "text", + "id": "ajRxM", + "name": "sxsLabel", + "fill": "$gray-600", + "content": "shadow-xs", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "0itXH", + "name": "ssm", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-lg", + "id": "AK1o9", + "name": "ssmRect", + "fill": "$white", + "width": 140, + "height": 80, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + } + }, + { + "type": "text", + "id": "KRGZV", + "name": "ssmLabel", + "fill": "$gray-600", + "content": "shadow-sm", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "eZOE5", + "name": "smd", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-lg", + "id": "fNIN8", + "name": "smdRect", + "fill": "$white", + "width": 140, + "height": 80, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 6 + } + }, + { + "type": "text", + "id": "tzSLk", + "name": "smdLabel", + "fill": "$gray-600", + "content": "shadow-md", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "uWHOz", + "name": "slg", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-lg", + "id": "9jBPZ", + "name": "slgRect", + "fill": "$white", + "width": 140, + "height": 80, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 10 + }, + "blur": 15 + } + }, + { + "type": "text", + "id": "WVBiF", + "name": "slgLabel", + "fill": "$gray-600", + "content": "shadow-lg", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "GB8Bn", + "name": "Button Variants Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "BBwKL", + "name": "btnTitle", + "fill": "$gray-800", + "content": "Button Variants", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "1BasI", + "name": "btnVariantLabel", + "fill": "$gray-600", + "content": "Variants", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "OyDAV", + "name": "btnVariantRow", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "S6634", + "name": "solidBtn", + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nAPd8", + "name": "solidLabel", + "fill": "$white", + "content": "Solid", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "Ng8dt", + "name": "solidDesc", + "fill": "$gray-400", + "content": "Primary action", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "iw0qJ", + "name": "outlineBtn", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$teal-600" + }, + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "3fRu1", + "name": "outlineLabel", + "fill": "$teal-600", + "content": "Outline", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "reZsS", + "name": "outlineDesc", + "fill": "$gray-400", + "content": "Secondary action", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "BNUj6", + "name": "ghostBtn", + "cornerRadius": "$radius-lg", + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IuKgs", + "name": "ghostLabel", + "fill": "$teal-600", + "content": "Ghost", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "GIuyY", + "name": "ghostDesc", + "fill": "$gray-400", + "content": "Tertiary action", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "YYGjw", + "name": "destructBtn", + "fill": "$red-600", + "cornerRadius": "$radius-lg", + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LpLFq", + "name": "destructLabel", + "fill": "$white", + "content": "Destructive", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "jvrrv", + "name": "destructDesc", + "fill": "$gray-400", + "content": "Danger action", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "Mm27b", + "name": "btnSizeLabel", + "fill": "$gray-600", + "content": "Sizes", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "MrXCy", + "name": "btnSizeRow", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "7SB2P", + "name": "xsBtn", + "fill": "$teal-600", + "cornerRadius": "$radius-md", + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "E4paZ", + "name": "xsBtnLabel", + "fill": "$white", + "content": "XS Button", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "PTg9o", + "name": "smBtn", + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "padding": [ + 8, + 14 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "78fYd", + "name": "smBtnLabel", + "fill": "$white", + "content": "SM Button", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "3DQJG", + "name": "mdBtn", + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "0AbQR", + "name": "mdBtnLabel", + "fill": "$white", + "content": "MD Button", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "sLIRv", + "name": "lgBtn", + "fill": "$teal-600", + "cornerRadius": "$radius-lg", + "padding": [ + 12, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "p8RoR", + "name": "lgBtnLabel", + "fill": "$white", + "content": "LG Button", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zFJId", + "x": 1750, + "y": 0, + "name": "Layout Patterns", + "width": 1800, + "fill": "$white", + "layout": "vertical", + "gap": 48, + "padding": 48, + "children": [ + { + "type": "text", + "id": "1ilBi", + "name": "sectionTitle", + "fill": "$gray-800", + "content": "Layout Patterns", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "text", + "id": "YP3Kp", + "name": "sectionDesc", + "fill": "$gray-500", + "content": "components/templates と components/ui で使われる共通レイアウトパターン", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "rectangle", + "id": "fNefj", + "name": "divider1", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "HuX01", + "name": "sideMenuTitle", + "fill": "$gray-800", + "content": "1. SideMenu — PCサイドバーナビゲーション (256px fixed)", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "pdu7B", + "name": "SideMenu Pattern", + "width": "fill_container", + "height": 560, + "fill": "$gray-50", + "cornerRadius": "$radius-lg", + "children": [ + { + "type": "frame", + "id": "un9a3", + "name": "Sidebar", + "width": 256, + "height": "fill_container", + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$gray-200" + }, + "layout": "vertical", + "padding": [ + 24, + 16 + ], + "children": [ + { + "type": "frame", + "id": "LaizJ", + "name": "logoArea", + "width": "fill_container", + "height": 48, + "gap": 8, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "mucnY", + "name": "logoIcon", + "width": 24, + "height": 24, + "iconFontName": "store", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "EBAfZ", + "name": "logoText", + "fill": "$gray-800", + "content": "ShiftManager", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "ZYogN", + "name": "shopSelect", + "width": "fill_container", + "height": 40, + "fill": "$gray-50", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ygIn1", + "name": "shopLabel", + "fill": "$gray-700", + "content": "渋谷店", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "Re5V6", + "name": "shopChevron", + "width": 16, + "height": 16, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$gray-400" + } + ] + }, + { + "type": "frame", + "id": "rNMq6", + "name": "spacer1", + "width": "fill_container", + "height": 16 + }, + { + "type": "frame", + "id": "yaGkH", + "name": "Nav Items", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "IHZVQ", + "name": "navActive", + "width": "fill_container", + "height": 40, + "fill": "$teal-50", + "cornerRadius": "$radius-md", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "PLdUe", + "name": "navActiveIcon", + "width": 20, + "height": 20, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "HtBFM", + "name": "navActiveText", + "fill": "$teal-600", + "content": "シフト", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "ph5pQ", + "name": "navItem1", + "width": "fill_container", + "height": 40, + "cornerRadius": "$radius-md", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "zyOtP", + "name": "navItem1Icon", + "width": 20, + "height": 20, + "iconFontName": "store", + "iconFontFamily": "lucide", + "fill": "$gray-500" + }, + { + "type": "text", + "id": "G9sWx", + "name": "navItem1Text", + "fill": "$gray-600", + "content": "店舗", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "nJdDR", + "name": "navItem2", + "width": "fill_container", + "height": 40, + "cornerRadius": "$radius-md", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "0hjqN", + "name": "navItem2Icon", + "width": 20, + "height": 20, + "iconFontName": "users", + "iconFontFamily": "lucide", + "fill": "$gray-500" + }, + { + "type": "text", + "id": "Dqd1A", + "name": "navItem2Text", + "fill": "$gray-600", + "content": "スタッフ", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lweXP", + "name": "navItem3", + "width": "fill_container", + "height": 40, + "cornerRadius": "$radius-md", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "zqQFE", + "name": "navItem3Icon", + "width": 20, + "height": 20, + "iconFontName": "layout-dashboard", + "iconFontFamily": "lucide", + "fill": "$gray-500" + }, + { + "type": "text", + "id": "jf2ek", + "name": "navItem3Text", + "fill": "$gray-600", + "content": "ダッシュボード", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HhyKR", + "name": "navItem4", + "width": "fill_container", + "height": 40, + "cornerRadius": "$radius-md", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ErYsX", + "name": "navItem4Icon", + "width": 20, + "height": 20, + "iconFontName": "settings", + "iconFontFamily": "lucide", + "fill": "$gray-500" + }, + { + "type": "text", + "id": "Ahf4v", + "name": "navItem4Text", + "fill": "$gray-600", + "content": "設定", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "h7Aiz", + "name": "spacerGrow", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "aiM7N", + "name": "logoutArea", + "width": "fill_container", + "height": 40, + "cornerRadius": "$radius-md", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QD26a", + "name": "logoutIcon", + "width": 20, + "height": 20, + "iconFontName": "log-out", + "iconFontFamily": "lucide", + "fill": "$red-500" + }, + { + "type": "text", + "id": "n1wax", + "name": "logoutText", + "fill": "$red-500", + "content": "ログアウト", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "v1mUz", + "name": "Main Content Area", + "width": "fill_container", + "height": "fill_container", + "fill": "$white", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "type": "text", + "id": "jyTGo", + "name": "mainTitle", + "fill": "$gray-800", + "content": "メインコンテンツエリア", + "fontFamily": "Inter", + "fontSize": "$font-2xl", + "fontWeight": "600" + }, + { + "type": "text", + "id": "VQVqm", + "name": "mainDesc", + "fill": "$gray-400", + "content": "fill_container で残りの幅を使用", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "ubL9p", + "name": "divider2", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "TBrgU", + "name": "bmTitle", + "fill": "$gray-800", + "content": "2. BottomMenu — モバイルボトムナビゲーション (60px fixed)", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "5HE5z", + "name": "BottomMenu Pattern", + "clip": true, + "width": 390, + "height": 740, + "fill": "$gray-50", + "cornerRadius": "$radius-lg", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "X3mvw", + "name": "Page Content", + "width": "fill_container", + "height": "fill_container", + "fill": "$white", + "layout": "vertical", + "gap": 16, + "padding": 16, + "children": [ + { + "type": "text", + "id": "1a8Lz", + "name": "bmContentTitle", + "fill": "$gray-800", + "content": "ページコンテンツ", + "fontFamily": "Inter", + "fontSize": "$font-lg", + "fontWeight": "600" + }, + { + "type": "text", + "id": "XSTt3", + "name": "bmContentDesc", + "fill": "$gray-400", + "content": "モバイル画面のメインコンテンツ領域", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DpkP7", + "name": "Bottom Navigation Bar", + "width": "fill_container", + "height": 60, + "fill": "$white", + "stroke": { + "align": "inside", + "thickness": { + "top": 1 + }, + "fill": "$gray-200" + }, + "justifyContent": "space_around", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BQ3Bm", + "name": "tab1", + "width": 60, + "layout": "vertical", + "gap": 2, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "4QcGS", + "name": "tab1Icon", + "width": 20, + "height": 20, + "iconFontName": "user", + "iconFontFamily": "lucide", + "fill": "$gray-400" + }, + { + "type": "text", + "id": "bpHqb", + "name": "tab1Label", + "fill": "$gray-400", + "content": "マイページ", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "o10sq", + "name": "tab2", + "width": 60, + "layout": "vertical", + "gap": 2, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ZQrul", + "name": "tab2Icon", + "width": 20, + "height": 20, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "dxodw", + "name": "tab2Label", + "fill": "$teal-600", + "content": "シフト", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "Jw4mr", + "name": "tab3", + "width": 60, + "layout": "vertical", + "gap": 2, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VVDKR", + "name": "tab3Icon", + "width": 20, + "height": 20, + "iconFontName": "store", + "iconFontFamily": "lucide", + "fill": "$gray-400" + }, + { + "type": "text", + "id": "SusLq", + "name": "tab3Label", + "fill": "$gray-400", + "content": "店舗", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "opx0e", + "name": "tab4", + "width": 60, + "layout": "vertical", + "gap": 2, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "04CSK", + "name": "tab4Icon", + "width": 20, + "height": 20, + "iconFontName": "users", + "iconFontFamily": "lucide", + "fill": "$gray-400" + }, + { + "type": "text", + "id": "g5geT", + "name": "tab4Label", + "fill": "$gray-400", + "content": "スタッフ", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "XlFDw", + "name": "tab5", + "width": 60, + "layout": "vertical", + "gap": 2, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "8MKpo", + "name": "tab5Icon", + "width": 20, + "height": 20, + "iconFontName": "menu", + "iconFontFamily": "lucide", + "fill": "$gray-400" + }, + { + "type": "text", + "id": "X5tIv", + "name": "tab5Label", + "fill": "$gray-400", + "content": "メニュー", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "U0WHa", + "name": "divider3", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "Sd0k9", + "name": "fcTitle", + "fill": "$gray-800", + "content": "3. FormCard — フォームセクションカード (icon + title + content)", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "5XHpj", + "name": "FormCard Pattern", + "width": 600, + "fill": "$white", + "cornerRadius": "$radius-lg", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 6 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "b3yHF", + "name": "fcHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 16, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "59S46", + "name": "fcHeaderLeft", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "6sDO6", + "name": "fcIcon", + "width": 20, + "height": 20, + "iconFontName": "user", + "iconFontFamily": "lucide", + "fill": "$gray-700" + }, + { + "type": "text", + "id": "CRydP", + "name": "fcHeaderTitle", + "fill": "$gray-800", + "content": "基本情報", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "JOEzu", + "name": "fcRightEl", + "fill": "$teal-50", + "cornerRadius": "$radius-md", + "padding": [ + 6, + 12 + ], + "children": [ + { + "type": "text", + "id": "rJfNK", + "name": "fcRightText", + "fill": "$teal-600", + "content": "編集", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "w7p10", + "name": "FormCard Body", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 24, + 24, + 24 + ], + "children": [ + { + "type": "frame", + "id": "LJpnH", + "name": "fcField1", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "UScid", + "name": "fcLabel1", + "fill": "$gray-700", + "content": "名前", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "GL0Bv", + "name": "fcInput1", + "width": "fill_container", + "height": 40, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YzCxH", + "name": "fcInput1Text", + "fill": "$gray-800", + "content": "山田 太郎", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VCc9E", + "name": "fcField2", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "ElTuY", + "name": "fcLabel2", + "fill": "$gray-700", + "content": "メールアドレス", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "DuLxv", + "name": "fcInput2", + "width": "fill_container", + "height": 40, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WQBEx", + "name": "fcInput2Text", + "fill": "$gray-800", + "content": "yamada@example.com", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "39glQ", + "name": "fcAnnotation", + "fill": "$gray-400", + "content": " p={{ base: 4, md: 6 }} / shadow: sm / borderWidth: 0", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "rectangle", + "id": "V5RnE", + "name": "divider4", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "XX7Yy", + "name": "dlgTitle", + "fill": "$gray-800", + "content": "4. Dialog — モーダルダイアログ (centered, form buttons)", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "12uf9", + "name": "Dialog Pattern", + "clip": true, + "width": 600, + "height": 400, + "fill": "#00000033", + "cornerRadius": "$radius-lg", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "CZsm4", + "x": 60, + "y": 40, + "name": "dlgOverlay", + "width": 480, + "fill": "$white", + "cornerRadius": "$radius-xl", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000033", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 20 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "7yRdG", + "name": "dlgHeader", + "width": "fill_container", + "padding": [ + 16, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "fuXzC", + "name": "dlgHeaderTitle", + "fill": "$gray-800", + "content": "スタッフを追加", + "fontFamily": "Inter", + "fontSize": "$font-lg", + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "xf7zA", + "name": "dlgClose", + "width": 20, + "height": 20, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$gray-400" + } + ] + }, + { + "type": "frame", + "id": "O3PHj", + "name": "dlgBody", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "66zIW", + "name": "dlgField", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "NOJxQ", + "name": "dlgLabel", + "fill": "$gray-700", + "content": "スタッフ名", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "U1RUs", + "name": "dlgInput", + "width": "fill_container", + "height": 40, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "SAGnv", + "name": "dlgPlaceholder", + "fill": "$gray-400", + "content": "名前を入力", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "RVhlZ", + "name": "dlgDivider", + "fill": "$gray-100", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "QTMEM", + "name": "dlgFooter", + "width": "fill_container", + "gap": 12, + "padding": [ + 12, + 24, + 16, + 24 + ], + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "NskIv", + "name": "dlgCancel", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "padding": [ + 8, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "R6xkk", + "name": "dlgCancelText", + "fill": "$gray-700", + "content": "キャンセル", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "ge3EC", + "name": "dlgSubmit", + "fill": "$teal-600", + "cornerRadius": "$radius-md", + "padding": [ + 8, + 16 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Ynsmq", + "name": "dlgSubmitText", + "fill": "$white", + "content": "送信", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "YKAmA", + "name": "divider5", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "W1xi1", + "name": "bsTitle", + "fill": "$gray-800", + "content": "5. BottomSheet — ボトムシート (placement: bottom, max-height: 60vh)", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "3nUkp", + "name": "BottomSheet Pattern", + "clip": true, + "width": 390, + "height": 600, + "fill": "#00000033", + "cornerRadius": "$radius-lg", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "ZJRts", + "x": 0, + "y": 240, + "name": "bsSheet", + "width": 390, + "height": 360, + "fill": "$white", + "cornerRadius": [ + 16, + 16, + 0, + 0 + ], + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "IXhPj", + "name": "bsHandle", + "width": "fill_container", + "height": 24, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": "$radius-full", + "id": "9IeZS", + "name": "bsHandleBar", + "fill": "$gray-300", + "width": 40, + "height": 4 + } + ] + }, + { + "type": "frame", + "id": "8TMFa", + "name": "bsHeader", + "width": "fill_container", + "padding": [ + 4, + 20, + 12, + 20 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ap3M6", + "name": "bsHeaderTitle", + "fill": "$gray-800", + "content": "店舗を選択", + "fontFamily": "Inter", + "fontSize": "$font-lg", + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "8c8em", + "name": "bsHeaderClose", + "width": 20, + "height": 20, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$gray-400" + } + ] + }, + { + "type": "frame", + "id": "DBNGX", + "name": "bsBody", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [ + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "Dttja", + "name": "bsItem1", + "width": "fill_container", + "height": 56, + "fill": "$teal-50", + "cornerRadius": "$radius-md", + "gap": 12, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ToLix", + "name": "bsItem1Icon", + "width": 20, + "height": 20, + "iconFontName": "store", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "NA2n5", + "name": "bsItem1Text", + "fill": "$teal-600", + "content": "渋谷店", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "ILaxw", + "name": "bsItem1Check", + "width": 18, + "height": 18, + "iconFontName": "check", + "iconFontFamily": "lucide", + "fill": "$teal-600" + } + ] + }, + { + "type": "frame", + "id": "mJHLZ", + "name": "bsItem2", + "width": "fill_container", + "height": 56, + "cornerRadius": "$radius-md", + "gap": 12, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "qzXNV", + "name": "bsItem2Icon", + "width": 20, + "height": 20, + "iconFontName": "store", + "iconFontFamily": "lucide", + "fill": "$gray-500" + }, + { + "type": "text", + "id": "b357R", + "name": "bsItem2Text", + "fill": "$gray-700", + "content": "新宿店", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qCrw2", + "name": "bsItem3", + "width": "fill_container", + "height": 56, + "cornerRadius": "$radius-md", + "gap": 12, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "znxkA", + "name": "bsItem3Icon", + "width": 20, + "height": 20, + "iconFontName": "store", + "iconFontFamily": "lucide", + "fill": "$gray-500" + }, + { + "type": "text", + "id": "roOLL", + "name": "bsItem3Text", + "fill": "$gray-700", + "content": "池袋店", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "za014", + "name": "divider6", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "b4slE", + "name": "titleSec", + "fill": "$gray-800", + "content": "6. Title / TitleTemplate — ページタイトル + パンくずリスト (responsive)", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "B86lz", + "name": "Title Patterns", + "width": "fill_container", + "gap": 48, + "children": [ + { + "type": "frame", + "id": "JRNSG", + "name": "PC: Breadcrumbs + Title", + "width": "fill_container", + "fill": "$gray-50", + "cornerRadius": "$radius-lg", + "layout": "vertical", + "gap": 12, + "padding": 24, + "children": [ + { + "type": "text", + "id": "5GsBY", + "name": "pcLabel", + "fill": "$gray-400", + "content": "PC版 (lg以上)", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "gQnwK", + "name": "bcRow", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Puvsp", + "name": "bc1", + "fill": "$teal-600", + "content": "ホーム", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "uUTUN", + "name": "bcSep1", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$gray-400" + }, + { + "type": "text", + "id": "5CF7O", + "name": "bc2", + "fill": "$teal-600", + "content": "スタッフ", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "Skd2V", + "name": "bcSep2", + "width": 14, + "height": 14, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "$gray-400" + }, + { + "type": "text", + "id": "InoV1", + "name": "bc3", + "fill": "$gray-500", + "content": "山田太郎", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "zIsGi", + "name": "pcTitleRow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Uv4F1", + "name": "pcTitleText", + "fill": "$gray-900", + "content": "スタッフ詳細", + "fontFamily": "Inter", + "fontSize": "$font-2xl", + "fontWeight": "600" + }, + { + "type": "frame", + "id": "2xuNA", + "name": "pcAction", + "fill": "$teal-600", + "cornerRadius": "$radius-md", + "gap": 6, + "padding": [ + 8, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "2RauO", + "name": "pcActionIcon", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$white" + }, + { + "type": "text", + "id": "7vZHg", + "name": "pcActionText", + "fill": "$white", + "content": "追加", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "yB1ip", + "name": "Mobile: Back + Title", + "width": 390, + "fill": "$gray-50", + "cornerRadius": "$radius-lg", + "layout": "vertical", + "gap": 12, + "padding": 24, + "children": [ + { + "type": "text", + "id": "2luBe", + "name": "mobileLabel", + "fill": "$gray-400", + "content": "モバイル版 (base)", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "961le", + "name": "mobileBack", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Wj088", + "name": "mobileBackIcon", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "$teal-600" + }, + { + "type": "text", + "id": "sywHA", + "name": "mobileBackText", + "fill": "$teal-600", + "content": "スタッフ", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "Pz3tw", + "name": "mobileTitleText", + "fill": "$gray-900", + "content": "スタッフ詳細", + "fontFamily": "Inter", + "fontSize": "$font-xl", + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "G27Rj", + "name": "divider7", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "lHfl4", + "name": "esTitle", + "fill": "$gray-800", + "content": "7. Empty State / Loading State — フィードバックUI", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "jxFEP", + "name": "State Patterns", + "width": "fill_container", + "gap": 48, + "children": [ + { + "type": "frame", + "id": "HkkSB", + "name": "Empty State", + "width": "fill_container", + "height": 400, + "fill": "$gray-50", + "cornerRadius": "$radius-lg", + "layout": "vertical", + "gap": 12, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ihR4d", + "name": "emptyIcon", + "width": 48, + "height": 48, + "iconFontName": "inbox", + "iconFontFamily": "lucide", + "fill": "$gray-300" + }, + { + "type": "text", + "id": "bojqt", + "name": "emptyTitle", + "fill": "$gray-600", + "content": "データがありません", + "fontFamily": "Inter", + "fontSize": "$font-md", + "fontWeight": "500" + }, + { + "type": "text", + "id": "Zb8GO", + "name": "emptyDesc", + "fill": "$gray-400", + "content": "新しいスタッフを追加してみましょう", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "4gsDf", + "name": "emptyAction", + "fill": "$teal-600", + "cornerRadius": "$radius-md", + "gap": 6, + "padding": [ + 8, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "fsHCE", + "name": "emptyActionIcon", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$white" + }, + { + "type": "text", + "id": "0hZCV", + "name": "emptyActionText", + "fill": "$white", + "content": "スタッフを追加", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "HEf23", + "name": "Loading State", + "width": "fill_container", + "height": 400, + "fill": "$gray-50", + "cornerRadius": "$radius-lg", + "layout": "vertical", + "gap": 16, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "xdQx1", + "name": "spinner", + "fill": "$transparent", + "width": 32, + "height": 32, + "stroke": { + "align": "center", + "thickness": 3, + "cap": "round", + "fill": "$teal-500" + } + }, + { + "type": "text", + "id": "3wPa7", + "name": "loadingText", + "fill": "$gray-500", + "content": "読み込み中...", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "ftBcZ", + "name": "divider8", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "GFKoc", + "name": "selTitle", + "fill": "$gray-800", + "content": "8. Select / Toast — フォーム要素とフィードバック", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "beZeb", + "name": "Select & Toast Patterns", + "width": "fill_container", + "gap": 48, + "children": [ + { + "type": "frame", + "id": "2loIZ", + "name": "Select Component", + "width": 300, + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "5PIIY", + "name": "selLabel", + "fill": "$gray-700", + "content": "ポジション", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "text", + "id": "1aOds", + "name": "selNote", + "fill": "$gray-400", + "content": "スタッフの担当ポジションを選択", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "SWc7N", + "name": "selInput", + "width": "fill_container", + "height": 40, + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-200" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ro5eZ", + "name": "selValue", + "fill": "$gray-400", + "content": "選択してください", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "fhqT8", + "name": "selChevron", + "width": 16, + "height": 16, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$gray-400" + } + ] + }, + { + "type": "frame", + "id": "PE3Kh", + "name": "selDropdown", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$gray-100" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 12 + }, + "layout": "vertical", + "padding": 4, + "children": [ + { + "type": "frame", + "id": "5pla6", + "name": "selOpt1", + "width": "fill_container", + "height": 36, + "fill": "$teal-50", + "cornerRadius": "$radius-sm", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "offLO", + "name": "selOpt1Text", + "fill": "$teal-600", + "content": "ホール", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "en8Qk", + "name": "selOpt2", + "width": "fill_container", + "height": 36, + "cornerRadius": "$radius-sm", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "4nakP", + "name": "selOpt2Text", + "fill": "$gray-700", + "content": "キッチン", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "tWE8W", + "name": "selOpt3", + "width": "fill_container", + "height": 36, + "cornerRadius": "$radius-sm", + "gap": 8, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wYwTR", + "name": "selOpt3Text", + "fill": "$gray-700", + "content": "レジ", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "PUVEm", + "name": "Toast Notifications", + "width": 360, + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "zEqVz", + "name": "toastLabel", + "fill": "$gray-400", + "content": "Toast (placement: top-start)", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "23np3", + "name": "toastSuccess", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$green-100" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 12 + }, + "gap": 12, + "padding": 12, + "children": [ + { + "type": "icon_font", + "id": "yimly", + "name": "tsIcon", + "width": 20, + "height": 20, + "iconFontName": "circle-check", + "iconFontFamily": "lucide", + "fill": "$green-500" + }, + { + "type": "frame", + "id": "74zCK", + "name": "tsContent", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "LMt3o", + "name": "tsTitle", + "fill": "$gray-800", + "content": "保存しました", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + }, + { + "type": "text", + "id": "MKv2h", + "name": "tsDesc", + "fill": "$gray-500", + "content": "スタッフ情報が更新されました", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "glbPS", + "name": "toastError", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$red-100" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 12 + }, + "gap": 12, + "padding": 12, + "children": [ + { + "type": "icon_font", + "id": "yAzUt", + "name": "teIcon", + "width": 20, + "height": 20, + "iconFontName": "circle-alert", + "iconFontFamily": "lucide", + "fill": "$red-500" + }, + { + "type": "frame", + "id": "4pwwA", + "name": "teContent", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "WccNq", + "name": "teTitle", + "fill": "$gray-800", + "content": "エラーが発生しました", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + }, + { + "type": "text", + "id": "CjtdO", + "name": "teDesc", + "fill": "$gray-500", + "content": "入力内容を確認してください", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "gNjbb", + "name": "toastLoading", + "width": "fill_container", + "fill": "$white", + "cornerRadius": "$radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$blue-100" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000001A", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 12 + }, + "gap": 12, + "padding": 12, + "children": [ + { + "type": "ellipse", + "id": "QNnTO", + "name": "tlSpinner", + "fill": "$transparent", + "width": 20, + "height": 20, + "stroke": { + "align": "center", + "thickness": 2, + "cap": "round", + "fill": "$blue-500" + } + }, + { + "type": "frame", + "id": "4BAsm", + "name": "tlContent", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zFssJ", + "name": "tlTitle", + "fill": "$gray-800", + "content": "処理中...", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "600" + }, + { + "type": "text", + "id": "5ciNL", + "name": "tlDesc", + "fill": "$gray-500", + "content": "しばらくお待ちください", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "oM1TH", + "name": "divider9", + "fill": "$gray-200", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "BzHdC", + "name": "cpTitle", + "fill": "$gray-800", + "content": "9. ColorPicker — ポジション色選択 (28x28 circles)", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "zReVL", + "name": "ColorPicker Pattern", + "width": 400, + "fill": "$gray-50", + "cornerRadius": "$radius-lg", + "layout": "vertical", + "gap": 12, + "padding": 24, + "children": [ + { + "type": "text", + "id": "2M3Z8", + "name": "cpLabel", + "fill": "$gray-700", + "content": "カラー", + "fontFamily": "Inter", + "fontSize": "$font-sm", + "fontWeight": "500" + }, + { + "type": "frame", + "id": "a23Mz", + "name": "cpRow", + "gap": 8, + "children": [ + { + "type": "ellipse", + "id": "GJWyb", + "name": "cp1", + "fill": "$position-blue", + "width": 28, + "height": 28, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "$gray-700" + } + }, + { + "type": "ellipse", + "id": "dfTlG", + "name": "cp2", + "fill": "$position-orange", + "width": 28, + "height": 28 + }, + { + "type": "ellipse", + "id": "6JdPy", + "name": "cp3", + "fill": "$position-green", + "width": 28, + "height": 28 + }, + { + "type": "ellipse", + "id": "z2Dc8", + "name": "cp4", + "fill": "$position-gray", + "width": 28, + "height": 28 + }, + { + "type": "ellipse", + "id": "FaMf0", + "name": "cp5", + "fill": "$red-500", + "width": 28, + "height": 28 + }, + { + "type": "ellipse", + "id": "wvHDA", + "name": "cp6", + "fill": "$purple-600", + "width": 28, + "height": 28 + } + ] + }, + { + "type": "text", + "id": "ZA2Lt", + "name": "cpNote", + "fill": "$gray-400", + "content": "選択中: blue (#3b82f6) — border: 2px solid #374151", + "fontFamily": "Inter", + "fontSize": "$font-xs", + "fontWeight": "normal" + } + ] + } + ] + } + ], + "variables": { + "black": { + "type": "color", + "value": "#000000" + }, + "blue-100": { + "type": "color", + "value": "#dbeafe" + }, + "blue-50": { + "type": "color", + "value": "#eff6ff" + }, + "blue-500": { + "type": "color", + "value": "#3b82f6" + }, + "blue-600": { + "type": "color", + "value": "#2563eb" + }, + "blue-700": { + "type": "color", + "value": "#1d4ed8" + }, + "font-2xl": { + "type": "number", + "value": 24 + }, + "font-4xl": { + "type": "number", + "value": 36 + }, + "font-lg": { + "type": "number", + "value": 18 + }, + "font-md": { + "type": "number", + "value": 16 + }, + "font-sm": { + "type": "number", + "value": 14 + }, + "font-xl": { + "type": "number", + "value": 20 + }, + "font-xs": { + "type": "number", + "value": 12 + }, + "gray-100": { + "type": "color", + "value": "#f3f4f6" + }, + "gray-200": { + "type": "color", + "value": "#e5e7eb" + }, + "gray-300": { + "type": "color", + "value": "#d1d5db" + }, + "gray-400": { + "type": "color", + "value": "#9ca3af" + }, + "gray-50": { + "type": "color", + "value": "#f9fafb" + }, + "gray-500": { + "type": "color", + "value": "#6b7280" + }, + "gray-600": { + "type": "color", + "value": "#4b5563" + }, + "gray-700": { + "type": "color", + "value": "#374151" + }, + "gray-800": { + "type": "color", + "value": "#1f2937" + }, + "gray-900": { + "type": "color", + "value": "#111827" + }, + "green-100": { + "type": "color", + "value": "#dcfce7" + }, + "green-50": { + "type": "color", + "value": "#f0fdf4" + }, + "green-500": { + "type": "color", + "value": "#22c55e" + }, + "green-600": { + "type": "color", + "value": "#16a34a" + }, + "orange-100": { + "type": "color", + "value": "#ffedd5" + }, + "orange-50": { + "type": "color", + "value": "#fff7ed" + }, + "orange-500": { + "type": "color", + "value": "#f97316" + }, + "orange-600": { + "type": "color", + "value": "#ea580c" + }, + "position-blue": { + "type": "color", + "value": "#3b82f6" + }, + "position-gray": { + "type": "color", + "value": "#6b7280" + }, + "position-green": { + "type": "color", + "value": "#10b981" + }, + "position-orange": { + "type": "color", + "value": "#f97316" + }, + "purple-50": { + "type": "color", + "value": "#faf5ff" + }, + "purple-600": { + "type": "color", + "value": "#9333ea" + }, + "radius-2xl": { + "type": "number", + "value": 16 + }, + "radius-full": { + "type": "number", + "value": 9999 + }, + "radius-lg": { + "type": "number", + "value": 8 + }, + "radius-md": { + "type": "number", + "value": 6 + }, + "radius-sm": { + "type": "number", + "value": 4 + }, + "radius-xl": { + "type": "number", + "value": 12 + }, + "red-100": { + "type": "color", + "value": "#fee2e2" + }, + "red-50": { + "type": "color", + "value": "#fef2f2" + }, + "red-500": { + "type": "color", + "value": "#ef4444" + }, + "red-600": { + "type": "color", + "value": "#dc2626" + }, + "red-700": { + "type": "color", + "value": "#b91c1c" + }, + "shadow-lg": { + "type": "string", + "value": "0 10px 15px rgba(0,0,0,0.1)" + }, + "shadow-md": { + "type": "string", + "value": "0 4px 6px rgba(0,0,0,0.1)" + }, + "shadow-sm": { + "type": "string", + "value": "0 1px 3px rgba(0,0,0,0.1)" + }, + "shadow-xs": { + "type": "string", + "value": "0 1px 2px rgba(0,0,0,0.05)" + }, + "spacing-1": { + "type": "number", + "value": 4 + }, + "spacing-12": { + "type": "number", + "value": 48 + }, + "spacing-2": { + "type": "number", + "value": 8 + }, + "spacing-3": { + "type": "number", + "value": 12 + }, + "spacing-4": { + "type": "number", + "value": 16 + }, + "spacing-6": { + "type": "number", + "value": 24 + }, + "spacing-8": { + "type": "number", + "value": 32 + }, + "teal-100": { + "type": "color", + "value": "#ccfbf1" + }, + "teal-200": { + "type": "color", + "value": "#99f6e4" + }, + "teal-300": { + "type": "color", + "value": "#5eead4" + }, + "teal-400": { + "type": "color", + "value": "#2dd4bf" + }, + "teal-50": { + "type": "color", + "value": "#f0fdfa" + }, + "teal-500": { + "type": "color", + "value": "#14b8a6" + }, + "teal-600": { + "type": "color", + "value": "#0d9488" + }, + "teal-700": { + "type": "color", + "value": "#0f766e" + }, + "teal-800": { + "type": "color", + "value": "#115e59" + }, + "teal-900": { + "type": "color", + "value": "#134e4a" + }, + "transparent": { + "type": "color", + "value": "#00000000" + }, + "white": { + "type": "color", + "value": "#ffffff" + } + } +} \ No newline at end of file diff --git "a/doc/design/\345\277\205\350\246\201\344\272\272\345\223\241\350\250\255\345\256\232_\343\203\207\343\202\266\343\202\244\343\203\263\344\276\235\351\240\274.md" "b/doc/design/\345\277\205\350\246\201\344\272\272\345\223\241\350\250\255\345\256\232_\343\203\207\343\202\266\343\202\244\343\203\263\344\276\235\351\240\274.md" new file mode 100644 index 00000000..aee8c569 --- /dev/null +++ "b/doc/design/\345\277\205\350\246\201\344\272\272\345\223\241\350\250\255\345\256\232_\343\203\207\343\202\266\343\202\244\343\203\263\344\276\235\351\240\274.md" @@ -0,0 +1,106 @@ +# 必要人員設定画面 デザイン依頼 + +## コンポーネント +Chakra UI V3 + +## この画面の目的 + +店舗の「時間帯 × ポジション × 曜日」ごとに、必要なスタッフ人数を設定する画面。 +ここで設定した人数が、シフト編集画面の充足度判定(足りてる/足りてない)の基準値になる。 + +## 前提情報 + +- **ユーザー**: 飲食店などの店舗管理者(ITリテラシーは高くない想定) +- **対応デバイス**: PC / SP(レスポンシブ) +- **曜日パターン**: 月〜日+祝日 の8パターンがあるが、多くの店舗では平日はほぼ同じ・土日祝が違う程度 +- **ポジション**: 店舗ごとに登録済み(例: ホール、キッチン、レジなど。1〜5個程度) +- **時間帯**: 店舗の営業時間内の各1時間(例: 9:00〜22:00なら13スロット) +- **人員数の範囲**: 各セル 0〜10人 + +## 画面構成 + +### 2つのモード切替 + +「かんたん設定」と「詳細設定」の2モードを切り替えられる。 + +#### かんたん設定(デフォルト) + +- **タブ**: 「平日」「週末」の2タブのみ + - 平日 = 月〜金 + - 週末 = 土日祝 +- **テーブル**: 行=時間帯、列=ポジション のマトリックス +- **操作**: 各セルでStepper(+/−)で人数を設定 +- **保存**: 「平日」を保存 → 月〜金すべてに同じ値を適用。「週末」を保存 → 土日祝すべてに適用 +- **想定ユーザー**: ほとんどの店舗管理者(平日/週末の2パターンで十分な人) + +#### 詳細設定 + +- **タブ**: 月/火/水/木/金/土/日/祝 の8タブ +- **テーブル**: かんたん設定と同じマトリックス +- **操作**: 各セルでStepper(+/−)で人数を設定 +- **保存**: その曜日だけに適用 +- **想定ユーザー**: 金曜だけ違う、祝日は別にしたい、など細かく設定したい人 + +#### モード間の関係 + +- かんたん設定で保存 → 対象の全曜日を上書き(詳細で個別に変えた値も上書きされる) +- 詳細設定で保存 → その曜日だけ上書き +- どちらも同じデータを編集している(見せ方とスコープが違うだけ) + +### 一覧ビュー(ヒートマップ) + +- 全曜日 × 全時間帯を色の濃淡で俯瞰表示 +- 人員数が多い時間帯ほど色が濃い(5段階グラデーション) +- 曜日クリックで詳細設定の該当曜日に遷移 +- 編集不可(閲覧専用) + +## 補助機能 + +### 他曜日へコピー + +- 現在の曜日の設定を、選択した複数の曜日にまとめてコピーする機能 +- 対象曜日はチェックボックスで選択(「平日全部」「休日全部」「全て」の一括選択あり) + +### AI自動生成 + +- 店舗タイプ・来客数を入力すると、AIが時間帯別ポジション別の人員数を自動提案 +- 提案結果は編集可能(そのまま保存もOK) + +### 初期設定ウィザード(初回のみ) + +- データが1件もないときに表示される導入フロー +- Step 1: AI生成 or スキップ +- Step 2: 生成された人員数を編集+適用する曜日を選択 → 一括保存 +- 完了後はメインビューに遷移 + +## テーブルの具体例 + +``` + ホール キッチン レジ + 9:00 1 1 0 + 10:00 1 1 0 + 11:00 3 2 1 ← ランチ帯で増員 + 12:00 3 2 1 + 13:00 3 2 1 + 14:00 2 1 0 + 15:00 1 1 0 + ... + 18:00 3 2 1 ← ディナー帯で増員 + 19:00 3 2 1 + 20:00 2 1 1 + 21:00 1 1 0 +``` + +## SP版での考慮点 + +- テーブルが横に長くなるので、アコーディオンやカード形式など工夫が必要 +- ダイアログ → BottomSheet に置き換え +- 時間帯をグループ化して折りたたみ(朝/昼/午後/夕方/夜) + +## デザインで特に考えてほしいポイント + +1. **かんたん設定 ↔ 詳細設定 の切り替えUI**: ユーザーが迷わない導線 +2. **テーブルの視認性**: 時間帯×ポジションのマトリックスが見やすく、編集しやすいこと +3. **変更の分かりやすさ**: どのセルを変更したか一目で分かるハイライト +4. **SP版のテーブル**: 限られた横幅でどう表現するか +5. **ヒートマップ**: 全体を俯瞰できる一覧ビューのデザイン diff --git a/src/components/features/Shift/PeakBandSettings/ModeConfirmDialog.tsx b/src/components/features/Shift/PeakBandSettings/ModeConfirmDialog.tsx new file mode 100644 index 00000000..fc068a30 --- /dev/null +++ b/src/components/features/Shift/PeakBandSettings/ModeConfirmDialog.tsx @@ -0,0 +1,44 @@ +import { Flex, Icon, Text } from "@chakra-ui/react"; +import { LuInfo, LuTriangleAlert } from "react-icons/lu"; +import { Dialog } from "@/src/components/ui/Dialog"; + +type ModeConfirmDialogProps = { + isOpen: boolean; + onOpenChange: (details: { open: boolean }) => void; + onConfirm: () => void; + onCancel: () => void; +}; + +export const ModeConfirmDialog = ({ isOpen, onOpenChange, onConfirm, onCancel }: ModeConfirmDialogProps) => { + return ( + + + + + + モード切替の確認 + + + + かんたんモードに切り替えると、曜日ごとの個別設定は「平日」「休日」の設定で上書きされます。 + + + + + この操作は取り消せません + + + + + ); +}; diff --git a/src/components/features/Shift/PeakBandSettings/index.stories.tsx b/src/components/features/Shift/PeakBandSettings/index.stories.tsx new file mode 100644 index 00000000..9b6bb5cb --- /dev/null +++ b/src/components/features/Shift/PeakBandSettings/index.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { withDummyRouter } from "../../../../../.storybook/withDummyRouter"; +import type { InitialDayData } from "./index"; +import { PeakBandSettings } from "./index"; + +const meta = { + title: "features/Shift/PeakBandSettings", + component: PeakBandSettings, + decorators: [withDummyRouter("/")], + parameters: { + layout: "padded", + }, + args: { + shopId: "shop-1", + shopName: "渋谷センター店", + onSave: async (params) => { + console.log("onSave called:", params); + }, + isSaving: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// 空の状態 +export const Default: Story = {}; + +// かんたんモード(初期データあり) +const simpleInitialData: InitialDayData[] = [ + // 平日(月〜金)は同じ設定 + ...[1, 2, 3, 4, 5].map((day) => ({ + dayOfWeek: day, + peakBands: [ + { startTime: "11:00", endTime: "14:00", requiredCount: 3 }, + { startTime: "17:00", endTime: "21:00", requiredCount: 4 }, + ], + minimumStaff: 2, + })), + // 休日(日,土,祝)は同じ設定 + ...[0, 6, 7].map((day) => ({ + dayOfWeek: day, + peakBands: [ + { startTime: "11:00", endTime: "14:00", requiredCount: 4 }, + { startTime: "17:00", endTime: "21:00", requiredCount: 5 }, + ], + minimumStaff: 3, + })), +]; + +export const SimpleMode: Story = { + args: { + initialData: simpleInitialData, + }, +}; + +// 詳細モード(曜日ごとに異なる設定) +const detailedInitialData: InitialDayData[] = [ + { + dayOfWeek: 1, + peakBands: [{ startTime: "11:00", endTime: "14:00", requiredCount: 3 }], + minimumStaff: 2, + }, + { + dayOfWeek: 5, + peakBands: [ + { startTime: "11:00", endTime: "14:00", requiredCount: 4 }, + { startTime: "17:00", endTime: "22:00", requiredCount: 5 }, + ], + minimumStaff: 2, + }, +]; + +export const DetailedMode: Story = { + args: { + initialData: detailedInitialData, + }, +}; + +// 保存中 +export const Saving: Story = { + args: { + initialData: simpleInitialData, + isSaving: true, + }, +}; diff --git a/src/components/features/Shift/PeakBandSettings/index.tsx b/src/components/features/Shift/PeakBandSettings/index.tsx index ba4856e8..ffbd2b99 100644 --- a/src/components/features/Shift/PeakBandSettings/index.tsx +++ b/src/components/features/Shift/PeakBandSettings/index.tsx @@ -1,50 +1,139 @@ -import { Box, Button, Container, Flex, Heading, Icon, IconButton, Input, Text, VStack } from "@chakra-ui/react"; -import { useCallback, useState } from "react"; -import { LuPlus, LuSave, LuSettings, LuTrash2 } from "react-icons/lu"; +import { Box, Button, Container, Flex, Heading, Icon, IconButton, Input, Tabs, Text, VStack } from "@chakra-ui/react"; +import { useCallback, useMemo, useState } from "react"; +import { LuCircle, LuMinus, LuPlus, LuTrash2 } from "react-icons/lu"; +import { useDialog } from "@/src/components/ui/Dialog"; import { Title } from "@/src/components/ui/Title"; +import type { PeakBand } from "../ShiftForm/types"; +import { HOLIDAY_DAYS, WEEKDAY_DAYS } from "../StaffingRequirement/constants"; import { DayTabs } from "../StaffingRequirement/DayTabs"; - -type PeakBand = { - name: string; - startTime: string; - endTime: string; - requiredCount: number; -}; +import { ModeConfirmDialog } from "./ModeConfirmDialog"; type DaySettings = { peakBands: PeakBand[]; minimumStaff: number; }; +export type InitialDayData = { + dayOfWeek: number; + peakBands?: PeakBand[]; + minimumStaff?: number; +}; + +type Mode = "simple" | "detailed"; +type SimpleGroup = "weekday" | "holiday"; + type PeakBandSettingsProps = { shopId: string; shopName: string; - onSave: (params: { dayOfWeek: number; peakBands: PeakBand[]; minimumStaff: number }) => Promise; + initialData?: InitialDayData[]; + onSave: (params: { dayOfWeeks: readonly number[]; peakBands: PeakBand[]; minimumStaff: number }) => Promise; isSaving?: boolean; }; -const DEFAULT_PEAK_BAND: PeakBand = { name: "", startTime: "11:00", endTime: "14:00", requiredCount: 3 }; +// ============================================================ +// 定数 +// ============================================================ + +const DEFAULT_PEAK_BAND: PeakBand = { startTime: "11:00", endTime: "14:00", requiredCount: 3 }; const DEFAULT_DAY_SETTINGS: DaySettings = { peakBands: [], minimumStaff: 1 }; -export const PeakBandSettings = ({ shopId, shopName, onSave, isSaving = false }: PeakBandSettingsProps) => { +// ============================================================ +// ユーティリティ +// ============================================================ + +/** 初期データからdaySettingsMapを構築 */ +const buildDaySettingsMap = (data: InitialDayData[]): Record => { + const map: Record = {}; + for (const d of data) { + if (d.peakBands && d.peakBands.length > 0) { + map[d.dayOfWeek] = { + peakBands: d.peakBands, + minimumStaff: d.minimumStaff ?? 1, + }; + } + } + return map; +}; + +/** 初期データからモードを推定 */ +const inferMode = (data: InitialDayData[]): Mode => { + if (data.length === 0) return "simple"; + + const hasPeakBands = data.filter((d) => d.peakBands && d.peakBands.length > 0); + if (hasPeakBands.length === 0) return "simple"; + + // 平日が全て同じ設定で、休日も全て同じ設定ならsimple + const weekdays = hasPeakBands.filter((d) => (WEEKDAY_DAYS as readonly number[]).includes(d.dayOfWeek)); + const holidays = hasPeakBands.filter((d) => (HOLIDAY_DAYS as readonly number[]).includes(d.dayOfWeek)); + + const allSame = (items: InitialDayData[]) => { + if (items.length <= 1) return true; + const first = JSON.stringify({ peakBands: items[0].peakBands, minimumStaff: items[0].minimumStaff }); + return items.every( + (item) => JSON.stringify({ peakBands: item.peakBands, minimumStaff: item.minimumStaff }) === first, + ); + }; + + return allSame(weekdays) && allSame(holidays) ? "simple" : "detailed"; +}; + +/** simpleグループの代表dayOfWeekを取得 */ +const getRepresentativeDay = (group: SimpleGroup): number => (group === "weekday" ? 1 : 0); + +/** simpleグループのdayOfWeek配列を取得 */ +const getGroupDays = (group: SimpleGroup): readonly number[] => (group === "weekday" ? WEEKDAY_DAYS : HOLIDAY_DAYS); + +// ============================================================ +// メインコンポーネント +// ============================================================ + +export const PeakBandSettings = ({ + shopId, + shopName, + initialData = [], + onSave, + isSaving = false, +}: PeakBandSettingsProps) => { + const [mode, setMode] = useState(() => inferMode(initialData)); const [selectedDay, setSelectedDay] = useState(1); - const [daySettingsMap, setDaySettingsMap] = useState>({}); + const [selectedGroup, setSelectedGroup] = useState("weekday"); + const [daySettingsMap, setDaySettingsMap] = useState>(() => + buildDaySettingsMap(initialData), + ); const [hasChanges, setHasChanges] = useState(false); + const confirmDialog = useDialog(); + + // 現在の編集対象dayOfWeek + const activeDayOfWeek = mode === "simple" ? getRepresentativeDay(selectedGroup) : selectedDay; + const currentSettings = daySettingsMap[activeDayOfWeek] ?? DEFAULT_DAY_SETTINGS; + + // 設定済み曜日の一覧 + const configuredDays = useMemo( + () => + Object.keys(daySettingsMap) + .map(Number) + .filter((day) => { + const s = daySettingsMap[day]; + return s && s.peakBands.length > 0; + }), + [daySettingsMap], + ); - const currentSettings = daySettingsMap[selectedDay] ?? DEFAULT_DAY_SETTINGS; + // ============================================================ + // ハンドラ + // ============================================================ const updateCurrentDay = useCallback( (updater: (prev: DaySettings) => DaySettings) => { setDaySettingsMap((prev) => ({ ...prev, - [selectedDay]: updater(prev[selectedDay] ?? DEFAULT_DAY_SETTINGS), + [activeDayOfWeek]: updater(prev[activeDayOfWeek] ?? DEFAULT_DAY_SETTINGS), })); setHasChanges(true); }, - [selectedDay], + [activeDayOfWeek], ); - // ピーク帯追加 const handleAddBand = useCallback(() => { updateCurrentDay((prev) => ({ ...prev, @@ -52,7 +141,6 @@ export const PeakBandSettings = ({ shopId, shopName, onSave, isSaving = false }: })); }, [updateCurrentDay]); - // ピーク帯削除 const handleRemoveBand = useCallback( (index: number) => { updateCurrentDay((prev) => ({ @@ -63,7 +151,6 @@ export const PeakBandSettings = ({ shopId, shopName, onSave, isSaving = false }: [updateCurrentDay], ); - // ピーク帯フィールド更新 const handleBandChange = useCallback( (index: number, field: keyof PeakBand, value: string | number) => { updateCurrentDay((prev) => ({ @@ -74,19 +161,18 @@ export const PeakBandSettings = ({ shopId, shopName, onSave, isSaving = false }: [updateCurrentDay], ); - // 最低人員更新 const handleMinimumStaffChange = useCallback( (value: number) => { - updateCurrentDay((prev) => ({ ...prev, minimumStaff: value })); + updateCurrentDay((prev) => ({ ...prev, minimumStaff: Math.max(0, value) })); }, [updateCurrentDay], ); - // 保存 const handleSave = useCallback(async () => { try { + const dayOfWeeks = mode === "simple" ? getGroupDays(selectedGroup) : [selectedDay]; await onSave({ - dayOfWeek: selectedDay, + dayOfWeeks, peakBands: currentSettings.peakBands, minimumStaff: currentSettings.minimumStaff, }); @@ -94,180 +180,340 @@ export const PeakBandSettings = ({ shopId, shopName, onSave, isSaving = false }: } catch { // エラーは親でハンドリング } - }, [selectedDay, currentSettings, onSave]); + }, [mode, selectedGroup, selectedDay, currentSettings, onSave]); - // 設定済み曜日の一覧 - const configuredDays = Object.keys(daySettingsMap) - .map(Number) - .filter((day) => { - const settings = daySettingsMap[day]; - return settings && settings.peakBands.length > 0; - }); + // モード切替 + const handleModeChange = useCallback( + (newMode: Mode) => { + if (newMode === mode) return; + if (newMode === "simple") { + // 詳細→かんたん: 確認ダイアログ表示 + confirmDialog.open(); + } else { + // かんたん→詳細: そのまま切替 + setMode("detailed"); + setSelectedDay(1); + } + }, + [mode, confirmDialog], + ); + + const handleConfirmModeSwitch = useCallback(() => { + setMode("simple"); + setSelectedGroup("weekday"); + confirmDialog.close(); + }, [confirmDialog]); + + // ============================================================ + // レンダリング + // ============================================================ return ( - + {/* ヘッダー */} - - <Flex align="center" gap={3}> - <Flex p={{ base: 2, md: 3 }} bg="purple.50" borderRadius="lg"> - <Icon as={LuSettings} boxSize={6} color="purple.600" /> - </Flex> - <Box> - <Heading as="h2" size="xl" color="gray.900"> - 必要人員設定 - </Heading> - <Text color="gray.500" fontSize="sm"> - {shopName} - </Text> - </Box> - </Flex> + <Title prev={{ url: `/shops/${shopId}/shifts`, label: "シフト管理" }}> + <Heading as="h2" size={{ base: "lg", md: "xl" }} color="gray.900"> + 必要人員設定 + </Heading> + <Text color="gray.500" fontSize="sm" display={{ base: "none", md: "block" }}> + {shopName} + </Text> - {/* 曜日タブ */} + {/* モード切替タブ */} + handleModeChange(e.value as Mode)} + variant="enclosed" + size="sm" + mb={2} + > + + + かんたんモード + + + 詳細モード + + + + + {/* モード説明 */} + + {mode === "simple" + ? "平日・休日の2パターンでかんたんに設定できます" + : "曜日ごとに細かくピーク帯と人数を設定できます"} + + + {/* 曜日セレクタ */} - + {mode === "simple" ? ( + + ) : ( + + )} {/* ピーク帯設定 */} - {/* ピーク帯リスト */} - - - - ピーク帯 - - - - - {currentSettings.peakBands.length === 0 ? ( - - - ピーク帯が設定されていません - - - 「追加」ボタンからランチ帯・ディナー帯などを設定してください - - - ) : ( - - {currentSettings.peakBands.map((band, index) => ( - - handleBandChange(index, "name", e.target.value)} - w={{ base: "100%", md: "140px" }} - /> - - handleBandChange(index, "startTime", e.target.value)} - w="120px" - /> - - 〜 - - handleBandChange(index, "endTime", e.target.value)} - w="120px" - /> - - - handleBandChange(index, "requiredCount", Number(e.target.value))} - w="70px" - textAlign="center" - /> - - 人 - - - handleRemoveBand(index)} - > - - - - ))} - - )} - + {/* SP版: セクションヘッダー */} + + ピーク帯設定 + - {/* 最低人員 */} - - - 最低人員 - - - - 常に最低 - - handleMinimumStaffChange(Number(e.target.value))} - w="70px" - textAlign="center" + {/* ピーク帯リスト */} + + {currentSettings.peakBands.map((band, index) => ( + - - 人を配置 - - - - - {/* 保存バー */} - - {hasChanges && ( - - 未保存の変更があります - - )} - + + {/* 最低人員 */} + + + {/* 保存バー */} + + + + + {/* モード切替確認ダイアログ */} + ); }; + +// ============================================================ +// サブコンポーネント: かんたんモードのタブ(平日/休日) +// ============================================================ + +const SimpleDayTabs = ({ + selectedGroup, + onChange, +}: { + selectedGroup: SimpleGroup; + onChange: (group: SimpleGroup) => void; +}) => ( + onChange(e.value as SimpleGroup)} + variant="line" + colorPalette="teal" + size="sm" + > + + + 平日 + + + 休日 + + + +); + +// ============================================================ +// サブコンポーネント: ピーク帯行 +// ============================================================ + +const PeakBandRow = ({ + band, + index, + onBandChange, + onRemove, +}: { + band: PeakBand; + index: number; + onBandChange: (index: number, field: keyof PeakBand, value: string | number) => void; + onRemove: (index: number) => void; +}) => ( + + {/* PC版レイアウト */} + + + 時間帯 + + onBandChange(index, "startTime", e.target.value)} + w="130px" + /> + + 〜 + + onBandChange(index, "endTime", e.target.value)} + w="130px" + /> + + 必要人数 + + onBandChange(index, "requiredCount", Number(e.target.value))} + w="70px" + textAlign="center" + /> + + 人 + + + onRemove(index)}> + + + + + {/* SP版レイアウト */} + + {/* 時間帯 + 削除ボタン */} + + onBandChange(index, "startTime", e.target.value)} + flex={1} + /> + + 〜 + + onBandChange(index, "endTime", e.target.value)} + flex={1} + /> + onRemove(index)}> + + + + + {/* 人数ステッパー */} + + onBandChange(index, "requiredCount", Math.max(1, band.requiredCount - 1))} + disabled={band.requiredCount <= 1} + > + + + + {band.requiredCount} + + onBandChange(index, "requiredCount", band.requiredCount + 1)} + > + + + + 人 + + + + +); + +// ============================================================ +// サブコンポーネント: 最低人員セクション +// ============================================================ + +const MinimumStaffSection = ({ value, onChange }: { value: number; onChange: (value: number) => void }) => ( + + {/* SP版: セクションヘッダー */} + + + + 最低人員設定 + + + + + + 常に最低 + + + {/* PC版: number input */} + onChange(Number(e.target.value))} + w="70px" + textAlign="center" + /> + + {/* SP版: stepper */} + + onChange(Math.max(0, value - 1))} + disabled={value <= 0} + > + + + + {value} + + onChange(value + 1)} + > + + + + + + 人を配置 + + + +); diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx index 1e136079..eab6f2f1 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx @@ -34,14 +34,14 @@ export const PeakBandAlert = ({ shifts, date, peakBands, minimumStaff }: PeakBan > {/* ピーク帯ステータス */} {status.peakBandStatuses.map((band) => ( - + {band.isSatisfied ? ( ) : ( )} - {band.name || `${band.startTime}〜${band.endTime}`} + {band.startTime}〜{band.endTime} {!band.isSatisfied && ( diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx index 32ff8ac7..afeac7d6 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftGrid/ShiftBar.tsx @@ -77,12 +77,7 @@ export const ShiftBar = ({ borderRadius="md" top="50%" transform="translateY(-50%)" - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - onClick={(e) => onClick(shift.id, null, e)} - cursor="pointer" - transition="all 0.15s" - _hover={{ borderColor: "gray.500" }} + pointerEvents="none" zIndex={1} /> )} @@ -166,7 +161,7 @@ export const ShiftBar = ({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={(e) => onClick(shift.id, pos.id, e)} - cursor="pointer" + cursor="inherit" transition={isResizing ? "width 0.05s ease-out, left 0.05s ease-out" : "all 0.15s"} opacity={0.9} _hover={{ opacity: 1, boxShadow: "0 0 0 2px rgba(0,0,0,0.15)" }} diff --git a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftPopover.tsx b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftPopover.tsx index 516e9d2d..bc7748fb 100644 --- a/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftPopover.tsx +++ b/src/components/features/Shift/ShiftForm/pc/DailyView/ShiftPopover.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Flex, IconButton, Popover, Portal, Text } from "@chakra-ui/react"; +import { Badge, Box, Button, Flex, IconButton, Popover, Portal, Text } from "@chakra-ui/react"; import { useEffect } from "react"; import { LuMinus, LuTrash2, LuX } from "react-icons/lu"; import type { ShiftData } from "../../types"; @@ -89,13 +89,20 @@ export const ShiftPopover = ({ > - {/* 労働時間 */} + {/* 希望時間 */} - - {!isStaffSubmitted - ? "未提出" - : `希望:${shift.requestedTime ? `${shift.requestedTime.start} - ${shift.requestedTime.end}` : "なし"}`} - + + + {shift.requestedTime + ? `希望: ${shift.requestedTime.start} - ${shift.requestedTime.end}` + : "希望: なし"} + + {!isStaffSubmitted && ( + + 未提出 + + )} + {/* ポジション一覧 */} diff --git a/src/components/features/Shift/ShiftForm/sp/DailyView/ShiftDetailSheet.tsx b/src/components/features/Shift/ShiftForm/sp/DailyView/ShiftDetailSheet.tsx index 4c8253f3..9f6c667b 100644 --- a/src/components/features/Shift/ShiftForm/sp/DailyView/ShiftDetailSheet.tsx +++ b/src/components/features/Shift/ShiftForm/sp/DailyView/ShiftDetailSheet.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, Text, VStack } from "@chakra-ui/react"; +import { Badge, Box, Flex, Text, VStack } from "@chakra-ui/react"; import dayjs from "dayjs"; import { BottomSheet } from "@/src/components/ui/BottomSheet"; import type { ShiftData, StaffType } from "../../types"; @@ -14,20 +14,25 @@ type ShiftDetailSheetProps = { export const ShiftDetailSheet = ({ staff, shift, selectedDate, isOpen, onOpenChange }: ShiftDetailSheetProps) => { const dateLabel = dayjs(selectedDate).format("M/D(ddd)"); - const requestLabel = (() => { - if (!staff.isSubmitted) return "未提出"; - if (!shift?.requestedTime) return "希望: なし"; - return `希望: ${shift.requestedTime.start} - ${shift.requestedTime.end}`; - })(); + const requestLabel = shift?.requestedTime + ? `希望: ${shift.requestedTime.start} - ${shift.requestedTime.end}` + : "希望: なし"; const visibleSegments = shift?.positions.filter((p) => p.positionName !== "休憩") ?? []; return ( - - {requestLabel} - + + + {requestLabel} + + {!staff.isSubmitted && ( + + 未提出 + + )} + {visibleSegments.length > 0 && ( diff --git a/src/components/features/Shift/ShiftForm/sp/DailyView/ShiftEditSheet.tsx b/src/components/features/Shift/ShiftForm/sp/DailyView/ShiftEditSheet.tsx index fb861cea..6add3863 100644 --- a/src/components/features/Shift/ShiftForm/sp/DailyView/ShiftEditSheet.tsx +++ b/src/components/features/Shift/ShiftForm/sp/DailyView/ShiftEditSheet.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, HStack, IconButton, Text, VStack } from "@chakra-ui/react"; +import { Badge, Box, Flex, HStack, IconButton, Text, VStack } from "@chakra-ui/react"; import dayjs from "dayjs"; import { useCallback, useMemo, useState } from "react"; import { LuPlus, LuTrash2, LuX } from "react-icons/lu"; @@ -72,12 +72,9 @@ export const ShiftEditSheet = ({ const dateLabel = dayjs(selectedDate).format("M/D(ddd)"); - // 希望時間テキスト - const requestLabel = (() => { - if (!staff.isSubmitted) return "未提出"; - if (!shift?.requestedTime) return "希望: なし"; - return `希望: ${shift.requestedTime.start} - ${shift.requestedTime.end}`; - })(); + const requestLabel = shift?.requestedTime + ? `希望: ${shift.requestedTime.start} - ${shift.requestedTime.end}` + : "希望: なし"; // 現在のshift(なければ空で作る) const currentShift: ShiftData = shift ?? { @@ -151,9 +148,16 @@ export const ShiftEditSheet = ({ {/* 希望時間 */} - - {requestLabel} - + + + {requestLabel} + + {!staff.isSubmitted && ( + + 未提出 + + )} + {/* 割当ポジション一覧 */} {visibleSegments.length > 0 && ( diff --git a/src/components/features/Shift/ShiftForm/types.ts b/src/components/features/Shift/ShiftForm/types.ts index ddd46035..4aa3371b 100644 --- a/src/components/features/Shift/ShiftForm/types.ts +++ b/src/components/features/Shift/ShiftForm/types.ts @@ -83,7 +83,6 @@ export type LinkedResizeTarget = { // ピーク帯定義 export type PeakBand = { - name: string; // "ランチ", "ディナー" startTime: string; // "11:00" endTime: string; // "14:00" requiredCount: number; // 必要人数 diff --git a/src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts b/src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts index ee8b98a7..ae483609 100644 --- a/src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts +++ b/src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts @@ -20,8 +20,8 @@ const makeShift = (staffId: string, positions: { start: string; end: string }[]) describe("calculateDayStaffingStatus", () => { const peakBands: PeakBand[] = [ - { name: "ランチ", startTime: "11:00", endTime: "14:00", requiredCount: 3 }, - { name: "ディナー", startTime: "17:00", endTime: "21:00", requiredCount: 5 }, + { startTime: "11:00", endTime: "14:00", requiredCount: 3 }, + { startTime: "17:00", endTime: "21:00", requiredCount: 5 }, ]; test("ピーク帯が充足している場合", () => { @@ -75,7 +75,7 @@ describe("calculateDayStaffingStatus", () => { const result = calculateDayStaffingStatus({ shifts, date: "2026-03-24", - peakBands: [{ name: "ランチ", startTime: "11:00", endTime: "14:00", requiredCount: 3 }], + peakBands: [{ startTime: "11:00", endTime: "14:00", requiredCount: 3 }], }); expect(getDayStatus(result)).toBe("ok"); }); diff --git a/src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts b/src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts index 65414dd5..d5b7e94b 100644 --- a/src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts +++ b/src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts @@ -2,7 +2,6 @@ import type { PeakBand, ShiftData } from "../types"; import { timeToMinutes } from "./timeConversion"; export type PeakBandStatus = { - name: string; startTime: string; endTime: string; requiredCount: number; @@ -69,7 +68,6 @@ export const calculateDayStaffingStatus = (params: { const actualCount = getMinStaffInBand(shifts, date, band.startTime, band.endTime); const shortfall = Math.max(0, band.requiredCount - actualCount); return { - name: band.name, startTime: band.startTime, endTime: band.endTime, requiredCount: band.requiredCount, diff --git a/src/components/features/Shift/StaffingRequirement/constants.ts b/src/components/features/Shift/StaffingRequirement/constants.ts index 9ec7c4f2..cbded594 100644 --- a/src/components/features/Shift/StaffingRequirement/constants.ts +++ b/src/components/features/Shift/StaffingRequirement/constants.ts @@ -16,5 +16,9 @@ export const TIME_PERIODS = [ { label: "夜", rangeStart: 21, rangeEnd: 30 }, ] as const; +// 平日(月〜金)/ 休日(日,土,祝)のグルーピング +export const WEEKDAY_DAYS = [1, 2, 3, 4, 5] as const; +export const HOLIDAY_DAYS = [0, 6, 7] as const; + // ヒートマップの色段階(blue系グラデーション) export const HEATMAP_COLORS = ["gray.50", "blue.100", "blue.300", "blue.500", "blue.700"] as const; diff --git a/src/components/pages/Shops/StaffingSettingsPage/index.tsx b/src/components/pages/Shops/StaffingSettingsPage/index.tsx index f30dc99b..3a7c703b 100644 --- a/src/components/pages/Shops/StaffingSettingsPage/index.tsx +++ b/src/components/pages/Shops/StaffingSettingsPage/index.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery } from "convex/react"; import { useCallback, useState } from "react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; +import type { InitialDayData } from "@/src/components/features/Shift/PeakBandSettings"; import { PeakBandSettings } from "@/src/components/features/Shift/PeakBandSettings"; import { LazyShow } from "@/src/components/ui/LazyShow"; import { LoadingState } from "@/src/components/ui/LoadingState"; @@ -13,6 +14,7 @@ type Props = { export const StaffingSettingsPage = ({ shopId }: Props) => { const shop = useQuery(api.shop.queries.getById, { shopId: shopId as Id<"shops"> }); + const staffingData = useQuery(api.requiredStaffing.queries.getByShopId, { shopId: shopId as Id<"shops"> }); // Mutations const upsertPeakBandsMutation = useMutation(api.requiredStaffing.mutations.upsertPeakBands); @@ -20,21 +22,26 @@ export const StaffingSettingsPage = ({ shopId }: Props) => { // UI状態 const [isSaving, setIsSaving] = useState(false); - // 保存処理 + // 保存処理(複数曜日対応) const handleSave = useCallback( async (params: { - dayOfWeek: number; - peakBands: { name: string; startTime: string; endTime: string; requiredCount: number }[]; + dayOfWeeks: readonly number[]; + peakBands: { startTime: string; endTime: string; requiredCount: number }[]; minimumStaff: number; }) => { setIsSaving(true); try { - await upsertPeakBandsMutation({ - shopId: shopId as Id<"shops">, - dayOfWeek: params.dayOfWeek, - peakBands: params.peakBands, - minimumStaff: params.minimumStaff, - }); + // 全dayOfWeekに同じ設定を保存 + await Promise.all( + params.dayOfWeeks.map((dayOfWeek) => + upsertPeakBandsMutation({ + shopId: shopId as Id<"shops">, + dayOfWeek, + peakBands: params.peakBands, + minimumStaff: params.minimumStaff, + }), + ), + ); toaster.create({ description: "必要人員設定を保存しました", type: "success", @@ -54,7 +61,7 @@ export const StaffingSettingsPage = ({ shopId }: Props) => { ); // ローディング - if (shop === undefined) { + if (shop === undefined || staffingData === undefined) { return ( @@ -67,5 +74,20 @@ export const StaffingSettingsPage = ({ shopId }: Props) => { return null; } - return ; + // 初期データを変換 + const initialData: InitialDayData[] = (staffingData ?? []).map((s) => ({ + dayOfWeek: s.dayOfWeek, + peakBands: s.peakBands, + minimumStaff: s.minimumStaff, + })); + + return ( + + ); }; From 6b39dab74e0ac5766b44a6c8629f641b7274eccf Mon Sep 17 00:00:00 2001 From: y-natani Date: Tue, 24 Mar 2026 20:42:06 +0900 Subject: [PATCH 025/176] f --- .../Shops/StaffingSettingsPage/index.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/pages/Shops/StaffingSettingsPage/index.tsx b/src/components/pages/Shops/StaffingSettingsPage/index.tsx index 3a7c703b..abcf3426 100644 --- a/src/components/pages/Shops/StaffingSettingsPage/index.tsx +++ b/src/components/pages/Shops/StaffingSettingsPage/index.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery } from "convex/react"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import type { InitialDayData } from "@/src/components/features/Shift/PeakBandSettings"; @@ -22,6 +22,16 @@ export const StaffingSettingsPage = ({ shopId }: Props) => { // UI状態 const [isSaving, setIsSaving] = useState(false); + const initialData = useMemo( + () => + (staffingData ?? []).map((s) => ({ + dayOfWeek: s.dayOfWeek, + peakBands: s.peakBands, + minimumStaff: s.minimumStaff, + })), + [staffingData], + ); + // 保存処理(複数曜日対応) const handleSave = useCallback( async (params: { @@ -74,13 +84,6 @@ export const StaffingSettingsPage = ({ shopId }: Props) => { return null; } - // 初期データを変換 - const initialData: InitialDayData[] = (staffingData ?? []).map((s) => ({ - dayOfWeek: s.dayOfWeek, - peakBands: s.peakBands, - minimumStaff: s.minimumStaff, - })); - return ( Date: Wed, 25 Mar 2026 23:10:36 +0900 Subject: [PATCH 026/176] =?UTF-8?q?chore:=20v2=E3=81=AEConvex=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4=E3=83=BB=E3=82=B9=E3=82=AD=E3=83=BC=E3=83=9E=E7=B0=A1?= =?UTF-8?q?=E7=B4=A0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- convex/constants.ts | 53 ------ convex/email/actions.ts | 159 ----------------- convex/helpers.ts | 201 ---------------------- convex/invite/mutations.ts | 245 --------------------------- convex/invite/queries.ts | 101 ----------- convex/position/mutations.ts | 183 -------------------- convex/position/queries.ts | 36 ---- convex/recruitment/mutations.ts | 194 --------------------- convex/recruitment/queries.ts | 58 ------- convex/requiredStaffing/mutations.ts | 240 -------------------------- convex/requiredStaffing/queries.ts | 37 ---- convex/schema.ts | 214 +---------------------- convex/shiftAssignment/mutations.ts | 51 ------ convex/shiftAssignment/queries.ts | 30 ---- convex/shiftRequest/mutations.ts | 96 ----------- convex/shiftRequest/queries.ts | 169 ------------------ convex/shop/mutations.ts | 218 ------------------------ convex/shop/queries.ts | 125 -------------- convex/staff/mutations.ts | 196 --------------------- convex/staffSkill/mutations.ts | 90 ---------- convex/staffSkill/queries.ts | 94 ---------- convex/testing.ts | 16 -- convex/user/mutations.ts | 120 ------------- convex/user/queries.ts | 43 ----- 24 files changed, 2 insertions(+), 2967 deletions(-) delete mode 100644 convex/constants.ts delete mode 100644 convex/email/actions.ts delete mode 100644 convex/helpers.ts delete mode 100644 convex/invite/mutations.ts delete mode 100644 convex/invite/queries.ts delete mode 100644 convex/position/mutations.ts delete mode 100644 convex/position/queries.ts delete mode 100644 convex/recruitment/mutations.ts delete mode 100644 convex/recruitment/queries.ts delete mode 100644 convex/requiredStaffing/mutations.ts delete mode 100644 convex/requiredStaffing/queries.ts delete mode 100644 convex/shiftAssignment/mutations.ts delete mode 100644 convex/shiftAssignment/queries.ts delete mode 100644 convex/shiftRequest/mutations.ts delete mode 100644 convex/shiftRequest/queries.ts delete mode 100644 convex/shop/mutations.ts delete mode 100644 convex/shop/queries.ts delete mode 100644 convex/staff/mutations.ts delete mode 100644 convex/staffSkill/mutations.ts delete mode 100644 convex/staffSkill/queries.ts delete mode 100644 convex/testing.ts delete mode 100644 convex/user/mutations.ts delete mode 100644 convex/user/queries.ts diff --git a/convex/constants.ts b/convex/constants.ts deleted file mode 100644 index b78c10f4..00000000 --- a/convex/constants.ts +++ /dev/null @@ -1,53 +0,0 @@ -// DB関連の定数定義(Single Source of Truth) -// クライアント側(src/constants/validations.ts)からre-exportされる - -export const SHOP_TIME_UNIT = [15, 30, 60] as const; -export type ShopTimeUnitType = (typeof SHOP_TIME_UNIT)[number]; - -export const SHOP_SUBMIT_FREQUENCY = ["1w", "2w", "1m"] as const; -export type ShopSubmitFrequencyType = (typeof SHOP_SUBMIT_FREQUENCY)[number]; - -export const SHOP_MIN_LENGTH = 2; -export const SHOP_MAX_LENGTH = 50; - -// スタッフステータス定義 -export const STAFF_STATUS = ["pending", "active", "resigned"] as const; -export type StaffStatusType = (typeof STAFF_STATUS)[number]; - -// スキルレベル -export const SKILL_LEVELS = ["未経験", "研修中", "一人前", "ベテラン"] as const; -export type SkillLevelType = (typeof SKILL_LEVELS)[number]; - -// ポジション(デフォルト値、店舗ごとにカスタム可能) -export const DEFAULT_POSITIONS = ["ホール", "キッチン", "レジ", "その他"] as const; -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]; - -// 招待関連 -export const INVITE_EXPIRY_DAYS = 14; -export const INVITE_EXPIRY_MS = INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000; - -// 募集ステータス定義 -export const RECRUITMENT_STATUS = ["open", "closed", "confirmed"] as const; -export type RecruitmentStatusType = (typeof RECRUITMENT_STATUS)[number]; diff --git a/convex/email/actions.ts b/convex/email/actions.ts deleted file mode 100644 index c86f1be2..00000000 --- a/convex/email/actions.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * メール送信ドメイン - アクション(外部API呼び出し) - * - * 責務: - * - Resend APIを使用したメール送信 - * - シフト募集通知メールの送信 - */ -import { v } from "convex/values"; -import { Resend } from "resend"; -import { internalAction } from "../_generated/server"; - -const FROM_EMAIL = "onboarding@resend.dev"; - -// シフト募集通知メール送信 -export const sendRecruitmentNotification = internalAction({ - args: { - shopName: v.string(), - startDate: v.string(), - endDate: v.string(), - deadline: v.string(), - recipients: v.array( - v.object({ - email: v.string(), - magicLinkToken: v.string(), - }), - ), - }, - handler: async (_ctx, args) => { - const apiKey = process.env.RESEND_API_KEY; - if (!apiKey) { - console.error("RESEND_API_KEY が設定されていません"); - return; - } - - const resend = new Resend(apiKey); - const appUrl = process.env.APP_URL ?? "http://localhost:3000"; - - const subject = `【${args.shopName}】シフト募集のお知らせ(${args.startDate}〜${args.endDate})`; - - for (const recipient of args.recipients) { - try { - const magicLinkUrl = `${appUrl}/shift-submit?token=${recipient.magicLinkToken}`; - - await resend.emails.send({ - from: FROM_EMAIL, - to: recipient.email, - subject, - html: buildEmailHtml({ - shopName: args.shopName, - startDate: args.startDate, - endDate: args.endDate, - deadline: args.deadline, - magicLinkUrl, - }), - }); - } catch (e) { - console.error(`メール送信失敗: ${recipient.email}`, e); - } - } - }, -}); - -// シフト確定通知メール送信 -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; - endDate: string; - deadline: string; - magicLinkUrl: string; -}) => { - return ` -
-

${params.shopName} のシフト募集が開始されました

- - - - - - - - - -
募集期間${params.startDate} 〜 ${params.endDate}
申請締切${params.deadline}
-

以下のリンクからシフトを申請してください:

- シフトを申請する -

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

-
-`.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 deleted file mode 100644 index 961c4466..00000000 --- a/convex/helpers.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Convex用ヘルパー関数 -import { ConvexError } from "convex/values"; -import type { Id } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; -import { DEFAULT_POSITIONS, POSITION_COLORS, SKILL_LEVELS } from "./constants"; - -// セキュアなトークン生成(crypto APIを使用) -export const generateToken = () => { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); -}; - -// authIdからユーザー(管理者)を取得 -export const getUserByAuthId = async (ctx: QueryCtx | MutationCtx, authId: string) => { - const user = await ctx.db - .query("users") - .withIndex("by_auth_id", (q) => q.eq("authId", authId)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .first(); - - return user; -}; - -// authIdからユーザーを取得(存在しない場合はエラー) -export const requireUserByAuthId = async (ctx: QueryCtx | MutationCtx, authId: string) => { - const user = await getUserByAuthId(ctx, authId); - - if (!user) { - throw new ConvexError({ - message: "ユーザーが見つかりません", - code: "USER_NOT_FOUND", - }); - } - - return user; -}; - -// 店舗のオーナー(作成者)かどうかチェック -export const isShopOwner = async (ctx: QueryCtx | MutationCtx, shopId: Id<"shops">, authId: string) => { - const shop = await ctx.db.get(shopId); - if (!shop || shop.isDeleted) return false; - return shop.createdBy === authId; -}; - -// 店舗のオーナーであることを要求 -export const requireShopOwner = async (ctx: QueryCtx | MutationCtx, shopId: Id<"shops">, authId: string) => { - const isOwner = await isShopOwner(ctx, shopId, authId); - - if (!isOwner) { - throw new ConvexError({ - message: "この操作を行う権限がありません", - code: "PERMISSION_DENIED", - }); - } - - return true; -}; - -// スタッフを取得(店舗ID + スタッフID) -export const getStaff = async (ctx: QueryCtx | MutationCtx, staffId: Id<"staffs">) => { - const staff = await ctx.db.get(staffId); - if (!staff || staff.isDeleted) return null; - return staff; -}; - -// スタッフを取得(店舗ID + メールアドレス) -export const getStaffByEmail = async (ctx: QueryCtx | MutationCtx, shopId: Id<"shops">, email: string) => { - const staff = await ctx.db - .query("staffs") - .withIndex("by_shop_and_email", (q) => q.eq("shopId", shopId).eq("email", email)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .first(); - - return staff; -}; - -// マジックリンクトークンから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(); - - if (!magicLink) return null; - - const staff = await ctx.db.get(magicLink.staffId); - if (!staff || staff.isDeleted) return null; - - return { magicLink, staff }; -}; - -// 招待トークンでスタッフを取得 -export const getStaffByInviteToken = async (ctx: QueryCtx | MutationCtx, token: string) => { - const staff = await ctx.db - .query("staffs") - .withIndex("by_invite_token", (q) => q.eq("inviteToken", token)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .first(); - - return staff; -}; - -// 店舗のオーナーまたはマネージャーかどうかチェック -export const isShopOwnerOrManager = async (ctx: QueryCtx | MutationCtx, shopId: Id<"shops">, authId: string) => { - // まずオーナーかどうかチェック - const isOwner = await isShopOwner(ctx, shopId, authId); - if (isOwner) return true; - - // ユーザーを取得 - const user = await getUserByAuthId(ctx, authId); - if (!user) return false; - - // スタッフとしてマネージャーかどうかチェック - const staff = await ctx.db - .query("staffs") - .withIndex("by_shop", (q) => q.eq("shopId", shopId)) - .filter((q) => - q.and(q.eq(q.field("userId"), user._id), q.neq(q.field("isDeleted"), true), q.eq(q.field("role"), "manager")), - ) - .first(); - - return !!staff; -}; - -// 店舗のオーナーまたはマネージャーであることを要求 -export const requireShopOwnerOrManager = async (ctx: QueryCtx | MutationCtx, shopId: Id<"shops">, authId: string) => { - const isOwnerOrManager = await isShopOwnerOrManager(ctx, shopId, authId); - - if (!isOwnerOrManager) { - throw new ConvexError({ - message: "この操作を行う権限がありません", - code: "PERMISSION_DENIED", - }); - } - - return true; -}; - -// 店舗存在チェック(存在しない場合はエラー) -export const requireShop = async (ctx: QueryCtx | MutationCtx, shopId: Id<"shops">) => { - const shop = await ctx.db.get(shopId); - - if (!shop || shop.isDeleted) { - throw new ConvexError({ - message: "店舗が見つかりません", - code: "SHOP_NOT_FOUND", - }); - } - - return shop; -}; - -// 時刻形式バリデーション(HH:mm形式) -export const isValidTimeFormat = (time: string) => { - const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; - return timeRegex.test(time); -}; - -// 全ポジションを「未経験」で初期化したスキル配列を生成 -export const createDefaultSkills = () => { - return DEFAULT_POSITIONS.map((position) => ({ - position, - level: SKILL_LEVELS[0], // "未経験" - })); -}; - -// 店舗のデフォルトポジションを初期化 -export const initializeDefaultPositions = async (ctx: MutationCtx, shopId: Id<"shops">) => { - const positionIds: Id<"shopPositions">[] = []; - for (let i = 0; i < DEFAULT_POSITIONS.length; i++) { - const positionId = await ctx.db.insert("shopPositions", { - shopId, - name: DEFAULT_POSITIONS[i], - color: POSITION_COLORS[i % POSITION_COLORS.length], - order: i, - isDeleted: false, - createdAt: Date.now(), - }); - positionIds.push(positionId); - } - return positionIds; -}; - -// スタッフのスキルを全ポジション「未経験」で初期化 -export const initializeStaffSkills = async (ctx: MutationCtx, shopId: Id<"shops">, staffId: Id<"staffs">) => { - const positions = await ctx.db - .query("shopPositions") - .withIndex("by_shop", (q) => q.eq("shopId", shopId)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .collect(); - - for (const position of positions) { - await ctx.db.insert("staffSkills", { - staffId, - positionId: position._id, - level: SKILL_LEVELS[0], // "未経験" - updatedAt: Date.now(), - }); - } -}; diff --git a/convex/invite/mutations.ts b/convex/invite/mutations.ts deleted file mode 100644 index 2354d180..00000000 --- a/convex/invite/mutations.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * 招待ドメイン - ミューテーション(書き込み操作) - * - * 責務: - * - マネージャー招待の作成・キャンセル・再送 - * - 招待の受け入れ処理 - */ -import { ConvexError, v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { INVITE_EXPIRY_MS, STAFF_ROLES } from "../constants"; -import { - createDefaultSkills, - generateToken, - getStaff, - getStaffByEmail, - getStaffByInviteToken, - getUserByAuthId, - initializeStaffSkills, - requireShop, -} from "../helpers"; - -// 招待作成 -export const create = mutation({ - args: { - shopId: v.id("shops"), - displayName: v.string(), - email: v.string(), - role: v.string(), // "owner" | "manager" | "general" - authId: v.string(), - }, - handler: async (ctx, args) => { - const shop = await requireShop(ctx, args.shopId); - - const trimmedDisplayName = args.displayName.trim(); - const trimmedEmail = args.email.trim().toLowerCase(); - - if (!trimmedDisplayName) { - throw new ConvexError({ message: "表示名は必須です", code: "EMPTY_DISPLAY_NAME" }); - } - - if (!trimmedEmail) { - throw new ConvexError({ message: "メールアドレスは必須です", code: "EMPTY_EMAIL" }); - } - - if (!STAFF_ROLES.includes(args.role as (typeof STAFF_ROLES)[number])) { - throw new ConvexError({ message: "無効なロールです", code: "INVALID_ROLE" }); - } - - // 重複メールチェック - const existingStaff = await getStaffByEmail(ctx, args.shopId, trimmedEmail); - if (existingStaff && existingStaff.status === "active") { - throw new ConvexError({ - message: "このメールアドレスは既に登録されています", - code: "EMAIL_ALREADY_EXISTS", - }); - } - if (existingStaff && existingStaff.status === "pending") { - throw new ConvexError({ - message: "このメールアドレスは既に招待中です", - code: "EMAIL_ALREADY_INVITED", - }); - } - - // トークン生成 - const token = generateToken(); - const expiresAt = Date.now() + INVITE_EXPIRY_MS; - - // スタッフレコード作成(招待状態) - const staffId = await ctx.db.insert("staffs", { - shopId: args.shopId, - email: trimmedEmail, - displayName: trimmedDisplayName, - status: "pending", - role: args.role, - skills: createDefaultSkills(), // 後方互換のため残す - inviteToken: token, - inviteExpiresAt: expiresAt, - invitedBy: args.authId, - createdAt: Date.now(), - isDeleted: false, - }); - - // 新テーブルにもスキルを初期化 - await initializeStaffSkills(ctx, args.shopId, staffId); - - return { - success: true, - data: { - staffId, - token, - expiresAt, - shopName: shop.shopName, - }, - }; - }, -}); - -// 招待受け入れ -export const accept = mutation({ - args: { - token: v.string(), - authId: v.string(), - }, - handler: async (ctx, args) => { - const trimmedToken = args.token.trim(); - - if (!trimmedToken) { - throw new ConvexError({ message: "無効なリンクです", code: "INVALID_TOKEN" }); - } - - // トークンでスタッフを検索 - const staff = await getStaffByInviteToken(ctx, trimmedToken); - - if (!staff) { - throw new ConvexError({ message: "招待が見つかりません", code: "INVITATION_NOT_FOUND" }); - } - - // 有効期限チェック - if (staff.inviteExpiresAt && staff.inviteExpiresAt < Date.now()) { - throw new ConvexError({ message: "招待の有効期限が切れています", code: "INVITATION_EXPIRED" }); - } - - // 既に受け入れ済みかチェック - if (staff.status === "active") { - throw new ConvexError({ message: "この招待は既に承認済みです", code: "INVITATION_ALREADY_ACCEPTED" }); - } - - // キャンセル済みかチェック - if (staff.isDeleted) { - throw new ConvexError({ message: "この招待はキャンセルされました", code: "INVITATION_CANCELLED" }); - } - - // ユーザー情報を取得(存在しない場合は招待情報で自動作成) - let user = await getUserByAuthId(ctx, args.authId); - - if (!user) { - const userId = await ctx.db.insert("users", { - name: staff.displayName, - email: staff.email, - authId: args.authId, - status: "active", - createdAt: Date.now(), - isDeleted: false, - }); - const newUser = await ctx.db.get(userId); - if (!newUser) { - throw new ConvexError({ message: "ユーザーの作成に失敗しました", code: "USER_CREATION_FAILED" }); - } - user = newUser; - } - - // 店舗情報を取得 - const shop = await ctx.db.get(staff.shopId); - - if (!shop || shop.isDeleted) { - throw new ConvexError({ message: "店舗が見つかりません", code: "SHOP_NOT_FOUND" }); - } - - // スタッフ情報を更新(招待受け入れ) - await ctx.db.patch(staff._id, { - userId: user._id, - email: user.email, - status: "active", - inviteToken: undefined, // トークンをクリア - inviteExpiresAt: undefined, - }); - - return { - success: true, - data: { - shopId: staff.shopId, - shopName: shop.shopName, - }, - }; - }, -}); - -// 招待キャンセル -export const cancel = mutation({ - args: { - staffId: v.id("staffs"), - authId: v.string(), - }, - handler: async (ctx, args) => { - const staff = await getStaff(ctx, args.staffId); - - if (!staff) { - throw new ConvexError({ message: "招待が見つかりません", code: "INVITATION_NOT_FOUND" }); - } - - // pending状態のみキャンセル可能 - if (staff.status !== "pending") { - throw new ConvexError({ message: "この招待はキャンセルできません", code: "CANNOT_CANCEL" }); - } - - // 論理削除 - await ctx.db.patch(args.staffId, { - isDeleted: true, - inviteToken: undefined, - inviteExpiresAt: undefined, - }); - - return { success: true }; - }, -}); - -// 招待再送(トークン再生成) -export const resend = mutation({ - args: { - staffId: v.id("staffs"), - authId: v.string(), - }, - handler: async (ctx, args) => { - const staff = await getStaff(ctx, args.staffId); - - if (!staff) { - throw new ConvexError({ message: "招待が見つかりません", code: "INVITATION_NOT_FOUND" }); - } - - const shop = await requireShop(ctx, staff.shopId); - - // pending状態のみ再送可能 - if (staff.status !== "pending") { - throw new ConvexError({ message: "この招待は再送できません", code: "CANNOT_RESEND" }); - } - - // 新しいトークン生成 - const token = generateToken(); - const expiresAt = Date.now() + INVITE_EXPIRY_MS; - - await ctx.db.patch(args.staffId, { - inviteToken: token, - inviteExpiresAt: expiresAt, - }); - - return { - success: true, - data: { - token, - expiresAt, - shopName: shop.shopName, - }, - }; - }, -}); diff --git a/convex/invite/queries.ts b/convex/invite/queries.ts deleted file mode 100644 index f1b0eefe..00000000 --- a/convex/invite/queries.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 招待ドメイン - クエリ(読み取り操作) - * - * 責務: - * - 招待情報の取得 - * - 招待一覧の取得 - */ -import { v } from "convex/values"; -import { query } from "../_generated/server"; -import { getStaffByInviteToken } from "../helpers"; - -// トークンで招待情報を取得(公開API - 最小限の情報のみ返却) -export const getByToken = query({ - args: { - token: v.string(), - }, - handler: async (ctx, args) => { - const trimmedToken = args.token.trim(); - - if (!trimmedToken) { - return null; - } - - const staff = await getStaffByInviteToken(ctx, trimmedToken); - - if (!staff) { - return null; - } - - // 店舗情報を取得 - const shop = await ctx.db.get(staff.shopId); - - if (!shop || shop.isDeleted) { - return null; - } - - // 公開情報のみ返却 - return { - staffId: staff._id, - displayName: staff.displayName, - role: staff.role, - status: staff.status, - isDeleted: staff.isDeleted, - expiresAt: staff.inviteExpiresAt, - isExpired: staff.inviteExpiresAt ? staff.inviteExpiresAt < Date.now() : false, - shop: { - shopId: shop._id, - shopName: shop.shopName, - }, - }; - }, -}); - -// 店舗の招待一覧を取得 -export const listByShopId = query({ - args: { - shopId: v.id("shops"), - authId: v.string(), - }, - handler: async (ctx, args) => { - // pending状態の招待を取得 - const invitations = await ctx.db - .query("staffs") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .filter((q) => q.and(q.eq(q.field("status"), "pending"), q.neq(q.field("isDeleted"), true))) - .collect(); - - // 招待者情報を付加 - const result = await Promise.all( - invitations.map(async (inv) => { - let inviterName = "不明"; - - if (inv.invitedBy) { - const inviter = await ctx.db - .query("users") - .withIndex("by_auth_id", (q) => q.eq("authId", inv.invitedBy)) - .first(); - - if (inviter) { - inviterName = inviter.name; - } - } - - return { - staffId: inv._id, - displayName: inv.displayName, - email: inv.email, - role: inv.role, - inviteToken: inv.inviteToken, - expiresAt: inv.inviteExpiresAt, - isExpired: inv.inviteExpiresAt ? inv.inviteExpiresAt < Date.now() : false, - invitedBy: inviterName, - createdAt: inv.createdAt, - }; - }), - ); - - // 作成日時の降順でソート - return result.sort((a, b) => b.createdAt - a.createdAt); - }, -}); diff --git a/convex/position/mutations.ts b/convex/position/mutations.ts deleted file mode 100644 index c416acca..00000000 --- a/convex/position/mutations.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * ポジションドメイン - ミューテーション(書き込み操作) - * - * 責務: - * - ポジションの追加・更新・削除 - * - 店舗作成時のデフォルトポジション初期化 - */ -import { ConvexError, v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { POSITION_COLORS } from "../constants"; -import { - initializeDefaultPositions as initializeDefaultPositionsHelper, - initializeStaffSkills as initializeStaffSkillsHelper, - requireShop, -} from "../helpers"; - -// ポジション作成 -export const create = mutation({ - args: { - shopId: v.id("shops"), - name: v.string(), - authId: v.string(), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - const trimmedName = args.name.trim(); - if (!trimmedName) { - throw new ConvexError({ message: "ポジション名は必須です", code: "EMPTY_NAME" }); - } - - // 重複チェック - const existing = await ctx.db - .query("shopPositions") - .withIndex("by_shop_and_name", (q) => q.eq("shopId", args.shopId).eq("name", trimmedName)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .first(); - - if (existing) { - throw new ConvexError({ message: "同じ名前のポジションが既に存在します", code: "DUPLICATE_NAME" }); - } - - // 最大orderを取得 - const positions = await ctx.db - .query("shopPositions") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .collect(); - - const maxOrder = positions.length > 0 ? Math.max(...positions.map((p) => p.order)) : -1; - - 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(), - }); - - return { success: true, positionId }; - }, -}); - -// ポジション名更新 -export const updateName = mutation({ - args: { - positionId: v.id("shopPositions"), - name: 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" }); - } - - const trimmedName = args.name.trim(); - if (!trimmedName) { - throw new ConvexError({ message: "ポジション名は必須です", code: "EMPTY_NAME" }); - } - - // 重複チェック(自分以外) - const existing = await ctx.db - .query("shopPositions") - .withIndex("by_shop_and_name", (q) => q.eq("shopId", position.shopId).eq("name", trimmedName)) - .filter((q) => q.and(q.neq(q.field("isDeleted"), true), q.neq(q.field("_id"), args.positionId))) - .first(); - - if (existing) { - throw new ConvexError({ message: "同じ名前のポジションが既に存在します", code: "DUPLICATE_NAME" }); - } - - await ctx.db.patch(args.positionId, { name: trimmedName }); - - return { success: true }; - }, -}); - -// ポジションカラー更新 -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: { - positionId: v.id("shopPositions"), - 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" }); - } - - // このポジションに紐づくスキルも論理削除 - const skills = await ctx.db - .query("staffSkills") - .withIndex("by_position", (q) => q.eq("positionId", args.positionId)) - .collect(); - - for (const skill of skills) { - await ctx.db.delete(skill._id); - } - - await ctx.db.patch(args.positionId, { isDeleted: true }); - - return { success: true }; - }, -}); - -// ポジションの並び順更新 -export const updateOrder = mutation({ - args: { - positionIds: v.array(v.id("shopPositions")), - authId: v.string(), - }, - handler: async (ctx, args) => { - for (let i = 0; i < args.positionIds.length; i++) { - await ctx.db.patch(args.positionIds[i], { order: i }); - } - - return { success: true }; - }, -}); - -// 店舗作成時にデフォルトポジションを初期化 -export const initializeDefaultPositions = mutation({ - args: { - shopId: v.id("shops"), - }, - handler: async (ctx, args) => { - const positionIds = await initializeDefaultPositionsHelper(ctx, args.shopId); - return { success: true, positionIds }; - }, -}); - -// スタッフ追加時に全ポジションを「未経験」で初期化 -export const initializeStaffSkills = mutation({ - args: { - shopId: v.id("shops"), - staffId: v.id("staffs"), - }, - handler: async (ctx, args) => { - await initializeStaffSkillsHelper(ctx, args.shopId, args.staffId); - return { success: true }; - }, -}); diff --git a/convex/position/queries.ts b/convex/position/queries.ts deleted file mode 100644 index 95effad8..00000000 --- a/convex/position/queries.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * ポジションドメイン - クエリ(読み取り操作) - * - * 責務: - * - 店舗のポジション一覧取得 - * - ポジションごとのスタッフ数取得 - */ -import { v } from "convex/values"; -import { query } from "../_generated/server"; - -// 店舗のポジション一覧を取得 -export const listByShop = query({ - args: { shopId: v.id("shops") }, - handler: async (ctx, args) => { - const positions = await ctx.db - .query("shopPositions") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .collect(); - - return positions.sort((a, b) => a.order - b.order); - }, -}); - -// ポジションに紐づくスタッフ数を取得(削除確認用) -export const getStaffCountByPosition = query({ - args: { positionId: v.id("shopPositions") }, - handler: async (ctx, args) => { - const skills = await ctx.db - .query("staffSkills") - .withIndex("by_position", (q) => q.eq("positionId", args.positionId)) - .collect(); - - return skills.length; - }, -}); diff --git a/convex/recruitment/mutations.ts b/convex/recruitment/mutations.ts deleted file mode 100644 index f9da6083..00000000 --- a/convex/recruitment/mutations.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * 募集ドメイン - ミューテーション(書き込み操作) - * - * 責務: - * - シフト募集の作成 - * - シフト募集の締め切り - * - シフト募集の確定(メール通知付き) - */ -import { ConvexError, v } from "convex/values"; -import { internal } from "../_generated/api"; -import { mutation } from "../_generated/server"; -import { RECRUITMENT_STATUS } from "../constants"; -import { generateToken, requireShop, requireShopOwnerOrManager } from "../helpers"; - -// 日付形式バリデーション(YYYY-MM-DD形式) -const isValidDateFormat = (date: string) => { - const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - if (!dateRegex.test(date)) return false; - const parsed = new Date(date); - return !Number.isNaN(parsed.getTime()); -}; - -// シフト募集作成 -export const create = mutation({ - args: { - shopId: v.id("shops"), - authId: v.string(), - startDate: v.string(), - endDate: v.string(), - deadline: v.string(), - }, - handler: async (ctx, args) => { - // 店舗存在チェック - await requireShop(ctx, args.shopId); - - // 権限チェック(オーナーまたはマネージャーのみ) - await requireShopOwnerOrManager(ctx, args.shopId, args.authId); - - // 日付形式バリデーション - if (!isValidDateFormat(args.startDate)) { - throw new ConvexError({ message: "開始日の形式が不正です", code: "INVALID_START_DATE" }); - } - if (!isValidDateFormat(args.endDate)) { - throw new ConvexError({ message: "終了日の形式が不正です", code: "INVALID_END_DATE" }); - } - if (!isValidDateFormat(args.deadline)) { - throw new ConvexError({ message: "締切日の形式が不正です", code: "INVALID_DEADLINE" }); - } - - // ビジネスルールバリデーション(フロントエンドのzodスキーマと一致) - if (args.startDate > args.endDate) { - throw new ConvexError({ message: "終了日は開始日以降を指定してください", code: "END_BEFORE_START" }); - } - if (args.deadline >= args.startDate) { - throw new ConvexError({ - message: "締切日は開始日より前を指定してください", - code: "DEADLINE_NOT_BEFORE_START", - }); - } - - // アクティブスタッフ数を取得(非削除かつ非退職) - const activeStaffs = await ctx.db - .query("staffs") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .filter((q) => q.and(q.neq(q.field("isDeleted"), true), q.neq(q.field("status"), "resigned"))) - .collect(); - - const totalStaffCount = activeStaffs.length; - - // 募集作成 - const recruitmentId = await ctx.db.insert("recruitments", { - shopId: args.shopId, - startDate: args.startDate, - endDate: args.endDate, - deadline: args.deadline, - status: RECRUITMENT_STATUS[0], // "open" - appliedCount: 0, - totalStaffCount, - createdBy: args.authId, - createdAt: Date.now(), - isDeleted: false, - }); - - // 各スタッフにマジックリンクトークンを生成(募集単位) - 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) { - await ctx.scheduler.runAfter(0, internal.email.actions.sendRecruitmentNotification, { - shopName: shop.shopName, - startDate: args.startDate, - endDate: args.endDate, - deadline: args.deadline, - recipients, - }); - } - - return { success: true, data: { recruitmentId, totalStaffCount } }; - }, -}); - -// シフト募集の締め切り -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 deleted file mode 100644 index e2ef00e8..00000000 --- a/convex/recruitment/queries.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 募集ドメイン - クエリ(読み取り操作) - * - * 責務: - * - 店舗のシフト募集一覧取得 - * - 募集詳細取得 - */ -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") }, - handler: async (ctx, args) => { - const recruitments = await ctx.db - .query("recruitments") - .withIndex("by_shop_and_startDate", (q) => q.eq("shopId", args.shopId)) - .order("desc") - .filter((q) => q.neq(q.field("isDeleted"), true)) - .collect(); - - return recruitments.map((r) => ({ - _id: r._id, - startDate: r.startDate, - endDate: r.endDate, - deadline: r.deadline, - status: r.status as RecruitmentStatusType, - appliedCount: r.appliedCount, - totalStaffCount: r.totalStaffCount, - confirmedAt: r.confirmedAt, - })); - }, -}); diff --git a/convex/requiredStaffing/mutations.ts b/convex/requiredStaffing/mutations.ts deleted file mode 100644 index 068ed431..00000000 --- a/convex/requiredStaffing/mutations.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * 必要人員設定 - ミューテーション(書き込み操作) - * - * 責務: - * - 必要人員設定の保存・更新 - * - AI入力情報の保存 - */ -import { ConvexError, v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { requireShop } from "../helpers"; - -// 必要人員設定を保存・更新(曜日単位) -export const upsert = mutation({ - args: { - shopId: v.id("shops"), - dayOfWeek: v.number(), // 0=日, 1=月, ..., 6=土, 7=祝 - staffing: v.array( - v.object({ - hour: v.number(), - position: v.string(), - requiredCount: v.number(), - }), - ), - aiInput: v.optional( - v.object({ - shopType: v.string(), - customerCount: v.string(), - }), - ), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - // バリデーション - if (args.dayOfWeek < 0 || args.dayOfWeek > 7) { - throw new ConvexError({ message: "曜日の値が不正です", code: "INVALID_DAY_OF_WEEK" }); - } - - // 既存データを検索 - const existing = await ctx.db - .query("requiredStaffing") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .collect() - .then((list) => list.find((s) => s.dayOfWeek === args.dayOfWeek)); - - const now = Date.now(); - - if (existing) { - // 更新 - await ctx.db.patch(existing._id, { - staffing: args.staffing, - aiInput: args.aiInput, - updatedAt: now, - }); - return { success: true, id: existing._id, isNew: false }; - } - - // 新規作成 - const id = await ctx.db.insert("requiredStaffing", { - shopId: args.shopId, - dayOfWeek: args.dayOfWeek, - staffing: args.staffing, - aiInput: args.aiInput, - createdAt: now, - updatedAt: now, - }); - - return { success: true, id, isNew: true }; - }, -}); - -// 複数曜日に一括で設定をコピー -export const copyToMultipleDays = mutation({ - args: { - shopId: v.id("shops"), - sourceDayOfWeek: v.number(), - targetDaysOfWeek: v.array(v.number()), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - // コピー元の設定を取得 - const sourceList = await ctx.db - .query("requiredStaffing") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .collect(); - - const source = sourceList.find((s) => s.dayOfWeek === args.sourceDayOfWeek); - - if (!source) { - throw new ConvexError({ message: "コピー元の設定が見つかりません", code: "SOURCE_NOT_FOUND" }); - } - - const now = Date.now(); - const results: { dayOfWeek: number; id: string }[] = []; - - for (const targetDay of args.targetDaysOfWeek) { - if (targetDay === args.sourceDayOfWeek) continue; // 同じ曜日はスキップ - - const existing = sourceList.find((s) => s.dayOfWeek === targetDay); - - if (existing) { - await ctx.db.patch(existing._id, { - staffing: source.staffing, - peakBands: source.peakBands, - minimumStaff: source.minimumStaff, - updatedAt: now, - }); - results.push({ dayOfWeek: targetDay, id: existing._id }); - } else { - const id = await ctx.db.insert("requiredStaffing", { - shopId: args.shopId, - dayOfWeek: targetDay, - staffing: source.staffing, - peakBands: source.peakBands, - minimumStaff: source.minimumStaff, - aiInput: source.aiInput, - createdAt: now, - updatedAt: now, - }); - results.push({ dayOfWeek: targetDay, id }); - } - } - - return { success: true, copiedDays: results }; - }, -}); - -// ピーク帯設定を保存・更新(曜日単位) -export const upsertPeakBands = mutation({ - args: { - shopId: v.id("shops"), - dayOfWeek: v.number(), - peakBands: v.array( - v.object({ - startTime: v.string(), - endTime: v.string(), - requiredCount: v.number(), - }), - ), - minimumStaff: v.number(), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - if (args.dayOfWeek < 0 || args.dayOfWeek > 7) { - throw new ConvexError({ message: "曜日の値が不正です", code: "INVALID_DAY_OF_WEEK" }); - } - - const existing = await ctx.db - .query("requiredStaffing") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .collect() - .then((list) => list.find((s) => s.dayOfWeek === args.dayOfWeek)); - - const now = Date.now(); - - if (existing) { - await ctx.db.patch(existing._id, { - peakBands: args.peakBands, - minimumStaff: args.minimumStaff, - updatedAt: now, - }); - return { success: true, id: existing._id, isNew: false }; - } - - const id = await ctx.db.insert("requiredStaffing", { - shopId: args.shopId, - dayOfWeek: args.dayOfWeek, - staffing: [], - peakBands: args.peakBands, - minimumStaff: args.minimumStaff, - createdAt: now, - updatedAt: now, - }); - - return { success: true, id, isNew: true }; - }, -}); - -// 全曜日分をまとめて保存(初回設定用) -export const saveAll = mutation({ - args: { - shopId: v.id("shops"), - settings: v.array( - v.object({ - dayOfWeek: v.number(), - staffing: v.array( - v.object({ - hour: v.number(), - position: v.string(), - requiredCount: v.number(), - }), - ), - }), - ), - aiInput: v.optional( - v.object({ - shopType: v.string(), - customerCount: v.string(), - }), - ), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - const now = Date.now(); - - // 既存データを取得 - const existingList = await ctx.db - .query("requiredStaffing") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .collect(); - - const existingMap = new Map(existingList.map((s) => [s.dayOfWeek, s])); - - for (const setting of args.settings) { - const existing = existingMap.get(setting.dayOfWeek); - - if (existing) { - await ctx.db.patch(existing._id, { - staffing: setting.staffing, - aiInput: args.aiInput, - updatedAt: now, - }); - } else { - await ctx.db.insert("requiredStaffing", { - shopId: args.shopId, - dayOfWeek: setting.dayOfWeek, - staffing: setting.staffing, - aiInput: args.aiInput, - createdAt: now, - updatedAt: now, - }); - } - } - - return { success: true }; - }, -}); diff --git a/convex/requiredStaffing/queries.ts b/convex/requiredStaffing/queries.ts deleted file mode 100644 index 45ddff0b..00000000 --- a/convex/requiredStaffing/queries.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * 必要人員設定 - クエリ(読み取り操作) - * - * 責務: - * - 店舗の必要人員設定の取得 - */ -import { v } from "convex/values"; -import { query } from "../_generated/server"; - -// 店舗の必要人員設定を全曜日分取得 -export const getByShopId = query({ - args: { shopId: v.id("shops") }, - handler: async (ctx, args) => { - const staffingList = await ctx.db - .query("requiredStaffing") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .collect(); - - return staffingList; - }, -}); - -// 特定の曜日の必要人員設定を取得 -export const getByShopIdAndDay = query({ - args: { - shopId: v.id("shops"), - dayOfWeek: v.number(), - }, - handler: async (ctx, args) => { - const staffingList = await ctx.db - .query("requiredStaffing") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .collect(); - - return staffingList.find((s) => s.dayOfWeek === args.dayOfWeek) ?? null; - }, -}); diff --git a/convex/schema.ts b/convex/schema.ts index dd7315ee..ef610e2a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,215 +1,5 @@ -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; +import { defineSchema } from "convex/server"; -const users = defineTable({ - name: v.string(), - email: v.string(), - authId: v.optional(v.string()), - status: v.string(), // "pending" | "active" - createdAt: v.number(), - isDeleted: v.optional(v.boolean()), -}).index("by_auth_id", ["authId"]); - -const shops = defineTable({ - shopName: v.string(), - openTime: v.string(), - closeTime: v.string(), - timeUnit: v.number(), - submitFrequency: v.string(), - avatar: v.optional(v.string()), - description: v.optional(v.string()), - createdBy: v.string(), - createdAt: v.number(), - isDeleted: v.boolean(), -}).index("by_created_by", ["createdBy"]); - -// スタッフテーブル(旧: shopUserBelongings) -// 管理者(Clerk認証)が登録するスタッフ情報 -// スタッフはマジックリンクでアクセス(アカウント登録不要) -const staffs = defineTable({ - // 基本情報 - shopId: v.id("shops"), - email: v.string(), - displayName: v.string(), - status: v.string(), // "pending" | "active" | "resigned" - role: v.optional(v.string()), // "owner" | "manager" | "general" (undefinedはgeneral扱い) - - // ユーザー紐付け(マネージャー判定用) - userId: v.optional(v.id("users")), - - // スキル・労働条件 - skills: v.optional( - v.array( - v.object({ - position: v.string(), - level: v.string(), - }), - ), - ), - maxWeeklyHours: v.optional(v.number()), - - // 招待トークン(マネージャー招待用) - inviteToken: v.optional(v.string()), - inviteExpiresAt: v.optional(v.number()), - - // 管理情報 - invitedBy: v.optional(v.string()), // 招待した管理者のauthId - resignedAt: v.optional(v.number()), - resignationReason: v.optional(v.string()), - - // スタッフメモ(管理者用) - memo: v.optional(v.string()), - workStyleNote: v.optional(v.string()), // AIシフト作成用 - hourlyWage: v.optional(v.number()), - - // メタ - createdAt: v.number(), - isDeleted: v.boolean(), -}) - .index("by_shop", ["shopId"]) - .index("by_email", ["email"]) - .index("by_shop_and_email", ["shopId", "email"]) - .index("by_invite_token", ["inviteToken"]) - .index("by_user", ["userId"]); - -// 店舗のポジション定義(店舗ごとにカスタマイズ可能) -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(), -}) - .index("by_shop", ["shopId"]) - .index("by_shop_and_name", ["shopId", "name"]); - -// スタッフのスキル(スタッフ × ポジション × レベル) -const staffSkills = defineTable({ - staffId: v.id("staffs"), - positionId: v.id("shopPositions"), - level: v.string(), // "未経験" | "研修中" | "一人前" | "ベテラン" - updatedAt: v.number(), -}) - .index("by_staff", ["staffId"]) - .index("by_position", ["positionId"]); - -// 必要人員設定(曜日ごと) -const requiredStaffing = defineTable({ - shopId: v.id("shops"), - dayOfWeek: v.number(), // 0=日, 1=月, ..., 6=土 - staffing: v.array( - v.object({ - hour: v.number(), // 0-23 - position: v.string(), - requiredCount: v.number(), - }), - ), - // ピーク帯定義(簡易入力モード) - peakBands: v.optional( - v.array( - v.object({ - startTime: v.string(), // "11:00" - endTime: v.string(), // "14:00" - requiredCount: v.number(), // 必要人数 - }), - ), - ), - // 最低人員(常時必要な最低人数) - minimumStaff: v.optional(v.number()), - // AI生成時の入力情報(作り直し用) - aiInput: v.optional( - v.object({ - shopType: v.string(), - customerCount: v.string(), - }), - ), - createdAt: v.number(), - updatedAt: v.number(), -}).index("by_shop", ["shopId"]); - -// シフト提出テーブル(スタッフがマジックリンクから提出) -const shiftRequests = defineTable({ - recruitmentId: v.id("recruitments"), - staffId: v.id("staffs"), - entries: v.array( - v.object({ - date: v.string(), // "YYYY-MM-DD" - isAvailable: v.boolean(), - startTime: v.optional(v.string()), // "09:00"(isAvailable=true時) - endTime: v.optional(v.string()), // "17:00"(isAvailable=true時) - }), - ), - submittedAt: v.number(), - updatedAt: v.optional(v.number()), -}) - .index("by_recruitment", ["recruitmentId"]) - .index("by_staff", ["staffId"]) - .index("by_recruitment_and_staff", ["recruitmentId", "staffId"]); - -// シフト割当テーブル(管理者が編集・確定するシフト) -const 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"), - startDate: v.string(), // YYYY-MM-DD - endDate: v.string(), // YYYY-MM-DD - deadline: v.string(), // YYYY-MM-DD - status: v.string(), // "open" | "closed" | "confirmed" - appliedCount: v.number(), // 申請済みスタッフ数(初期値: 0) - totalStaffCount: v.number(), // 作成時のアクティブスタッフ数 - confirmedAt: v.optional(v.number()), - createdBy: v.string(), // authId - createdAt: v.number(), - isDeleted: v.boolean(), -}) - .index("by_shop", ["shopId"]) - .index("by_shop_and_status", ["shopId", "status"]) - .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, - staffs, - shopPositions, - staffSkills, - requiredStaffing, - shiftRequests, - shiftAssignments, - recruitments, - magicLinks, -}); - -// テーブル名を型安全にエクスポート(testing.tsで使用) -export const TABLE_NAMES = Object.keys(schema.tables) as (keyof typeof schema.tables)[]; +const schema = defineSchema({}); export default schema; diff --git a/convex/shiftAssignment/mutations.ts b/convex/shiftAssignment/mutations.ts deleted file mode 100644 index 28d21e06..00000000 --- a/convex/shiftAssignment/mutations.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * シフト割当ドメイン - ミューテーション(書き込み操作) - * - * 責務: - * - 管理者が編集したシフト割当データの保存(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 deleted file mode 100644 index 0c6eb682..00000000 --- a/convex/shiftAssignment/queries.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * シフト割当ドメイン - クエリ(読み取り操作) - * - * 責務: - * - 募集に紐づく管理者編集済みシフトデータの取得 - */ -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 deleted file mode 100644 index e3d7dcf2..00000000 --- a/convex/shiftRequest/mutations.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * シフト提出ドメイン - ミューテーション(書き込み操作) - * - * 責務: - * - シフト希望の提出(新規/更新) - */ -import { ConvexError, v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { getMagicLinkByToken, isValidTimeFormat } from "../helpers"; - -// シフト希望提出 -export const submit = mutation({ - args: { - token: v.string(), - entries: v.array( - v.object({ - date: v.string(), - isAvailable: v.boolean(), - startTime: v.optional(v.string()), - endTime: v.optional(v.string()), - }), - ), - }, - handler: async (ctx, args) => { - // トークンからmagicLinkレコードとスタッフを取得 - const result = await getMagicLinkByToken(ctx, args.token); - if (!result) { - throw new ConvexError({ message: "無効なトークンです", code: "INVALID_TOKEN" }); - } - const { magicLink, staff } = result; - - // トークン有効期限チェック - if (magicLink.expiresAt < Date.now()) { - throw new ConvexError({ message: "トークンの有効期限が切れています", code: "TOKEN_EXPIRED" }); - } - - // トークンに紐づく募集を直接取得 - const recruitment = await ctx.db.get(magicLink.recruitmentId); - if (!recruitment || recruitment.isDeleted || recruitment.status !== "open") { - throw new ConvexError({ message: "募集が見つかりません", code: "NO_OPEN_RECRUITMENT" }); - } - - // エントリーのバリデーション - for (const entry of args.entries) { - if (entry.isAvailable) { - if (!entry.startTime || !entry.endTime) { - throw new ConvexError({ - message: `${entry.date}: 出勤可能な場合は開始・終了時刻が必要です`, - code: "MISSING_TIME", - }); - } - if (!isValidTimeFormat(entry.startTime) || !isValidTimeFormat(entry.endTime)) { - throw new ConvexError({ - message: `${entry.date}: 時刻の形式が不正です`, - code: "INVALID_TIME_FORMAT", - }); - } - if (entry.startTime >= entry.endTime) { - throw new ConvexError({ - message: `${entry.date}: 終了時刻は開始時刻より後にしてください`, - code: "INVALID_TIME_RANGE", - }); - } - } - } - - // 既存の提出データを確認 - const existingRequest = await ctx.db - .query("shiftRequests") - .withIndex("by_recruitment_and_staff", (q) => q.eq("recruitmentId", recruitment._id).eq("staffId", staff._id)) - .first(); - - if (existingRequest) { - // 更新 - await ctx.db.patch(existingRequest._id, { - entries: args.entries, - updatedAt: Date.now(), - }); - } else { - // 新規作成 - await ctx.db.insert("shiftRequests", { - recruitmentId: recruitment._id, - staffId: staff._id, - entries: args.entries, - submittedAt: Date.now(), - }); - - // 初回提出時のみ appliedCount をインクリメント - await ctx.db.patch(recruitment._id, { - appliedCount: recruitment.appliedCount + 1, - }); - } - - return { success: true }; - }, -}); diff --git a/convex/shiftRequest/queries.ts b/convex/shiftRequest/queries.ts deleted file mode 100644 index 2b2790ab..00000000 --- a/convex/shiftRequest/queries.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * シフト提出ドメイン - クエリ(読み取り操作) - * - * 責務: - * - マジックリンクからの提出ページデータ取得 - * - 募集に紐づく全申請の取得 - */ -import { v } from "convex/values"; -import { query } from "../_generated/server"; -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) => { - // トークンからmagicLinkレコードとスタッフを取得 - const result = await getMagicLinkByToken(ctx, args.token); - if (!result) { - return { error: "INVALID_TOKEN" as const }; - } - const { magicLink, staff } = result; - - // トークン有効期限チェック - if (magicLink.expiresAt < Date.now()) { - return { error: "TOKEN_EXPIRED" as const }; - } - - // 店舗情報取得 - const shop = await ctx.db.get(staff.shopId); - if (!shop || shop.isDeleted) { - return { error: "SHOP_NOT_FOUND" as const }; - } - - // トークンに紐づく募集を直接取得 - const recruitment = await ctx.db.get(magicLink.recruitmentId); - if (!recruitment || recruitment.isDeleted) { - return { error: "NO_OPEN_RECRUITMENT" as const }; - } - - // 募集ステータスに応じた分岐 - 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 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 }; - } - - return { error: "NO_OPEN_RECRUITMENT" as const }; - }, -}); - -// 過去の全提出データから頻出の {startTime, endTime} ペアを上位3件抽出 -const calcFrequentTimePatterns = ( - requests: { entries: { isAvailable: boolean; startTime?: string; endTime?: string }[] }[], -) => { - const countMap = new Map(); - - for (const req of requests) { - for (const entry of req.entries) { - if (entry.isAvailable && entry.startTime && entry.endTime) { - const key = `${entry.startTime}-${entry.endTime}`; - const existing = countMap.get(key); - if (existing) { - existing.count++; - } else { - countMap.set(key, { startTime: entry.startTime, endTime: entry.endTime, count: 1 }); - } - } - } - } - - return [...countMap.values()].sort((a, b) => b.count - a.count).slice(0, 3); -}; diff --git a/convex/shop/mutations.ts b/convex/shop/mutations.ts deleted file mode 100644 index 9d04dcbc..00000000 --- a/convex/shop/mutations.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * 店舗ドメイン - ミューテーション(書き込み操作) - * - * 責務: - * - 店舗のCRUD操作 - * - スタッフ追加・退職処理 - * - 権限チェックを含むビジネスロジック - */ -import { ConvexError, v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { SHOP_SUBMIT_FREQUENCY, SHOP_TIME_UNIT } from "../constants"; -import { - initializeDefaultPositions, - initializeStaffSkills, - isValidTimeFormat, - requireShop, - requireUserByAuthId, -} from "../helpers"; - -// 店舗作成 -export const create = mutation({ - args: { - shopName: v.string(), - openTime: v.string(), - closeTime: v.string(), - timeUnit: v.number(), - submitFrequency: v.string(), - description: v.optional(v.string()), - authId: v.string(), - positions: v.optional( - v.array( - v.object({ - name: v.string(), - order: v.number(), - }), - ), - ), - }, - handler: async (ctx, args) => { - const trimmedShopName = args.shopName.trim(); - const trimmedAuthId = args.authId.trim(); - - // バリデーション - if (!trimmedShopName) { - throw new ConvexError({ message: "店舗名は必須です", code: "EMPTY_SHOP_NAME" }); - } - if (!trimmedAuthId) { - throw new ConvexError({ message: "認証IDは必須です", code: "EMPTY_AUTH_ID" }); - } - if (!isValidTimeFormat(args.openTime)) { - throw new ConvexError({ message: "開店時間の形式が不正です", code: "INVALID_TIME_FORMAT" }); - } - if (!isValidTimeFormat(args.closeTime)) { - throw new ConvexError({ message: "閉店時間の形式が不正です", code: "INVALID_TIME_FORMAT" }); - } - if (!SHOP_TIME_UNIT.includes(args.timeUnit as (typeof SHOP_TIME_UNIT)[number])) { - throw new ConvexError({ message: "シフト時間単位が不正です", code: "INVALID_TIME_UNIT" }); - } - if (!SHOP_SUBMIT_FREQUENCY.includes(args.submitFrequency as (typeof SHOP_SUBMIT_FREQUENCY)[number])) { - throw new ConvexError({ message: "シフト提出頻度が不正です", code: "INVALID_SUBMIT_FREQUENCY" }); - } - - // ユーザー存在確認 - const user = await requireUserByAuthId(ctx, trimmedAuthId); - - // 店舗作成 - const shopId = await ctx.db.insert("shops", { - shopName: trimmedShopName, - openTime: args.openTime, - closeTime: args.closeTime, - timeUnit: args.timeUnit, - submitFrequency: args.submitFrequency, - avatar: "", - description: args.description, - createdBy: trimmedAuthId, - createdAt: Date.now(), - isDeleted: false, - }); - - // ポジションを初期化 - if (args.positions && args.positions.length > 0) { - // カスタムポジションを作成 - for (const pos of args.positions) { - await ctx.db.insert("shopPositions", { - shopId, - name: pos.name, - order: pos.order, - isDeleted: false, - createdAt: Date.now(), - }); - } - } else { - // デフォルトポジションを初期化 - await initializeDefaultPositions(ctx, shopId); - } - - // オーナーをスタッフとして追加 - const ownerStaffId = await ctx.db.insert("staffs", { - shopId, - email: user.email, - displayName: user.name, - status: "active", - invitedBy: trimmedAuthId, - createdAt: Date.now(), - isDeleted: false, - role: "manager", - userId: user._id, - }); - - // オーナーのスキルを初期化 - await initializeStaffSkills(ctx, shopId, ownerStaffId); - - return { - success: true, - data: { shopId, shopName: trimmedShopName }, - }; - }, -}); - -// 店舗情報更新(オーナーのみ) -export const update = mutation({ - args: { - shopId: v.id("shops"), - authId: v.string(), - shopName: v.optional(v.string()), - openTime: v.optional(v.string()), - closeTime: v.optional(v.string()), - timeUnit: v.optional(v.number()), - submitFrequency: v.optional(v.string()), - description: v.optional(v.string()), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - const fieldsToUpdate: Partial<{ - shopName: string; - openTime: string; - closeTime: string; - timeUnit: number; - submitFrequency: string; - description: string; - }> = {}; - - if (args.shopName) fieldsToUpdate.shopName = args.shopName.trim(); - if (args.openTime) fieldsToUpdate.openTime = args.openTime; - if (args.closeTime) fieldsToUpdate.closeTime = args.closeTime; - if (args.timeUnit) fieldsToUpdate.timeUnit = args.timeUnit; - if (args.submitFrequency) fieldsToUpdate.submitFrequency = args.submitFrequency; - if (args.description !== undefined) fieldsToUpdate.description = args.description; - - if (Object.keys(fieldsToUpdate).length === 0) { - throw new ConvexError({ message: "更新するフィールドがありません", code: "NO_FIELDS_TO_UPDATE" }); - } - - await ctx.db.patch(args.shopId, fieldsToUpdate); - - return args.shopId; - }, -}); - -// 店舗削除(オーナーのみ) -export const remove = mutation({ - args: { - shopId: v.id("shops"), - authId: v.string(), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - await ctx.db.patch(args.shopId, { isDeleted: true }); - - return { success: true }; - }, -}); - -// テスト用データリセット(メールアドレス指定) -export const resetUserByEmail = mutation({ - args: { email: v.string() }, - handler: async (ctx, args) => { - // ユーザー検索 - const user = await ctx.db - .query("users") - .filter((q) => q.eq(q.field("email"), args.email)) - .first(); - - if (!user) { - return { success: true, message: "User not found" }; - } - - // ユーザー削除 - await ctx.db.delete(user._id); - - if (user.authId) { - const authId = user.authId; - // 店舗削除 - const shops = await ctx.db - .query("shops") - .withIndex("by_created_by", (q) => q.eq("createdBy", authId)) - .collect(); - - for (const shop of shops) { - await ctx.db.delete(shop._id); - } - - // スタッフ削除(自分が招待したスタッフ) - const staffs = await ctx.db - .query("staffs") - .filter((q) => q.eq(q.field("invitedBy"), user.authId)) - .collect(); - - for (const staff of staffs) { - await ctx.db.delete(staff._id); - } - } - - return { success: true, deletedUser: user.email }; - }, -}); diff --git a/convex/shop/queries.ts b/convex/shop/queries.ts deleted file mode 100644 index 80115e32..00000000 --- a/convex/shop/queries.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * 店舗ドメイン - クエリ(読み取り操作) - * - * 責務: - * - 店舗情報の取得 - * - 店舗一覧の取得 - * - スタッフ一覧の取得 - */ -import { v } from "convex/values"; -import { query } from "../_generated/server"; -import { getUserByAuthId } from "../helpers"; - -// 店舗IDで取得(単純なCRUD) -export const getById = query({ - args: { shopId: v.id("shops") }, - handler: async (ctx, args) => { - const shop = await ctx.db.get(args.shopId); - if (!shop || shop.isDeleted) { - return null; - } - return shop; - }, -}); - -// authIdで所有店舗一覧取得(スタッフ人数付き) -export const listByAuthId = query({ - args: { authId: v.string() }, - handler: async (ctx, args) => { - const user = await getUserByAuthId(ctx, args.authId); - - if (!user) { - return []; - } - - // ユーザーが所属する店舗を取得(staffsテーブル経由) - const staffRecords = await ctx.db - .query("staffs") - .withIndex("by_user", (q) => q.eq("userId", user._id)) - .filter((q) => q.and(q.neq(q.field("isDeleted"), true), q.eq(q.field("status"), "active"))) - .collect(); - - // 各店舗情報とスタッフ数を取得 - const shopsWithStaffCount = await Promise.all( - staffRecords.map(async (staffRecord) => { - const shop = await ctx.db.get(staffRecord.shopId); - if (!shop || shop.isDeleted) { - return null; - } - - const staffs = await ctx.db - .query("staffs") - .withIndex("by_shop", (q) => q.eq("shopId", shop._id)) - .filter((q) => q.and(q.neq(q.field("isDeleted"), true), q.neq(q.field("status"), "resigned"))) - .collect(); - - return { - ...shop, - staffCount: staffs.length, - }; - }), - ); - - return shopsWithStaffCount.filter((shop) => shop !== null); - }, -}); - -// 店舗スタッフ一覧取得 -export const listStaffs = query({ - args: { - shopId: v.id("shops"), - authId: v.string(), - }, - handler: async (ctx, args) => { - const staffs = await ctx.db - .query("staffs") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .collect(); - - // role で isManager を判定(pending状態でも正しくマネージャー判定される) - const staffsWithRole = staffs.map((staff) => ({ - _id: staff._id, - email: staff.email, - displayName: staff.displayName, - status: staff.status, - skills: staff.skills ?? [], - maxWeeklyHours: staff.maxWeeklyHours, - createdAt: staff.createdAt, - isManager: staff.role === "manager" || staff.role === "owner", - })); - - return staffsWithRole; - }, -}); - -// スタッフ詳細情報取得 -export const getStaffInfo = query({ - args: { - shopId: v.id("shops"), - staffId: v.id("staffs"), - authId: v.string(), - }, - handler: async (ctx, args) => { - const staff = await ctx.db.get(args.staffId); - if (!staff || staff.isDeleted || staff.shopId !== args.shopId) { - return null; - } - - return { - _id: staff._id, - email: staff.email, - displayName: staff.displayName, - status: staff.status, - skills: staff.skills ?? [], - maxWeeklyHours: staff.maxWeeklyHours, - memo: staff.memo ?? "", - workStyleNote: staff.workStyleNote ?? "", - hourlyWage: staff.hourlyWage ?? null, - resignedAt: staff.resignedAt, - resignationReason: staff.resignationReason, - createdAt: staff.createdAt, - isManager: staff.role === "manager" || staff.role === "owner", - }; - }, -}); diff --git a/convex/staff/mutations.ts b/convex/staff/mutations.ts deleted file mode 100644 index 1cd2e4dc..00000000 --- a/convex/staff/mutations.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * スタッフドメイン - ミューテーション(書き込み操作) - * - * 責務: - * - スタッフの追加・退職・情報更新 - */ -import { ConvexError, v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { SKILL_LEVELS } from "../constants"; -import { createDefaultSkills, getStaff, getStaffByEmail, initializeStaffSkills, requireShop } from "../helpers"; - -// スタッフを店舗に追加(オーナーのみ) -export const addStaff = mutation({ - args: { - shopId: v.id("shops"), - email: v.string(), - displayName: v.string(), - authId: v.string(), - skills: v.optional( - v.array( - v.object({ - position: v.string(), - level: v.string(), - }), - ), - ), - maxWeeklyHours: v.optional(v.number()), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - const trimmedEmail = args.email.trim().toLowerCase(); - const trimmedDisplayName = args.displayName.trim(); - - if (!trimmedEmail) { - throw new ConvexError({ message: "メールアドレスは必須です", code: "EMPTY_EMAIL" }); - } - if (!trimmedDisplayName) { - throw new ConvexError({ message: "表示名は必須です", code: "EMPTY_DISPLAY_NAME" }); - } - - // 同じ店舗に同じメールアドレスのスタッフがいないかチェック - const existingStaff = await getStaffByEmail(ctx, args.shopId, trimmedEmail); - if (existingStaff) { - throw new ConvexError({ message: "このメールアドレスは既に登録されています", code: "EMAIL_ALREADY_EXISTS" }); - } - - // skillsが渡されなかった場合、全ポジション「未経験」で初期化 - const skills = args.skills ?? createDefaultSkills(); - - const staffId = await ctx.db.insert("staffs", { - shopId: args.shopId, - email: trimmedEmail, - displayName: trimmedDisplayName, - status: "active", - skills, // 後方互換のため残す - maxWeeklyHours: args.maxWeeklyHours, - invitedBy: args.authId, - createdAt: Date.now(), - isDeleted: false, - }); - - // 新テーブルにもスキルを初期化 - await initializeStaffSkills(ctx, args.shopId, staffId); - - return { success: true, staffId }; - }, -}); - -// スタッフを退職処理(オーナーのみ) -export const resignStaff = mutation({ - args: { - shopId: v.id("shops"), - staffId: v.id("staffs"), - authId: v.string(), - resignationReason: v.optional(v.string()), - }, - handler: async (ctx, args) => { - await requireShop(ctx, args.shopId); - - const staff = await getStaff(ctx, args.staffId); - if (!staff || staff.shopId !== args.shopId) { - throw new ConvexError({ message: "スタッフが見つかりません", code: "STAFF_NOT_FOUND" }); - } - - if (staff.status === "resigned") { - throw new ConvexError({ message: "このスタッフは既に退職済みです", code: "ALREADY_RESIGNED" }); - } - - await ctx.db.patch(args.staffId, { - status: "resigned", - resignedAt: Date.now(), - resignationReason: args.resignationReason, - }); - - return { success: true }; - }, -}); - -// スタッフ情報更新(オーナーのみ) -export const updateStaffInfo = mutation({ - args: { - shopId: v.id("shops"), - staffId: v.id("staffs"), - authId: v.string(), - email: v.optional(v.string()), - displayName: v.optional(v.string()), - skills: v.optional( - v.array( - v.object({ - positionId: v.id("shopPositions"), - level: v.string(), - }), - ), - ), - maxWeeklyHours: v.optional(v.union(v.number(), v.null())), - memo: v.optional(v.string()), - workStyleNote: v.optional(v.string()), - hourlyWage: v.optional(v.union(v.number(), v.null())), - }, - handler: async (ctx, args) => { - const staff = await getStaff(ctx, args.staffId); - if (!staff || staff.shopId !== args.shopId) { - throw new ConvexError({ message: "スタッフが見つかりません", code: "STAFF_NOT_FOUND" }); - } - - const fieldsToUpdate: Partial<{ - email: string; - displayName: string; - maxWeeklyHours: number | undefined; - memo: string; - workStyleNote: string; - hourlyWage: number | undefined; - }> = {}; - - if (args.email !== undefined) { - fieldsToUpdate.email = args.email.trim().toLowerCase(); - } - if (args.displayName !== undefined) { - fieldsToUpdate.displayName = args.displayName.trim(); - } - if (args.maxWeeklyHours !== undefined) { - fieldsToUpdate.maxWeeklyHours = args.maxWeeklyHours ?? undefined; - } - if (args.memo !== undefined) { - fieldsToUpdate.memo = args.memo; - } - if (args.workStyleNote !== undefined) { - fieldsToUpdate.workStyleNote = args.workStyleNote; - } - if (args.hourlyWage !== undefined) { - fieldsToUpdate.hourlyWage = args.hourlyWage ?? undefined; - } - - // スキルの更新(staffSkillsテーブル) - if (args.skills !== undefined) { - // 既存のスキルを取得 - const existingSkills = await ctx.db - .query("staffSkills") - .withIndex("by_staff", (q) => q.eq("staffId", args.staffId)) - .collect(); - - const existingSkillMap = new Map(existingSkills.map((s) => [s.positionId, s])); - - for (const skillInput of args.skills) { - if (!SKILL_LEVELS.includes(skillInput.level as (typeof SKILL_LEVELS)[number])) { - throw new ConvexError({ message: "無効なスキルレベルです", code: "INVALID_LEVEL" }); - } - - const existing = existingSkillMap.get(skillInput.positionId); - - if (existing) { - // 既存のスキルを更新 - await ctx.db.patch(existing._id, { - level: skillInput.level, - updatedAt: Date.now(), - }); - } else { - // 新規スキルを作成 - await ctx.db.insert("staffSkills", { - staffId: args.staffId, - positionId: skillInput.positionId, - level: skillInput.level, - updatedAt: Date.now(), - }); - } - } - } - - if (Object.keys(fieldsToUpdate).length > 0) { - await ctx.db.patch(args.staffId, fieldsToUpdate); - } - - return { success: true }; - }, -}); diff --git a/convex/staffSkill/mutations.ts b/convex/staffSkill/mutations.ts deleted file mode 100644 index 3282fa6b..00000000 --- a/convex/staffSkill/mutations.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * スタッフスキルドメイン - ミューテーション(書き込み操作) - * - * 責務: - * - スタッフのスキルレベル更新 - */ -import { ConvexError, v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { SKILL_LEVELS } from "../constants"; -import { getStaff } from "../helpers"; - -// スキルレベル更新 -export const updateLevel = mutation({ - args: { - staffSkillId: v.id("staffSkills"), - level: v.string(), - authId: v.string(), - }, - handler: async (ctx, args) => { - const staffSkill = await ctx.db.get(args.staffSkillId); - if (!staffSkill) { - throw new ConvexError({ message: "スキルが見つかりません", code: "NOT_FOUND" }); - } - - if (!SKILL_LEVELS.includes(args.level as (typeof SKILL_LEVELS)[number])) { - throw new ConvexError({ message: "無効なスキルレベルです", code: "INVALID_LEVEL" }); - } - - await ctx.db.patch(args.staffSkillId, { - level: args.level, - updatedAt: Date.now(), - }); - - return { success: true }; - }, -}); - -// 複数スキルを一括更新(スタッフ編集画面用) -export const updateMultiple = mutation({ - args: { - staffId: v.id("staffs"), - skills: v.array( - v.object({ - positionId: v.id("shopPositions"), - level: v.string(), - }), - ), - authId: v.string(), - }, - handler: async (ctx, args) => { - const staff = await getStaff(ctx, args.staffId); - if (!staff) { - throw new ConvexError({ message: "スタッフが見つかりません", code: "STAFF_NOT_FOUND" }); - } - - // 既存のスキルを取得 - const existingSkills = await ctx.db - .query("staffSkills") - .withIndex("by_staff", (q) => q.eq("staffId", args.staffId)) - .collect(); - - const existingSkillMap = new Map(existingSkills.map((s) => [s.positionId, s])); - - for (const skillInput of args.skills) { - if (!SKILL_LEVELS.includes(skillInput.level as (typeof SKILL_LEVELS)[number])) { - throw new ConvexError({ message: "無効なスキルレベルです", code: "INVALID_LEVEL" }); - } - - const existing = existingSkillMap.get(skillInput.positionId); - - if (existing) { - // 既存のスキルを更新 - await ctx.db.patch(existing._id, { - level: skillInput.level, - updatedAt: Date.now(), - }); - } else { - // 新規スキルを作成 - await ctx.db.insert("staffSkills", { - staffId: args.staffId, - positionId: skillInput.positionId, - level: skillInput.level, - updatedAt: Date.now(), - }); - } - } - - return { success: true }; - }, -}); diff --git a/convex/staffSkill/queries.ts b/convex/staffSkill/queries.ts deleted file mode 100644 index f77542dc..00000000 --- a/convex/staffSkill/queries.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * スタッフスキルドメイン - クエリ(読み取り操作) - * - * 責務: - * - スタッフのスキル一覧取得(ポジション情報付き) - */ -import { v } from "convex/values"; -import { query } from "../_generated/server"; - -// スタッフのスキル一覧を取得(ポジション情報付き) -export const listByStaff = query({ - args: { staffId: v.id("staffs") }, - handler: async (ctx, args) => { - const skills = await ctx.db - .query("staffSkills") - .withIndex("by_staff", (q) => q.eq("staffId", args.staffId)) - .collect(); - - // 並列でポジション情報を取得 - const skillsWithPositions = await Promise.all( - skills.map(async (skill) => { - const position = await ctx.db.get(skill.positionId); - return { - _id: skill._id, - staffId: skill.staffId, - positionId: skill.positionId, - level: skill.level, - updatedAt: skill.updatedAt, - positionName: position?.name ?? "", - positionOrder: position?.order ?? 0, - positionIsDeleted: position?.isDeleted ?? true, - }; - }), - ); - - // 削除されたポジションは除外し、orderでソート - return skillsWithPositions - .filter((skill) => !skill.positionIsDeleted) - .sort((a, b) => a.positionOrder - b.positionOrder); - }, -}); - -// 店舗の全スタッフのスキル一覧を取得(スタッフ一覧表示用) -export const listByShop = query({ - args: { shopId: v.id("shops") }, - handler: async (ctx, args) => { - // 店舗のスタッフ一覧を取得 - const staffs = await ctx.db - .query("staffs") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .collect(); - - // ポジション一覧を事前に一括取得してMap化(N+1回避) - const positions = await ctx.db - .query("shopPositions") - .withIndex("by_shop", (q) => q.eq("shopId", args.shopId)) - .filter((q) => q.neq(q.field("isDeleted"), true)) - .collect(); - - const positionMap = new Map(positions.map((p) => [p._id, p])); - - // 各スタッフのスキルを取得 - const staffSkillsMap = new Map< - string, - { positionId: string; positionName: string; level: string; order: number }[] - >(); - - for (const staff of staffs) { - const skills = await ctx.db - .query("staffSkills") - .withIndex("by_staff", (q) => q.eq("staffId", staff._id)) - .collect(); - - const skillsWithPositions = skills - .map((skill) => { - const position = positionMap.get(skill.positionId); - if (!position) return null; - return { - positionId: skill.positionId as string, - positionName: position.name, - level: skill.level, - order: position.order, - }; - }) - .filter((s): s is NonNullable => s !== null) - .sort((a, b) => a.order - b.order); - - staffSkillsMap.set(staff._id, skillsWithPositions); - } - - return Object.fromEntries(staffSkillsMap); - }, -}); diff --git a/convex/testing.ts b/convex/testing.ts deleted file mode 100644 index fc1eb7ed..00000000 --- a/convex/testing.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { internalMutation } from "./_generated/server"; -import { TABLE_NAMES } from "./schema"; - -/** - * E2Eテスト用:全テーブルのデータをクリア - * GitHub Actionsでseed import前に実行 - */ -export const clearAllTables = internalMutation(async ({ db }) => { - for (const tableName of TABLE_NAMES) { - const docs = await db.query(tableName).collect(); - for (const doc of docs) { - await db.delete(doc._id); - } - } - return { cleared: TABLE_NAMES }; -}); diff --git a/convex/user/mutations.ts b/convex/user/mutations.ts deleted file mode 100644 index 15f81907..00000000 --- a/convex/user/mutations.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * ユーザードメイン - ミューテーション(書き込み操作) - * - * 責務: - * - ユーザーのCRUD操作 - */ -import { ConvexError, v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { getUserByAuthId } from "../helpers"; - -// ユーザー取得または作成(初回ログイン時に自動作成) -export const getOrCreate = mutation({ - args: { - authId: v.string(), - name: v.string(), - email: v.string(), - }, - handler: async (ctx, args) => { - // 既存ユーザーを検索 - const existingUser = await getUserByAuthId(ctx, args.authId); - if (existingUser) { - return existingUser; - } - - // 新規ユーザー作成 - const userId = await ctx.db.insert("users", { - name: args.name.trim() || "新規ユーザー", - email: args.email.trim().toLowerCase(), - authId: args.authId, - status: "active", - createdAt: Date.now(), - isDeleted: false, - }); - - return await ctx.db.get(userId); - }, -}); - -// ユーザー作成 -export const create = mutation({ - args: { - name: v.string(), - email: v.string(), - authId: v.optional(v.string()), - status: v.optional(v.string()), - }, - handler: async (ctx, args) => { - if (!args.name.trim()) { - throw new ConvexError({ - message: "名前は必須です", - code: "EMPTY_NAME", - }); - } - - if (!args.email.trim()) { - throw new ConvexError({ - message: "メールアドレスは必須です", - code: "EMPTY_EMAIL", - }); - } - - // authIdがある場合は本登録、なければ仮登録 - const status = args.status ?? (args.authId ? "active" : "pending"); - - const userId = await ctx.db.insert("users", { - name: args.name.trim(), - email: args.email.trim().toLowerCase(), - authId: args.authId, - status, - createdAt: Date.now(), - isDeleted: false, - }); - - return { - success: true, - data: { userId, authId: args.authId, name: args.name.trim(), email: args.email.trim().toLowerCase(), status }, - }; - }, -}); - -// ユーザー情報更新 -export const update = mutation({ - args: { - id: v.id("users"), - name: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const { id, ...updates } = args; - - const existingUser = await ctx.db.get(id); - if (!existingUser || existingUser.isDeleted) { - throw new ConvexError({ - message: "指定されたユーザーが見つかりません", - code: "USER_NOT_FOUND", - }); - } - - if (!updates?.name?.trim()) { - throw new ConvexError({ - message: "名前は空にできません", - code: "EMPTY_NAME", - }); - } - - const fieldsToUpdate: Partial<{ name: string }> = {}; - if (updates.name) { - fieldsToUpdate.name = updates.name.trim(); - } - - if (Object.keys(fieldsToUpdate).length === 0) { - throw new ConvexError({ - message: "更新するフィールドがありません", - code: "NO_FIELDS_TO_UPDATE", - }); - } - - await ctx.db.patch(id, fieldsToUpdate); - return id; - }, -}); diff --git a/convex/user/queries.ts b/convex/user/queries.ts deleted file mode 100644 index 33a59607..00000000 --- a/convex/user/queries.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * ユーザードメイン - クエリ(読み取り操作) - * - * 責務: - * - 管理者ユーザー情報の取得 - */ -import { v } from "convex/values"; -import { query } from "../_generated/server"; -import { getUserByAuthId as getUserByAuthIdHelper } from "../helpers"; - -// authIdでユーザー取得 -export const getByAuthId = query({ - args: { authId: v.string() }, - handler: async (ctx, args) => { - return await getUserByAuthIdHelper(ctx, args.authId); - }, -}); - -// すべてのユーザー取得(管理用) -export const listAll = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db - .query("users") - .filter((q) => q.neq(q.field("isDeleted"), true)) - .order("desc") - .collect(); - }, -}); - -// ユーザーIDで取得 -export const getById = query({ - args: { - userId: v.id("users"), - }, - handler: async (ctx, args) => { - const user = await ctx.db.get(args.userId); - if (!user || user.isDeleted) { - return null; - } - return user; - }, -}); From 0b6f1329c2157ba68fd0d94d646fd2b74e08b441 Mon Sep 17 00:00:00 2001 From: y-natani Date: Wed, 25 Mar 2026 23:14:57 +0900 Subject: [PATCH 027/176] =?UTF-8?q?docs:=20v3=E3=83=97=E3=83=AD=E3=83=80?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=AE=9A=E7=BE=A9=E6=9B=B8=E3=83=BBConvex?= =?UTF-8?q?=E3=82=A2=E3=83=BC=E3=82=AD=E3=83=86=E3=82=AF=E3=83=81=E3=83=A3?= =?UTF-8?q?=E8=A6=8F=E7=B4=84=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 6 +- convex/CLAUDE.md | 108 ++++------------ designIndex.md | 0 doc/claude/soul.md | 26 +++- doc/v3/product-definition.md | 242 +++++++++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 85 deletions(-) create mode 100644 designIndex.md create mode 100644 doc/v3/product-definition.md diff --git a/CLAUDE.md b/CLAUDE.md index 1294df06..42a81774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,10 +116,10 @@ import { bar } from "@/convex/..."; ## デザイン -- `doc/design/`: デザインファイル格納ディレクトリ。`.pen`ファイルはPencil MCPツール経由で読み書きする(`Read`や`Grep`では読めない) +- `design.pen`: Pencil MCP 経由でアクセスすること(`Read`や`Grep`では読めない) - デザイン確認・編集には `batch_get`、`batch_design`、`get_screenshot` 等のPencil MCPツールを使用 -- `doc/design/INDEX.md`: .penファイルのフレームIDインデックス。参照時はまずここのIDで `batch_get(nodeIds=[...])` を使い、IDが無効な場合は `batch_get(patterns=[{name: "..."}])` でフォールバックする -- デザインフレームを追加・削除した際は `doc/design/INDEX.md` のIDを更新すること +- `designIndex.md`: .penファイルのフレームIDインデックス。参照時はまずここのIDで `batch_get(nodeIds=[...])` を使い、IDが無効な場合は `batch_get(patterns=[{name: "..."}])` でフォールバックする +- デザインフレームを追加・削除した際は `designIndex.md` のIDを更新すること ## コーディング diff --git a/convex/CLAUDE.md b/convex/CLAUDE.md index b7a9df9c..c2c23c7a 100644 --- a/convex/CLAUDE.md +++ b/convex/CLAUDE.md @@ -1,94 +1,40 @@ -# Convex アーキテクチャ設計方針 +# Convex アーキテクチャ規約 -## 設計名 -**Feature Slices + CQRS + Policy Pattern** +## 設計方針 + +**Use-Case Slices + CQRS** — ユースケース(画面/機能)単位でディレクトリを分割し、読み取り(queries)と書き込み(mutations)を分離する。 ## ディレクトリ構造 ``` convex/ -├── shop/ # ドメイン単位でディレクトリ -│ ├── queries.ts # 読み取り操作 -│ ├── mutations.ts # 書き込み操作 -│ └── policies.ts # ロール判定ロジック -│ -├── user/ -│ ├── queries.ts -│ ├── mutations.ts -│ └── policies.ts +├── {useCase}/ # ユースケース単位のディレクトリ +│ ├── queries.ts # 読み取り(query) +│ ├── mutations.ts # 書き込み(mutation) +│ └── actions.ts # 外部API呼び出し(internalAction) │ -├── invite/ -│ ├── queries.ts -│ ├── mutations.ts -│ └── policies.ts -│ -├── helpers.ts # 全ドメイン共通ヘルパー -├── constants.ts # 全ドメイン共通定数 -└── schema.ts # DBスキーマ +├── _lib/ # 共通ユーティリティ +├── constants.ts # グローバル定数・型定義 +├── schema.ts # DBスキーマ +├── auth.config.ts # Clerk認証設定 +└── _generated/ # 自動生成(編集禁止) ``` -## 各ファイルの責務 - -| ファイル | 責務 | 特徴 | -|----------|------|------| -| **queries.ts** | データ取得 | 副作用なし、policiesで表示フィルタリング | -| **mutations.ts** | データ変更 | 副作用あり、policiesで操作可否判定 | -| **policies.ts** | 権限判定 | 純粋関数、テスト容易、ドメイン知識集約 | -| **helpers.ts** | DB操作共通処理 | `getUserByAuthId`, `requireShop`等 | - -## policies.tsの設計原則 - -```ts -// ✅ 純粋関数(DBアクセスなし) -export const canResignUser = ( - executorRole: ShopUserRoleType | null, - targetRole: ShopUserRoleType, -) => { - if (!executorRole) return false; - if (targetRole === "owner") return false; - if (executorRole === "manager" && targetRole === "manager") return false; - return executorRole === "owner" || executorRole === "manager"; -}; - -// ✅ 命名規則: can〜 / is〜 -canViewResignedUsers() -canUpdateShop() -canManageInvitation() -``` +## ファイル配置ルール -## API呼び出し規則 +| コードの種類 | 配置先 | +|------------|--------| +| 特定画面/機能のAPI | そのユースケースの `queries.ts` / `mutations.ts` | +| 外部APIを呼ぶ処理 | そのユースケースの `actions.ts`(`internalAction`) | +| 複数ユースケースの共通処理 | `_lib/` | +| 定数・型定義 | `constants.ts` | -```ts -// フロントエンド -api.shop.queries.getById // 読み取り -api.shop.mutations.create // 書き込み - -api.user.queries.getByAuthId -api.user.mutations.update - -api.invite.queries.getByToken -api.invite.mutations.accept -``` - -## データフロー - -``` -[Frontend] - ↓ useQuery / useMutation -[queries.ts / mutations.ts] - ↓ 権限判定 -[policies.ts] ← 純粋関数で判定 - ↓ DB操作 -[helpers.ts] ← 共通処理 - ↓ -[Convex DB] -``` +**判断基準**: 「この API は誰が、どの画面で使うか?」で決める。DBテーブルではなくユースケースに紐付ける。 -## 設計原則まとめ +## Convex 固有の注意事項 -| 原則 | 内容 | -|------|------| -| **Feature Slices** | ドメイン単位でディレクトリ分割 | -| **CQRS** | 読み取り(queries)と書き込み(mutations)を分離 | -| **Policy Pattern** | 権限判定を純粋関数として集約 | -| **コロケーション** | 関連コードは同じディレクトリに配置 | +- `_` プレフィクスのディレクトリ(`_lib/` 等)はConvexがAPIとして公開しない +- `_generated/` は Convex CLI が自動生成するため手動編集禁止 +- `actions.ts` は `internalAction` で定義し、`mutations` から `ctx.scheduler` 経由で呼び出す +- queries のエラーは `null` or `{ error }` を返す(throwしない)。mutations のエラーは `ConvexError` をthrow +- 論理削除は `isDeleted` フラグを使用。クエリでは常にフィルタリングする diff --git a/designIndex.md b/designIndex.md new file mode 100644 index 00000000..e69de29b diff --git a/doc/claude/soul.md b/doc/claude/soul.md index 99851e7c..93e09f47 100644 --- a/doc/claude/soul.md +++ b/doc/claude/soul.md @@ -1,5 +1,29 @@ ## このAI自身の考え方をまとめる重要なファイル +### 基本原則 + 1. ユーザーの視点で物事を考えること 2. UXデザインの観点で解決策を考えること -3. UIで解決すること \ No newline at end of file +3. UIで解決すること + +### プロダクトの核心 + +> **「スタッフに何も要求しないシフト管理」** + +管理者だけが登録すれば即日運用開始。スタッフはメールのリンクをタップするだけ。アカウント登録もアプリインストールも不要。 + +すべての設計判断で「この機能はスタッフに何かを要求していないか?」と問うこと。 + +### 判断基準 + +- **迷ったら「田中さんならどうする?」で即決する** + - 田中さん = 飲食店の店長、スタッフ10名、ITスキルはExcel+LINE程度 + - 業態は飲食で考えるが、UI設計は業種名をハードコードしない(結果的に他業種でも使える汎用設計とする) + +### YPSが提供すべき体験 + +> スタッフがリンクから希望を出した翌朝にシフトボードを開いたら、**全員の希望が既にガントチャート上にバーとして並んでいる**。 +> +> LINEを遡って1件ずつExcelに打ち込む30-60分の作業が、最初から存在しない。 + +「Excelより楽」を実感する瞬間がプロダクトの生命線。この体験を損なう設計判断はしない。 diff --git a/doc/v3/product-definition.md b/doc/v3/product-definition.md new file mode 100644 index 00000000..b3a4872b --- /dev/null +++ b/doc/v3/product-definition.md @@ -0,0 +1,242 @@ +# YPS v3 プロダクト定義書 + +> **ステータス**: 仮説段階(未検証) +> **作成日**: 2026-03-25 +> **次のアクション**: オンボーディング設計 → 画面設計 → 実装 + +--- + +## 1. プロダクトの一言定義 + +> **要検討**: 以下は仮。今後ブラッシュアップする。 + +**「スタッフに何も要求しないシフト管理」** + +管理者だけが登録すれば即日運用開始。スタッフはメールのリンクをタップするだけ。アカウント登録もアプリインストールも不要。 + +--- + +## 2. 市場データ(2025年調査ベース) + +### シフト管理方法の分布(クロスビット社調査、n=1,242) + +| 管理方法 | 割合 | +|---------|------| +| 紙で回収 + Excelで管理 | 47.7% | +| 口頭/LINE + Excelで管理 | 約23% | +| **ツール未導入 合計** | **75.0%** | +| シフト管理ツール導入済み | 25.0%(自社開発11%、外部サービス14%) | + +### 店舗規模とシフト管理方法の相関(飲食店ドットコム調査、n=200) + +| 規模 | 紙 | Excel | ツール | +|------|-----|-------|--------| +| 1-5名 | 60%+ | 30% | 少 | +| 6-10名 | 約40% | 約50% | 約10% | +| 11名以上 | 30% | 61% | 9% | + +### シフト管理の課題(複数回答) + +| 課題 | 割合 | +|------|------| +| 急な変更対応が大変 | 32% | +| シフト表の作成に時間がかかる | 30% | +| 希望と必要人員のバランス調整が難しい | 28.3% | +| 公平なシフト編成が難しい | 22.1% | +| 特に課題はない | 約15% | + +### ツール未導入の理由 + +- **費用対効果への懐疑** → 無料プランで解消 +- **現状維持の安心感** → スタッフ側負担ゼロで導入ハードルを解消 + +--- + +## 3. ターゲット + +### スイートスポット + +**6〜20名規模の店舗でExcel/スプレッドシートでシフトを管理している層** + +- 5名以下:紙で十分回るのでツールの必要性が薄い +- 20名超:Airシフト等の本格ツールの費用対効果が出る +- 6〜20名:**「Excelだと辛いけど、有料ツールを入れるほどでもない」ゾーン** + +### 基準ペルソナ:田中さん(仮) + +- 飲食店の店長(業態は限定しない) +- 営業時間:朝〜夜(日またぎなし) +- スタッフ10名(学生3人、パート3人、フリーター4人) +- シフトは週1で作成 +- 希望収集:LINE/口頭。管理:Excel +- ITスキル:Excel基本操作 + LINE程度 + +> **注意**: 業態は飲食で固定しているが、UI設計は業種名をハードコードしない。結果的に他業種でも使える汎用設計とする。判断に迷ったときは「田中さんならどうする?」で即決する。 + +--- + +## 4. 仮説(検証前) + +### A. 課題仮説 + +| 優先度 | 課題 | 詳細 | +|--------|------|------| +| **P1** | 組むのが辛い | Excelに転記→調整で2-3時間。毎週繰り返し。かかりっきりで他の作業ができない | +| P2 | 集めるのが辛い | LINEで催促、既読スルー、締切破り | +| P3 | 伝えるのが辛い | スクショ共有→「いつでしたっけ?」の個別質問 | + +### B. 解決策仮説 + +| 課題 | YPSの解決策 | +|------|------------| +| P1(組むのが辛い) | 提出希望がそのままシフトボードに反映。ドラッグ編集。**転記作業が完全消滅** | +| P2(集めるのが辛い) | メール通知でリンク送付。回答状況リアルタイム表示。未提出者が一目で分かる | +| P3(伝えるのが辛い) | 確定ボタン1つで全員通知。各自リンクから自分のシフト確認可能 | + +### C. 差別化仮説 + +| vs | YPSの優位性 | +|----|------------| +| vs Excel | 希望収集→転記→調整→通知が一気通貫。**Step 2(転記作業30-60分)が消滅** | +| vs 既存ツール(Oplus等) | スタッフにアプリ導入/ログインを強制しない。管理者だけで導入完了 | + +### D.「Excelより楽」の瞬間 + +田中さんがYPSを初めて使い、スタッフがリンクから希望を出した翌朝にシフトボードを開いたら、**全員の希望が既にガントチャート上にバーとして並んでいる**。 + +LINEを遡って1件ずつExcelに打ち込む30-60分の作業が、最初から存在しない。 + +### E. 検証方法 + +| 仮説 | 検証方法 | +|------|---------| +| ペルソナ | 実ユーザーの反応を見る | +| 課題の優先順位 | プロトタイプへの反応で判断 | +| 差別化 | 「Oplusでよくない?」に反論できるか | +| 解決策 | プロトタイプを見せて反応を確認 | + +--- + +## 5. MVPスコープ + +### やること + +**管理者(店長)フロー:** +1. 募集を作る(期間を選んで「募集開始」) +2. スタッフにメールが届く(マジックリンク付き) +3. 提出状況をリアルタイムで確認する +4. シフトボードでガントチャート編集(横軸=時間、縦軸=スタッフ) +5. 確定ボタンで全スタッフにメール通知 +6. 確定後の微修正(保存のみ、再通知なし) + +**スタッフフロー:** +1. メールのリンクを開く(ログイン不要) +2. シフト希望を提出する +3. 確定後、同じリンクから自分のシフトを確認する + +**提出UX(スタッフ体験の生命線):** +- 「前回と同じで提出」を最上位に配置(プレビュー付き) +- パターンA(70%):前回と同じ → 1タップで完了 +- パターンB(20%):一部変更 → 前回反映 → 変更箇所のみ修正 +- パターンC(10%):初回 → 日ごとに入力 +- 「よく使う時間」チップでワンタップ時間選択 + +### 技術的な固定値 + +- シフト時間単位:**30分固定**(設定不要) +- ポジション管理:**MVPでは不要**(「誰がいつ出るか」のみ) +- 通知チャネル:**メールのみ**(LINE連携は将来) + +### やらないこと + +| やらないこと | 理由 | +|-------------|------| +| ポジション管理 | MVPでは「誰がいつ出るか」で十分 | +| スキル管理 | ポジションがないので不要 | +| 必要人員設定 | ポジションがないので不要 | +| タイムカード・勤怠集計 | スコープ外。別プロダクトの領域 | +| GPS打刻 | スコープ外 | +| 給与計算・CSV出力 | スコープ外 | +| 多店舗管理 | 1店舗で十分回してから | +| Owner/Manager権限区分 | 管理者は1人で十分 | +| 時間単位の設定UI | 30分固定 | +| AI自動シフト作成 | Phase 4で有料化の軸として実装 | +| LINE連携 | Phase 3で通知チャネルとして追加 | +| 既存ツールからの乗り換え訴求 | 個人開発で大手と戦わない | + +--- + +## 6. ロードマップ + +### MVP(無料・最速リリース) +- 管理者:募集→希望確認→ドラッグ編集→確定→メール通知 +- スタッフ:リンクからログイン不要で希望提出・確定シフト確認 +- 前回入力の保持(「前回と同じ」ボタン) +- 設定:店舗情報、スタッフのメアド登録 + +### Phase 2:使い続けてもらう機能 +- 充足度アラート(ピーク帯に人が足りない警告) +- 確定後の差分通知 +- スタッフ側の確定シフト閲覧改善 + +### Phase 3:通知チャネル拡張 +- LINE Messaging API連携(募集通知・確定通知をLINEで) +- 未提出者へのリマインド自動送信 + +### Phase 4:AI自動作成(有料化の軸) +- 希望 + 制約からシフト案を自動生成 +- 管理者はAI案をドラッグで微調整するだけ +- **ここが課金ポイント**(API呼び出しコストの合理的理由あり) + +### Phase 5:拡張 +- ポジション管理の追加 +- 多店舗対応 +- ヘルプ要請機能 + +--- + +## 7. 技術スタック(前回から継続) + +| 領域 | 技術 | +|------|------| +| フロントエンド | React 19, TanStack Router, Chakra UI v3, Jotai | +| フォーム | React Hook Form + Zod | +| バックエンド | Convex(サーバーレス・リアルタイムDB) | +| 認証 | Clerk(管理者のみ) | +| テスト | Vitest, Playwright, Storybook | +| リンター | Biome | + +--- + +## 8. 前回のコードから活かすもの + +| 活かす | 理由 | +|--------|------| +| Convexスキーマの基本構造 | users, shops, staffs, recruitments, shiftRequests, shiftAssignments, magicLinks | +| ShiftFormのドラッグ操作ロジック | shiftOperations.ts, timeConversion.ts のコアロジック | +| スタッフ提出UI(ShiftSubmit) | 前回入力反映、よく使う時間チップの仕組み | +| Clerk/Convex/Chakraの設定周り | 認証・DB・UIの基盤 | +| Storybook駆動の開発スタイル | コンポーネント単位の開発手法 | +| SP版レイアウトの考え方 | モバイルファーストの設計方針 | + +| 捨てる | 理由 | +|--------|------| +| ページ構成(10ページ以上) | CRUD地獄の根源。3ページで再構成 | +| ポジション管理関連(shopPositions, staffSkills, カラーピッカー等) | MVPスコープ外 | +| 必要人員設定(requiredStaffing, StaffingRequirement等) | MVPスコープ外 | +| PositionToolbarのundo/redo, selectモード | 不要な複雑性 | +| SummaryRow(充足度グラデーション) | ポジションがないので不要 | + +--- + +## 9. 未解決の論点 + +| 論点 | 状態 | 優先度 | +|------|------|--------| +| プロダクトの一言定義 | 仮決定・要ブラッシュアップ | 高 | +| オンボーディング(初回フロー)の設計 | 未着手 | 高 | +| シフトボード(管理者メイン画面)の画面設計 | 未着手 | 高 | +| ユーザーストーリーの詳細化と検証 | 定義済み・未検証 | 高 | +| ガントチャートUIの具体的な操作設計 | 方針のみ決定 | 中 | +| 料金プランの詳細 | Phase 4で検討 | 低 | +| LINE連携の技術設計 | Phase 3で検討 | 低 | From f23ed8737b87a8f71f2e196cc6ab60c688c5ae45 Mon Sep 17 00:00:00 2001 From: y-natani Date: Wed, 25 Mar 2026 23:17:01 +0900 Subject: [PATCH 028/176] =?UTF-8?q?chore:=20v2=E3=81=AE=E4=B8=8D=E8=A6=81?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92=E4=B8=80=E6=8B=AC=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .coderabbit.yml | 1 - e2e/.gitignore | 2 - e2e/constants/index.ts | 5 - e2e/fixtures/setup/login.setup.ts | 53 -- e2e/helpers/navigation.ts | 89 --- e2e/scenarios/auth/auth.test.ts | 11 - e2e/scenarios/invite/create.test.ts | 68 --- e2e/scenarios/settings/user-profile.test.ts | 41 -- e2e/scenarios/shop/detail.test.ts | 34 -- e2e/scenarios/shop/edit.test.ts | 47 -- e2e/scenarios/shop/list.test.ts | 47 -- e2e/scenarios/shop/register.test.ts | 29 - e2e/scenarios/staff/add.test.ts | 45 -- e2e/scenarios/staff/edit.test.ts | 38 -- e2e/scenarios/staff/list.test.ts | 63 --- e2e/scenarios/staff/resign.test.ts | 37 -- old_dbSchema.prisma | 213 ------- .../features/MyPage/index.stories.tsx | 10 - src/components/features/MyPage/index.tsx | 284 ---------- .../UserSetting/ShopSetting/index.stories.tsx | 14 - .../Setting/UserSetting/ShopSetting/index.tsx | 59 -- .../UserSetting/UserProfile/index.stories.tsx | 16 - .../Setting/UserSetting/UserProfile/index.tsx | 34 -- .../Setting/UserSetting/index.stories.tsx | 22 - .../features/Setting/UserSetting/index.tsx | 80 --- .../PeakBandSettings/ModeConfirmDialog.tsx | 44 -- .../Shift/PeakBandSettings/index.stories.tsx | 86 --- .../features/Shift/PeakBandSettings/index.tsx | 519 ------------------ .../Shift/RecruitmentDetail/index.stories.tsx | 95 ---- .../Shift/RecruitmentDetail/index.tsx | 206 ------- .../Shift/RecruitmentForm/index.stories.tsx | 68 --- .../features/Shift/RecruitmentForm/index.tsx | 68 --- .../features/Shift/RecruitmentForm/schema.ts | 18 - .../Shift/RecruitmentList/index.stories.tsx | 68 --- .../features/Shift/RecruitmentList/index.tsx | 251 --------- .../features/Shift/RecruitmentNew/index.tsx | 84 --- .../features/Shift/ShiftConfirm/index.tsx | 254 --------- .../ShiftForm/pc/DailyView/PeakBandAlert.tsx | 78 --- .../pc/DailyView/PositionToolbar.tsx | 36 -- .../ShiftForm/pc/DailyView/SummaryRow.tsx | 276 ---------- .../pc/OverviewView/SummaryFooterRow.tsx | 197 ------- .../ShiftForm/utils/staffingAlerts.test.ts | 82 --- .../Shift/ShiftForm/utils/staffingAlerts.ts | 116 ---- .../AIGenerateForm/index.stories.tsx | 83 --- .../AIGenerateForm/index.tsx | 83 --- .../AIInputFields/index.tsx | 51 -- .../CopyModal/index.stories.tsx | 44 -- .../StaffingRequirement/CopyModal/index.tsx | 95 ---- .../DaySelector/index.stories.tsx | 59 -- .../StaffingRequirement/DaySelector/index.tsx | 119 ---- .../DayTabs/index.stories.tsx | 67 --- .../StaffingRequirement/DayTabs/index.tsx | 50 -- .../MobileAccordionView/index.stories.tsx | 69 --- .../MobileAccordionView/index.tsx | 97 ---- .../MobileActionBar/index.stories.tsx | 46 -- .../MobileActionBar/index.tsx | 44 -- .../RegenerateModal/index.stories.tsx | 43 -- .../RegenerateModal/index.tsx | 96 ---- .../SetupWizard/index.stories.tsx | 50 -- .../StaffingRequirement/SetupWizard/index.tsx | 247 --------- .../StaffingTable/StepperCell.tsx | 55 -- .../StaffingTable/index.stories.tsx | 107 ---- .../StaffingTable/index.tsx | 119 ---- .../WeeklyHeatmap/index.stories.tsx | 68 --- .../WeeklyHeatmap/index.tsx | 151 ----- .../Shift/StaffingRequirement/constants.ts | 24 - .../StaffingRequirement/index.stories.tsx | 154 ------ .../Shift/StaffingRequirement/index.tsx | 342 ------------ .../Shift/StaffingRequirement/types.ts | 33 -- .../StaffingRequirement/useDayNavigation.ts | 45 -- .../StaffingRequirement/useStaffingData.ts | 157 ------ .../StaffingRequirement/utils/dayHelpers.ts | 6 - .../utils/generateMockStaffing.ts | 33 -- .../utils/heatmapCalculations.test.ts | 84 --- .../utils/heatmapCalculations.ts | 73 --- .../utils/staffingMapHelpers.test.ts | 89 --- .../utils/staffingMapHelpers.ts | 42 -- .../utils/summaryCalculations.test.ts | 55 -- .../utils/summaryCalculations.ts | 48 -- .../utils/timeHelpers.test.ts | 24 - .../StaffingRequirement/utils/timeHelpers.ts | 11 - .../utils/transformRecruitmentData.test.ts | 191 ------- .../Shift/utils/transformRecruitmentData.ts | 168 ------ .../ShiftSubmit/ConfirmView.stories.tsx | 33 -- .../features/ShiftSubmit/ConfirmView.tsx | 65 --- .../features/ShiftSubmit/ConfirmedView.tsx | 102 ---- .../features/ShiftSubmit/DayCard.stories.tsx | 72 --- .../features/ShiftSubmit/DayCard.tsx | 176 ------ .../features/ShiftSubmit/EntryForm.tsx | 85 --- .../ShiftSubmit/SubmittedView.stories.tsx | 42 -- .../features/ShiftSubmit/SubmittedView.tsx | 76 --- .../features/ShiftSubmit/dayStyle.ts | 35 -- .../features/ShiftSubmit/index.stories.tsx | 82 --- src/components/features/ShiftSubmit/index.tsx | 194 ------- .../Shop/MemberAddModal/index.stories.tsx | 26 - .../features/Shop/MemberAddModal/index.tsx | 166 ------ .../features/Shop/MemberAddModal/schema.ts | 12 - .../Shop/Position/PositionAddForm.tsx | 84 --- .../Shop/Position/SortablePositionItem.tsx | 148 ----- .../Shop/Position/validatePositionName.ts | 9 - .../Shop/PositionEditor/index.stories.tsx | 69 --- .../features/Shop/PositionEditor/index.tsx | 186 ------- .../Shop/PositionManager/index.stories.tsx | 54 -- .../features/Shop/PositionManager/index.tsx | 335 ----------- .../Shop/ShopDetail/index.stories.tsx | 86 --- .../features/Shop/ShopDetail/index.tsx | 187 ------- .../features/Shop/ShopEdit/index.stories.tsx | 42 -- .../features/Shop/ShopEdit/index.tsx | 171 ------ .../features/Shop/ShopForm/index.stories.tsx | 89 --- .../features/Shop/ShopForm/index.tsx | 169 ------ .../features/Shop/ShopForm/schema.ts | 37 -- .../features/Shop/ShopList/index.stories.tsx | 41 -- .../features/Shop/ShopList/index.tsx | 131 ----- .../Shop/ShopRegister/index.stories.tsx | 30 - .../features/Shop/ShopRegister/index.tsx | 107 ---- .../features/Shop/ShopRegister/schema.test.ts | 95 ---- .../Shop/ShopSelector/index.stories.tsx | 41 -- .../features/Shop/ShopSelector/index.tsx | 50 -- .../Staff/StaffDetail/index.stories.tsx | 116 ---- .../features/Staff/StaffDetail/index.tsx | 143 ----- .../StaffDetailContent/index.stories.tsx | 129 ----- .../Staff/StaffDetailContent/index.tsx | 291 ---------- .../Staff/StaffEdit/index.stories.tsx | 134 ----- .../features/Staff/StaffEdit/index.tsx | 311 ----------- .../Staff/StaffEditForm/index.stories.tsx | 102 ---- .../features/Staff/StaffEditForm/index.tsx | 304 ---------- .../features/Staff/StaffEditForm/schema.ts | 31 -- .../Staff/StaffEditModal/index.stories.tsx | 40 -- .../features/Staff/StaffEditModal/index.tsx | 218 -------- .../Staff/StaffList/index.stories.tsx | 97 ---- .../features/Staff/StaffList/index.tsx | 293 ---------- .../User/UserRegister/index.stories.tsx | 12 - .../features/User/UserRegister/index.tsx | 123 ----- .../features/User/UserRegister/schema.ts | 10 - src/components/pages/Invite/index.tsx | 254 --------- .../pages/Settings/SettingsPage/index.tsx | 21 - .../Settings/ShiftTemplateForm/index.tsx | 195 ------- .../Settings/ShiftTemplateList/index.tsx | 317 ----------- src/components/pages/ShiftSubmit/index.tsx | 129 ----- .../pages/Shops/DetailPage/index.tsx | 32 -- src/components/pages/Shops/EditPage/index.tsx | 28 - src/components/pages/Shops/ListPage/index.tsx | 22 - src/components/pages/Shops/NewPage/index.tsx | 5 - .../Shops/RecruitmentDetailPage/index.tsx | 76 --- .../pages/Shops/RecruitmentNewPage/index.tsx | 26 - .../pages/Shops/ShiftConfirmPage/index.tsx | 82 --- .../pages/Shops/ShiftsPage/index.tsx | 34 -- .../pages/Shops/StaffDetailPage/index.tsx | 53 -- .../pages/Shops/StaffEditPage/index.tsx | 53 -- .../Shops/StaffingSettingsPage/index.tsx | 96 ---- .../pages/Shops/StaffsPage/index.tsx | 43 -- .../pages/TopPage/Sections/CTASection.tsx | 43 -- .../TopPage/Sections/FeaturesSection.tsx | 84 --- .../pages/TopPage/Sections/Footer.tsx | 96 ---- .../pages/TopPage/Sections/Header.tsx | 97 ---- .../pages/TopPage/Sections/HeroSection.tsx | 153 ------ .../TopPage/Sections/ProblemsSection.tsx | 58 -- .../TopPage/Sections/TargetUsersSection.tsx | 116 ---- src/components/pages/TopPage/index.tsx | 32 -- src/components/pages/WelcomePage/index.tsx | 105 ---- src/helpers/domain/convertShopData.ts | 32 -- src/hooks/useInitializeShop.ts | 37 -- src/routes/_auth/mypage.tsx | 15 - src/routes/_auth/settings/index.tsx | 15 - .../_auth/settings/shift-template/add.tsx | 15 - .../_auth/settings/shift-template/edit.tsx | 15 - .../_auth/settings/shift-template/index.tsx | 15 - src/routes/_auth/shifts.tsx | 47 -- src/routes/_auth/shops/$shopId/edit/index.tsx | 16 - src/routes/_auth/shops/$shopId/index.tsx | 17 - .../_auth/shops/$shopId/shifts/index.tsx | 16 - .../recruitments/$recruitmentId/confirm.tsx | 18 - .../recruitments/$recruitmentId/index.tsx | 18 - .../shops/$shopId/shifts/recruitments/new.tsx | 16 - .../_auth/shops/$shopId/shifts/settings.tsx | 16 - .../shops/$shopId/staffs/$staffId/edit.tsx | 17 - .../shops/$shopId/staffs/$staffId/index.tsx | 17 - .../_auth/shops/$shopId/staffs/index.tsx | 16 - src/routes/_auth/shops/index.tsx | 15 - src/routes/_auth/shops/new/index.tsx | 15 - src/routes/invite.tsx | 18 - src/routes/shift-submit.tsx | 18 - 182 files changed, 15665 deletions(-) delete mode 100644 .coderabbit.yml delete mode 100644 e2e/.gitignore delete mode 100644 e2e/constants/index.ts delete mode 100644 e2e/fixtures/setup/login.setup.ts delete mode 100644 e2e/helpers/navigation.ts delete mode 100644 e2e/scenarios/auth/auth.test.ts delete mode 100644 e2e/scenarios/invite/create.test.ts delete mode 100644 e2e/scenarios/settings/user-profile.test.ts delete mode 100644 e2e/scenarios/shop/detail.test.ts delete mode 100644 e2e/scenarios/shop/edit.test.ts delete mode 100644 e2e/scenarios/shop/list.test.ts delete mode 100644 e2e/scenarios/shop/register.test.ts delete mode 100644 e2e/scenarios/staff/add.test.ts delete mode 100644 e2e/scenarios/staff/edit.test.ts delete mode 100644 e2e/scenarios/staff/list.test.ts delete mode 100644 e2e/scenarios/staff/resign.test.ts delete mode 100644 old_dbSchema.prisma delete mode 100644 src/components/features/MyPage/index.stories.tsx delete mode 100644 src/components/features/MyPage/index.tsx delete mode 100644 src/components/features/Setting/UserSetting/ShopSetting/index.stories.tsx delete mode 100644 src/components/features/Setting/UserSetting/ShopSetting/index.tsx delete mode 100644 src/components/features/Setting/UserSetting/UserProfile/index.stories.tsx delete mode 100644 src/components/features/Setting/UserSetting/UserProfile/index.tsx delete mode 100644 src/components/features/Setting/UserSetting/index.stories.tsx delete mode 100644 src/components/features/Setting/UserSetting/index.tsx delete mode 100644 src/components/features/Shift/PeakBandSettings/ModeConfirmDialog.tsx delete mode 100644 src/components/features/Shift/PeakBandSettings/index.stories.tsx delete mode 100644 src/components/features/Shift/PeakBandSettings/index.tsx delete mode 100644 src/components/features/Shift/RecruitmentDetail/index.stories.tsx delete mode 100644 src/components/features/Shift/RecruitmentDetail/index.tsx delete mode 100644 src/components/features/Shift/RecruitmentForm/index.stories.tsx delete mode 100644 src/components/features/Shift/RecruitmentForm/index.tsx delete mode 100644 src/components/features/Shift/RecruitmentForm/schema.ts delete mode 100644 src/components/features/Shift/RecruitmentList/index.stories.tsx delete mode 100644 src/components/features/Shift/RecruitmentList/index.tsx delete mode 100644 src/components/features/Shift/RecruitmentNew/index.tsx delete mode 100644 src/components/features/Shift/ShiftConfirm/index.tsx delete mode 100644 src/components/features/Shift/ShiftForm/pc/DailyView/PeakBandAlert.tsx delete mode 100644 src/components/features/Shift/ShiftForm/pc/DailyView/PositionToolbar.tsx delete mode 100644 src/components/features/Shift/ShiftForm/pc/DailyView/SummaryRow.tsx delete mode 100644 src/components/features/Shift/ShiftForm/pc/OverviewView/SummaryFooterRow.tsx delete mode 100644 src/components/features/Shift/ShiftForm/utils/staffingAlerts.test.ts delete mode 100644 src/components/features/Shift/ShiftForm/utils/staffingAlerts.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/AIGenerateForm/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/AIGenerateForm/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/AIInputFields/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/CopyModal/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/CopyModal/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/DaySelector/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/DaySelector/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/DayTabs/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/DayTabs/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/MobileAccordionView/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/MobileAccordionView/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/MobileActionBar/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/MobileActionBar/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/RegenerateModal/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/RegenerateModal/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/SetupWizard/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/SetupWizard/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/StaffingTable/StepperCell.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/StaffingTable/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/StaffingTable/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/WeeklyHeatmap/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/WeeklyHeatmap/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/constants.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/index.stories.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/index.tsx delete mode 100644 src/components/features/Shift/StaffingRequirement/types.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/useDayNavigation.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/useStaffingData.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/dayHelpers.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/generateMockStaffing.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/heatmapCalculations.test.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/heatmapCalculations.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/staffingMapHelpers.test.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/staffingMapHelpers.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/summaryCalculations.test.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/summaryCalculations.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/timeHelpers.test.ts delete mode 100644 src/components/features/Shift/StaffingRequirement/utils/timeHelpers.ts delete mode 100644 src/components/features/Shift/utils/transformRecruitmentData.test.ts delete mode 100644 src/components/features/Shift/utils/transformRecruitmentData.ts delete mode 100644 src/components/features/ShiftSubmit/ConfirmView.stories.tsx delete mode 100644 src/components/features/ShiftSubmit/ConfirmView.tsx delete mode 100644 src/components/features/ShiftSubmit/ConfirmedView.tsx delete mode 100644 src/components/features/ShiftSubmit/DayCard.stories.tsx delete mode 100644 src/components/features/ShiftSubmit/DayCard.tsx delete mode 100644 src/components/features/ShiftSubmit/EntryForm.tsx delete mode 100644 src/components/features/ShiftSubmit/SubmittedView.stories.tsx delete mode 100644 src/components/features/ShiftSubmit/SubmittedView.tsx delete mode 100644 src/components/features/ShiftSubmit/dayStyle.ts delete mode 100644 src/components/features/ShiftSubmit/index.stories.tsx delete mode 100644 src/components/features/ShiftSubmit/index.tsx delete mode 100644 src/components/features/Shop/MemberAddModal/index.stories.tsx delete mode 100644 src/components/features/Shop/MemberAddModal/index.tsx delete mode 100644 src/components/features/Shop/MemberAddModal/schema.ts delete mode 100644 src/components/features/Shop/Position/PositionAddForm.tsx delete mode 100644 src/components/features/Shop/Position/SortablePositionItem.tsx delete mode 100644 src/components/features/Shop/Position/validatePositionName.ts delete mode 100644 src/components/features/Shop/PositionEditor/index.stories.tsx delete mode 100644 src/components/features/Shop/PositionEditor/index.tsx delete mode 100644 src/components/features/Shop/PositionManager/index.stories.tsx delete mode 100644 src/components/features/Shop/PositionManager/index.tsx delete mode 100644 src/components/features/Shop/ShopDetail/index.stories.tsx delete mode 100644 src/components/features/Shop/ShopDetail/index.tsx delete mode 100644 src/components/features/Shop/ShopEdit/index.stories.tsx delete mode 100644 src/components/features/Shop/ShopEdit/index.tsx delete mode 100644 src/components/features/Shop/ShopForm/index.stories.tsx delete mode 100644 src/components/features/Shop/ShopForm/index.tsx delete mode 100644 src/components/features/Shop/ShopForm/schema.ts delete mode 100644 src/components/features/Shop/ShopList/index.stories.tsx delete mode 100644 src/components/features/Shop/ShopList/index.tsx delete mode 100644 src/components/features/Shop/ShopRegister/index.stories.tsx delete mode 100644 src/components/features/Shop/ShopRegister/index.tsx delete mode 100644 src/components/features/Shop/ShopRegister/schema.test.ts delete mode 100644 src/components/features/Shop/ShopSelector/index.stories.tsx delete mode 100644 src/components/features/Shop/ShopSelector/index.tsx delete mode 100644 src/components/features/Staff/StaffDetail/index.stories.tsx delete mode 100644 src/components/features/Staff/StaffDetail/index.tsx delete mode 100644 src/components/features/Staff/StaffDetailContent/index.stories.tsx delete mode 100644 src/components/features/Staff/StaffDetailContent/index.tsx delete mode 100644 src/components/features/Staff/StaffEdit/index.stories.tsx delete mode 100644 src/components/features/Staff/StaffEdit/index.tsx delete mode 100644 src/components/features/Staff/StaffEditForm/index.stories.tsx delete mode 100644 src/components/features/Staff/StaffEditForm/index.tsx delete mode 100644 src/components/features/Staff/StaffEditForm/schema.ts delete mode 100644 src/components/features/Staff/StaffEditModal/index.stories.tsx delete mode 100644 src/components/features/Staff/StaffEditModal/index.tsx delete mode 100644 src/components/features/Staff/StaffList/index.stories.tsx delete mode 100644 src/components/features/Staff/StaffList/index.tsx delete mode 100644 src/components/features/User/UserRegister/index.stories.tsx delete mode 100644 src/components/features/User/UserRegister/index.tsx delete mode 100644 src/components/features/User/UserRegister/schema.ts delete mode 100644 src/components/pages/Invite/index.tsx delete mode 100644 src/components/pages/Settings/SettingsPage/index.tsx delete mode 100644 src/components/pages/Settings/ShiftTemplateForm/index.tsx delete mode 100644 src/components/pages/Settings/ShiftTemplateList/index.tsx delete mode 100644 src/components/pages/ShiftSubmit/index.tsx delete mode 100644 src/components/pages/Shops/DetailPage/index.tsx delete mode 100644 src/components/pages/Shops/EditPage/index.tsx delete mode 100644 src/components/pages/Shops/ListPage/index.tsx delete mode 100644 src/components/pages/Shops/NewPage/index.tsx delete mode 100644 src/components/pages/Shops/RecruitmentDetailPage/index.tsx delete mode 100644 src/components/pages/Shops/RecruitmentNewPage/index.tsx delete mode 100644 src/components/pages/Shops/ShiftConfirmPage/index.tsx delete mode 100644 src/components/pages/Shops/ShiftsPage/index.tsx delete mode 100644 src/components/pages/Shops/StaffDetailPage/index.tsx delete mode 100644 src/components/pages/Shops/StaffEditPage/index.tsx delete mode 100644 src/components/pages/Shops/StaffingSettingsPage/index.tsx delete mode 100644 src/components/pages/Shops/StaffsPage/index.tsx delete mode 100644 src/components/pages/TopPage/Sections/CTASection.tsx delete mode 100644 src/components/pages/TopPage/Sections/FeaturesSection.tsx delete mode 100644 src/components/pages/TopPage/Sections/Footer.tsx delete mode 100644 src/components/pages/TopPage/Sections/Header.tsx delete mode 100644 src/components/pages/TopPage/Sections/HeroSection.tsx delete mode 100644 src/components/pages/TopPage/Sections/ProblemsSection.tsx delete mode 100644 src/components/pages/TopPage/Sections/TargetUsersSection.tsx delete mode 100644 src/components/pages/TopPage/index.tsx delete mode 100644 src/components/pages/WelcomePage/index.tsx delete mode 100644 src/helpers/domain/convertShopData.ts delete mode 100644 src/hooks/useInitializeShop.ts delete mode 100644 src/routes/_auth/mypage.tsx delete mode 100644 src/routes/_auth/settings/index.tsx delete mode 100644 src/routes/_auth/settings/shift-template/add.tsx delete mode 100644 src/routes/_auth/settings/shift-template/edit.tsx delete mode 100644 src/routes/_auth/settings/shift-template/index.tsx delete mode 100644 src/routes/_auth/shifts.tsx delete mode 100644 src/routes/_auth/shops/$shopId/edit/index.tsx delete mode 100644 src/routes/_auth/shops/$shopId/index.tsx delete mode 100644 src/routes/_auth/shops/$shopId/shifts/index.tsx delete mode 100644 src/routes/_auth/shops/$shopId/shifts/recruitments/$recruitmentId/confirm.tsx delete mode 100644 src/routes/_auth/shops/$shopId/shifts/recruitments/$recruitmentId/index.tsx delete mode 100644 src/routes/_auth/shops/$shopId/shifts/recruitments/new.tsx delete mode 100644 src/routes/_auth/shops/$shopId/shifts/settings.tsx delete mode 100644 src/routes/_auth/shops/$shopId/staffs/$staffId/edit.tsx delete mode 100644 src/routes/_auth/shops/$shopId/staffs/$staffId/index.tsx delete mode 100644 src/routes/_auth/shops/$shopId/staffs/index.tsx delete mode 100644 src/routes/_auth/shops/index.tsx delete mode 100644 src/routes/_auth/shops/new/index.tsx delete mode 100644 src/routes/invite.tsx delete mode 100644 src/routes/shift-submit.tsx diff --git a/.coderabbit.yml b/.coderabbit.yml deleted file mode 100644 index 6d45e21a..00000000 --- a/.coderabbit.yml +++ /dev/null @@ -1 +0,0 @@ -language: "ja-JP" diff --git a/e2e/.gitignore b/e2e/.gitignore deleted file mode 100644 index 4742ef77..00000000 --- a/e2e/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.clerk -.tmp \ No newline at end of file diff --git a/e2e/constants/index.ts b/e2e/constants/index.ts deleted file mode 100644 index 46be8bbb..00000000 --- a/e2e/constants/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const E2EAuthJsonFileMainUser = "e2e/.clerk/user.json"; -export const E2EAuthJsonFileSubUser = "e2e/.clerk/user-b.json"; - -// ユーザー間でデータを共有するためのtmpファイル -export const E2ETmpInviteUrl = "e2e/.tmp/invite-url.txt"; diff --git a/e2e/fixtures/setup/login.setup.ts b/e2e/fixtures/setup/login.setup.ts deleted file mode 100644 index b88c4658..00000000 --- a/e2e/fixtures/setup/login.setup.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { clerk, clerkSetup } from "@clerk/testing/playwright"; -import { test as setup } from "@playwright/test"; -import { E2EAuthJsonFileMainUser, E2EAuthJsonFileSubUser } from "@/e2e/constants"; - -// Setup must be run serially, this is necessary if Playwright is configured to run fully parallel: https://playwright.dev/docs/test-parallel -setup.describe.configure({ mode: "serial" }); - -setup("グローバルセットアップ", async () => { - await clerkSetup(); - if (!process.env.E2E_CLERK_USER || !process.env.E2E_CLERK_PASSWORD) { - throw new Error("Please provide E2E_CLERK_USER and E2E_CLERK_PASSWORD environment variables."); - } - - if (!process.env.E2E_CLERK_USER_B || !process.env.E2E_CLERK_PASSWORD_B) { - throw new Error("Please provide E2E_CLERK_USER_B and E2E_CLERK_PASSWORD_B environment variables."); - } -}); - -setup("ログイン(メインユーザー)", async ({ page }) => { - await page.goto("/"); - - // メインユーザー(管理者)でログイン - await clerk.signIn({ - page, - signInParams: { - strategy: "password", - identifier: process.env.E2E_CLERK_USER ?? "", - password: process.env.E2E_CLERK_PASSWORD ?? "", - }, - }); - - // 認証状態を保存 - await page.context().storageState({ path: E2EAuthJsonFileMainUser }); -}); - -setup("ログイン(サブユーザー)", async ({ page }) => { - await page.goto("/"); - - // サブユーザー(新規店長)でログイン - // このユーザーはConvexのusersテーブルに存在しない状態でテストする - await clerk.signIn({ - page, - signInParams: { - strategy: "password", - identifier: process.env.E2E_CLERK_USER_B ?? "", - password: process.env.E2E_CLERK_PASSWORD_B ?? "", - }, - }); - - // 認証状態を保存 - await page.context().storageState({ path: E2EAuthJsonFileSubUser }); - await clerk.signOut({ page }); -}); diff --git a/e2e/helpers/navigation.ts b/e2e/helpers/navigation.ts deleted file mode 100644 index 0e1cbf97..00000000 --- a/e2e/helpers/navigation.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { expect, type Page } from "@playwright/test"; - -/** - * 店舗一覧から最初の店舗詳細ページへ移動(直接URLで遷移) - * 注意: 店舗カードをクリックすると /staffs に遷移するため、詳細ページにはURL直接遷移が必要 - */ -export const goToFirstShopDetail = async (page: Page) => { - await page.goto("/shops"); - const shopLink = page - .getByRole("link") - .filter({ has: page.getByRole("group") }) - .first(); - const href = await shopLink.getAttribute("href"); - // href は /shops/{shopId}/staffs の形式 - const shopId = href?.match(/\/shops\/([^/]+)/)?.[1]; - await page.goto(`/shops/${shopId}`); - await expect(page).toHaveURL(/\/shops\/[^/]+$/); -}; - -/** - * スタッフ一覧が表示されるまで待機 - */ -export const waitForStaffList = async (page: Page) => { - await expect(page.getByText(/\d+名のスタッフ/)).toBeVisible(); -}; - -/** - * 店舗一覧 → スタッフ一覧表示まで一括で実行 - * 店舗カードをクリックすると直接 /shops/{shopId}/staffs に遷移する - */ -export const goToStaffList = async (page: Page) => { - await page.goto("/shops"); - await page - .getByRole("link") - .filter({ has: page.getByRole("group") }) - .first() - .click(); - // 店舗カードは /shops/{shopId}/staffs に遷移する - await expect(page).toHaveURL(/\/shops\/[^/]+\/staffs$/); - await waitForStaffList(page); -}; - -/** - * メンバー追加モーダルを開く - */ -export const openMemberAddModal = async (page: Page) => { - await page.getByRole("button", { name: "メンバーを追加" }).click(); - await expect(page.getByRole("dialog")).toBeVisible(); -}; - -/** - * スタッフ追加モーダルを開く(スタッフを選択した状態) - */ -export const openStaffAddModal = async (page: Page) => { - await openMemberAddModal(page); - // デフォルトでスタッフが選択されているので、そのまま -}; - -/** - * スタッフ詳細ページへ移動(スタッフ一覧から) - */ -export const goToStaffDetail = async (page: Page) => { - // スタッフカードのリンクをクリック(URL構造で判定) - await page.locator('a[href*="/staffs/"]').first().click(); - // URL: /shops/{shopId}/staffs/{staffId} - await expect(page).toHaveURL(/\/shops\/[^/]+\/staffs\/[^/]+$/); -}; - -/** - * スタッフ編集ページへ移動(スタッフ詳細から) - */ -export const goToStaffEditFromDetail = async (page: Page) => { - await page.getByRole("button", { name: "編集" }).click(); - // 編集ページのロード完了を待つ - await expect(page.getByLabel("表示名")).toBeVisible(); -}; - -/** - * マネージャー招待モーダルを開く(マネージャーを選択した状態) - */ -export const openManagerInviteModal = async (page: Page) => { - await openMemberAddModal(page); - // マネージャーのラジオカードを選択 - await page - .locator("div") - .filter({ hasText: /^マネージャーログインして店舗運営に参加シフト作成・スタッフ管理ができます$/ }) - .first() - .click(); -}; diff --git a/e2e/scenarios/auth/auth.test.ts b/e2e/scenarios/auth/auth.test.ts deleted file mode 100644 index 783a3858..00000000 --- a/e2e/scenarios/auth/auth.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("認証後のページ遷移チェック", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/"); - }); - - test("認証後でもTOPページにアクセスできること", async ({ page }) => { - await expect(page).toHaveURL("/"); - }); -}); diff --git a/e2e/scenarios/invite/create.test.ts b/e2e/scenarios/invite/create.test.ts deleted file mode 100644 index 438a4e11..00000000 --- a/e2e/scenarios/invite/create.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { goToStaffList, openManagerInviteModal } from "@/e2e/helpers/navigation"; - -test.describe("マネージャー招待作成", () => { - test.beforeEach(async ({ page }) => { - // 店舗カードクリックで直接スタッフ一覧に遷移 - await goToStaffList(page); - }); - - test("モーダルが開閉できること", async ({ page }) => { - await openManagerInviteModal(page); - - // キャンセルでモーダル閉じる - await page.getByRole("button", { name: "キャンセル" }).click(); - await expect(page.getByRole("dialog")).not.toBeVisible(); - }); - - test("招待リンクを作成できること", async ({ page }) => { - await openManagerInviteModal(page); - - // 名前とメールアドレスを入力 - const managerName = `招待テスト_${Date.now()}`; - await page.getByLabel("名前").fill(managerName); - await page.getByLabel("メールアドレス").fill(`create-test-${Date.now()}@example.com`); - - // 招待する - await page.getByRole("button", { name: "招待する" }).click(); - - // 成功トースト確認 - await expect(page.getByText("招待リンクを作成しました")).toBeVisible(); - - // モーダルが閉じる - await expect(page.getByRole("dialog")).not.toBeVisible(); - }); - - test("必須項目が未入力だとエラーになること", async ({ page }) => { - await openManagerInviteModal(page); - - // 何も入力せずに招待ボタンクリック - await page.getByRole("button", { name: "招待する" }).click(); - - // エラーメッセージ確認 - await expect(page.getByText("必須項目です")).toHaveCount(2); - }); - - test("重複メールで招待するとエラーになること", async ({ page }) => { - const duplicateEmail = `duplicate-${Date.now()}@example.com`; - - // 1回目の招待 - await openManagerInviteModal(page); - await page.getByLabel("名前").fill("重複テスト1"); - await page.getByLabel("メールアドレス").fill(duplicateEmail); - await page.getByRole("button", { name: "招待する" }).click(); - await expect(page.getByText("招待リンクを作成しました")).toBeVisible(); - - // 2回目の招待(同じメール) - await openManagerInviteModal(page); - await page.getByLabel("名前").fill("重複テスト2"); - await page.getByLabel("メールアドレス").fill(duplicateEmail); - await page.getByRole("button", { name: "招待する" }).click(); - - // エラーメッセージ確認 - await expect(page.getByText("このメールアドレスは既に招待中です")).toBeVisible(); - - // モーダルを閉じる - await page.getByRole("button", { name: "キャンセル" }).click(); - }); -}); diff --git a/e2e/scenarios/settings/user-profile.test.ts b/e2e/scenarios/settings/user-profile.test.ts deleted file mode 100644 index 47f14aa0..00000000 --- a/e2e/scenarios/settings/user-profile.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("ユーザー設定", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/settings"); - // 設定ページのロード完了を待つ - await expect(page.getByText("個人設定")).toBeVisible(); - }); - - test("ユーザー名を変更できること", async ({ page }) => { - // 名前フィールドを取得 - const nameInput = page.getByLabel("名前"); - await expect(nameInput).toBeVisible(); - - // 現在の名前を取得 - const originalName = await nameInput.inputValue(); - - // 名前を変更 - const newName = `テスト${Date.now()}`; - await nameInput.clear(); - await nameInput.fill(newName); - - // 保存ボタンをクリック - await page.getByRole("button", { name: "変更を保存" }).click(); - - // 成功トースト確認 - await expect(page.getByText("ユーザー名を更新しました")).toBeVisible(); - - // 元の名前に戻す - await nameInput.clear(); - await nameInput.fill(originalName); - await page.getByRole("button", { name: "変更を保存" }).click(); - await expect(page.getByText("ユーザー名を更新しました")).toBeVisible(); - }); - - test("メールアドレスは変更できないこと", async ({ page }) => { - // メールアドレスフィールドが無効化されていることを確認 - const emailInput = page.getByLabel("メールアドレス"); - await expect(emailInput).toBeDisabled(); - }); -}); diff --git a/e2e/scenarios/shop/detail.test.ts b/e2e/scenarios/shop/detail.test.ts deleted file mode 100644 index e0b27f18..00000000 --- a/e2e/scenarios/shop/detail.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { goToFirstShopDetail } from "@/e2e/helpers/navigation"; - -test.describe("店舗詳細", () => { - test.beforeEach(async ({ page }) => { - await goToFirstShopDetail(page); - }); - - test("店舗詳細ページが表示されること", async ({ page }) => { - // 店舗詳細の内容が表示される - await expect(page.getByText("営業時間")).toBeVisible(); - await expect(page.getByText("シフト提出期限")).toBeVisible(); - await expect(page.getByText("シフト入力の時間単位")).toBeVisible(); - }); - - test("編集ボタンをクリックすると編集ページに遷移すること", async ({ page }) => { - // 編集ボタンをクリック - await page.getByRole("button", { name: "編集" }).click(); - - // 編集ページに遷移 - await expect(page).toHaveURL(/\/shops\/[^/]+\/edit$/); - - // 店舗編集ページのタイトルが表示される - await expect(page.getByRole("heading", { name: "店舗編集" })).toBeVisible(); - }); - - test("店舗一覧に戻るボタンが機能すること", async ({ page }) => { - // 「店舗一覧に戻る」ボタンをクリック - await page.getByRole("button", { name: "店舗一覧に戻る" }).click(); - - // 店舗一覧ページに遷移 - await expect(page).toHaveURL("/shops"); - }); -}); diff --git a/e2e/scenarios/shop/edit.test.ts b/e2e/scenarios/shop/edit.test.ts deleted file mode 100644 index b0878c17..00000000 --- a/e2e/scenarios/shop/edit.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { goToFirstShopDetail } from "@/e2e/helpers/navigation"; - -test.describe("店舗編集", () => { - test.beforeEach(async ({ page }) => { - await goToFirstShopDetail(page); - // 編集ページへ遷移 - await page.getByRole("button", { name: "編集" }).click(); - await expect(page).toHaveURL(/\/shops\/[^/]+\/edit$/); - }); - - test("店舗名を変更して保存できること", async ({ page }) => { - // 現在の店舗名を取得 - const shopNameInput = page.getByLabel("店舗名"); - const originalName = await shopNameInput.inputValue(); - - // 店舗名を変更 - const newName = `${originalName}_編集済み`; - await shopNameInput.fill(newName); - - // 更新ボタンをクリック - await page.getByRole("button", { name: "更新" }).click(); - - // トースト確認(複数表示される可能性があるので first()) - await expect(page.getByText("店舗情報を更新しました").first()).toBeVisible(); - - // 店舗詳細ページに遷移 - await expect(page).toHaveURL(/\/shops\/[^/]+$/); - - // 変更後の店舗名が表示される - await expect(page.getByRole("heading", { name: newName })).toBeVisible(); - - // 元の名前に戻す(クリーンアップ) - await page.getByRole("button", { name: "編集" }).click(); - await page.getByLabel("店舗名").fill(originalName); - await page.getByRole("button", { name: "更新" }).click(); - await expect(page.getByText("店舗情報を更新しました").first()).toBeVisible(); - }); - - test("店舗詳細に戻るボタンが機能すること", async ({ page }) => { - // 「店舗詳細に戻る」ボタンをクリック - await page.getByRole("button", { name: "店舗詳細に戻る" }).click(); - - // 店舗詳細ページに遷移 - await expect(page).toHaveURL(/\/shops\/[^/]+$/); - }); -}); diff --git a/e2e/scenarios/shop/list.test.ts b/e2e/scenarios/shop/list.test.ts deleted file mode 100644 index 5bd08b1b..00000000 --- a/e2e/scenarios/shop/list.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("店舗一覧", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/shops"); - }); - - test("店舗一覧が表示されること", async ({ page }) => { - // ページタイトルが表示される(headingなのでgetByRole) - await expect(page.getByRole("heading", { name: "店舗一覧" })).toBeVisible(); - - // 店舗カードが表示される(groupロールを含むリンク) - const shopCards = page.getByRole("link").filter({ has: page.getByRole("group") }); - await expect(shopCards.first()).toBeVisible(); - }); - - test("店舗カードに営業時間とスタッフ数が表示されること", async ({ page }) => { - // 店舗カードに営業時間が表示される(HH:MM - HH:MM形式、複数あるので first()) - await expect(page.getByText(/\d{2}:\d{2} - \d{2}:\d{2}/).first()).toBeVisible(); - - // 店舗カードにスタッフ数が表示される(X名形式、複数あるので first()) - await expect(page.getByText(/\d+名/).first()).toBeVisible(); - }); - - test("店舗カードをクリックするとスタッフ一覧ページに遷移すること", async ({ page }) => { - // 最初の店舗カードをクリック(groupロールを含むリンク) - await page - .getByRole("link") - .filter({ has: page.getByRole("group") }) - .first() - .click(); - - // スタッフ一覧ページに遷移(店舗カードは /shops/{shopId}/staffs に遷移する) - await expect(page).toHaveURL(/\/shops\/[^/]+\/staffs$/); - - // スタッフ一覧が表示される - await expect(page.getByText(/\d+名のスタッフ/)).toBeVisible(); - }); - - test("新規店舗ボタンをクリックすると登録ページに遷移すること", async ({ page }) => { - // 新規店舗ボタンをクリック - await page.getByRole("link", { name: "新規店舗" }).click(); - - // 店舗登録ページに遷移 - await expect(page).toHaveURL("/shops/new"); - }); -}); diff --git a/e2e/scenarios/shop/register.test.ts b/e2e/scenarios/shop/register.test.ts deleted file mode 100644 index bdd8ab97..00000000 --- a/e2e/scenarios/shop/register.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("店舗登録", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/shops/new"); - }); - - test("店舗を正常に登録できること", async ({ page }) => { - const shopName = `E2Eテスト店舗_${new Date().toISOString()}`; - - // 店舗名入力 - await page.getByLabel("店舗名").fill(shopName); - - // 店舗メモ入力(任意) - await page.getByLabel("店舗メモ(マネージャー向け)").fill("E2Eテスト用の店舗です"); - - // 登録ボタンをクリック - await page.getByRole("button", { name: "登録" }).click(); - - // トースト確認 - await expect(page.getByText("店舗登録が完了しました")).toBeVisible(); - - // /shops に遷移したことを確認 - await expect(page).toHaveURL("/shops"); - - // 登録した店舗が一覧に表示されることを確認(店舗カード内のh3要素で特定) - await expect(page.getByRole("heading", { name: shopName, level: 3 })).toBeVisible(); - }); -}); diff --git a/e2e/scenarios/staff/add.test.ts b/e2e/scenarios/staff/add.test.ts deleted file mode 100644 index 9b4dba90..00000000 --- a/e2e/scenarios/staff/add.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { goToStaffList, openStaffAddModal } from "@/e2e/helpers/navigation"; - -test.describe("スタッフ追加", () => { - test.beforeEach(async ({ page }) => { - await goToStaffList(page); - }); - - test("モーダルが開閉できること", async ({ page }) => { - // 追加ボタンクリック → モーダル表示 - await openStaffAddModal(page); - - // キャンセルでモーダル閉じる - await page.getByRole("button", { name: "キャンセル" }).click(); - await expect(page.getByRole("dialog")).not.toBeVisible(); - }); - - test("スタッフを追加できること", async ({ page }) => { - await openStaffAddModal(page); - - // フォーム入力(ユニークなメールアドレス) - const uniqueEmail = `test-${Date.now()}@example.com`; - await page.getByLabel("名前").fill("テスト太郎"); - await page.getByLabel("メールアドレス").fill(uniqueEmail); - - // 追加実行 - await page.getByRole("button", { name: "追加" }).click(); - - // 成功トースト確認 - await expect(page.getByText("テスト太郎 を追加しました")).toBeVisible(); - - // モーダルが閉じる - await expect(page.getByRole("dialog")).not.toBeVisible(); - }); - - test("必須項目が未入力だとエラーになること", async ({ page }) => { - await openStaffAddModal(page); - - // 何も入力せずに追加ボタンクリック - await page.getByRole("button", { name: "追加" }).click(); - - // エラーメッセージ確認(複数表示される可能性があるので first()) - await expect(page.getByText("必須項目です").first()).toBeVisible(); - }); -}); diff --git a/e2e/scenarios/staff/edit.test.ts b/e2e/scenarios/staff/edit.test.ts deleted file mode 100644 index 257e8cec..00000000 --- a/e2e/scenarios/staff/edit.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { goToStaffDetail, goToStaffEditFromDetail, goToStaffList } from "@/e2e/helpers/navigation"; - -test.describe("スタッフ編集", () => { - test.beforeEach(async ({ page }) => { - await goToStaffList(page); - await goToStaffDetail(page); - await goToStaffEditFromDetail(page); - }); - - test("基本情報を編集できること", async ({ page }) => { - // 表示名フィールドを取得して変更 - const displayNameInput = page.getByLabel("表示名"); - await displayNameInput.clear(); - await displayNameInput.fill("編集テスト名"); - - // 保存 - await page.getByRole("button", { name: "保存" }).click(); - - // 成功トースト確認 - await expect(page.getByText("スタッフ情報を更新しました")).toBeVisible(); - }); - - test("スキルを追加できること", async ({ page }) => { - // スキル追加ボタンクリック - await page.getByRole("button", { name: "スキルを追加" }).click(); - - // スキル入力フォームが表示される(ポジション選択) - await expect(page.getByText("ポジション")).toBeVisible(); - }); - - test("キャンセルで編集画面を閉じられること", async ({ page }) => { - await page.getByRole("link", { name: "キャンセル" }).click(); - - // 詳細ページに戻る(URL: /shops/{shopId}/staffs/{staffId}) - await expect(page).toHaveURL(/\/shops\/[^/]+\/staffs\/[^/]+$/); - }); -}); diff --git a/e2e/scenarios/staff/list.test.ts b/e2e/scenarios/staff/list.test.ts deleted file mode 100644 index f5b3d3ba..00000000 --- a/e2e/scenarios/staff/list.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { goToStaffList } from "@/e2e/helpers/navigation"; - -test.describe("スタッフ一覧(StaffTab)", () => { - test.beforeEach(async ({ page }) => { - await goToStaffList(page); - }); - - test("スタッフ一覧が表示されること", async ({ page }) => { - // スタッフ数が表示される - await expect(page.getByText(/\d+名のスタッフ/)).toBeVisible(); - - // スタッフカードが表示される(スタッフ詳細へのリンク) - const staffLinks = page.locator('a[href*="/staffs/"]'); - await expect(staffLinks.first()).toBeVisible(); - }); - - test("名前で検索できること", async ({ page }) => { - // 検索前のスタッフ数を取得 - const countText = await page.getByText(/\d+名のスタッフ/).textContent(); - const initialCount = Number.parseInt(countText?.match(/\d+/)?.[0] ?? "0", 10); - - // 存在するスタッフ名で検索(seedデータの「桐山」) - await page.getByPlaceholder("名前・メールで検索...").fill("桐山"); - - // 検索結果が表示される(1名以上) - await expect(page.getByText(/\d+名のスタッフ/)).toBeVisible(); - - // 検索後のスタッフ数が初期より少ないか同じ - const filteredCountText = await page.getByText(/\d+名のスタッフ/).textContent(); - const filteredCount = Number.parseInt(filteredCountText?.match(/\d+/)?.[0] ?? "0", 10); - expect(filteredCount).toBeLessThanOrEqual(initialCount); - }); - - test("存在しない名前で検索すると空状態が表示されること", async ({ page }) => { - // 存在しない名前で検索 - await page.getByPlaceholder("名前・メールで検索...").fill("存在しないスタッフ名XXXYYY"); - - // 空状態メッセージが表示される - await expect(page.getByText("該当するスタッフが見つかりませんでした")).toBeVisible(); - }); - - test("ステータスフィルターで退職済みを選択できること", async ({ page }) => { - // 「退職済み」を選択(Selectコンポーネントをクリック) - await page.locator('button[role="combobox"]').click(); - await page.getByRole("option", { name: "退職済み" }).click(); - - // 退職済みスタッフが表示されるか、空状態が表示される - const hasResignedStaff = await page.getByText(/\d+名のスタッフ/).isVisible(); - const hasEmptyState = await page.getByText("該当するスタッフが見つかりませんでした").isVisible(); - - expect(hasResignedStaff || hasEmptyState).toBeTruthy(); - }); - - test("ステータスフィルターで全員を選択できること", async ({ page }) => { - // 「全員」を選択 - await page.locator('button[role="combobox"]').click(); - await page.getByRole("option", { name: "全員" }).click(); - - // スタッフ一覧が表示される - await expect(page.getByText(/\d+名のスタッフ/)).toBeVisible(); - }); -}); diff --git a/e2e/scenarios/staff/resign.test.ts b/e2e/scenarios/staff/resign.test.ts deleted file mode 100644 index 2829936c..00000000 --- a/e2e/scenarios/staff/resign.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { goToStaffDetail, goToStaffEditFromDetail, goToStaffList } from "@/e2e/helpers/navigation"; - -test.describe("スタッフ退職処理", () => { - test.beforeEach(async ({ page }) => { - await goToStaffList(page); - await goToStaffDetail(page); - await goToStaffEditFromDetail(page); - }); - - test("退職処理ダイアログが表示されること", async ({ page }) => { - // 退職処理ボタンがあるか確認(自分自身の場合は無効化されている可能性) - const resignButton = page.getByRole("button", { name: "退職処理を実行" }); - - // ボタンが無効化されていない場合のみダイアログテスト - if (await resignButton.isEnabled()) { - await resignButton.click(); - - // 確認ダイアログが表示される - await expect(page.getByRole("alertdialog")).toBeVisible(); - await expect(page.getByText("本当に")).toBeVisible(); - } - }); - - test("キャンセルで退職処理を中止できること", async ({ page }) => { - const resignButton = page.getByRole("button", { name: "退職処理を実行" }); - - // ボタンが無効化されていない場合のみテスト - if (await resignButton.isEnabled()) { - await resignButton.click(); - await page.getByRole("button", { name: "キャンセル" }).click(); - - // ダイアログが閉じる - await expect(page.getByRole("alertdialog")).not.toBeVisible(); - } - }); -}); diff --git a/old_dbSchema.prisma b/old_dbSchema.prisma deleted file mode 100644 index aba52324..00000000 --- a/old_dbSchema.prisma +++ /dev/null @@ -1,213 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - -generator client { - provider = "prisma-client-js" - output = "./generated" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - directUrl = env("DIRECT_URL") -} - -model ShopUserBelonging { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - shopId String @db.Uuid - userId String @unique - shop Shop @relation(fields: [shopId], references: [id]) - user User @relation(fields: [userId], references: [userId]) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model OrganizationShopBelonging { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - organizationId String @db.Uuid - shopId String @db.Uuid - shop Shop @relation(fields: [shopId], references: [id]) - organization Organization @relation(fields: [organizationId], references: [id]) - isDeleted Boolean - createdAt DateTime @default(now()) -} - -model Organization { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - organizationName String - shop OrganizationShopBelonging[] - temporaryClosed TemporaryClosed[] - announce Announce[] - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model Shop { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - shopName String - openTime String - closeTime String - timeUnit Int - submitFrequency String - avatar String - useTimeCard Boolean - description String? // マネージャー向け店舗メモ - organization OrganizationShopBelonging[] - user ShopUserBelonging[] - operation Operation[] - stableShift StableShift[] - unstableShift UnstableShift[] - request Request[] - timeCard TimeCard[] - temporaryClosed TemporaryClosed[] - announce Announce[] - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) - Invitation Invitation[] - shopInvitations ShopInvitation[] -} - -model User { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - userId String @unique @default(dbgenerated("uuid_generate_v4()")) - userName String - avatar String @default("") - shop ShopUserBelonging[] - stableShift StableShift[] - unstableShift UnstableShift[] - request Request[] - timeCard TimeCard[] - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) - Invitation Invitation[] - invitationUses InvitationUse[] -} - -model Invitation { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - invitedBy Shop @relation(fields: [invitedByShopId], references: [id]) - invitedByShopId String @db.Uuid - targetUser User @relation(fields: [targetUserId], references: [id]) - targetUserId String @db.Uuid - accepted Boolean @default(false) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model Operation { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - shopId String @db.Uuid - operationName String - icon String - color String - shop Shop @relation(fields: [shopId], references: [id]) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model StableShift { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - shopId String @db.Uuid - userId String @unique - workFrom DateTime - workTo DateTime - breakFrom DateTime - breakTo DateTime - shop Shop @relation(fields: [shopId], references: [id]) - user User @relation(fields: [userId], references: [userId]) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model UnstableShift { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - shopId String @db.Uuid - userId String @unique - workFrom DateTime - workTo DateTime - breakFrom DateTime - breakTo DateTime - shop Shop @relation(fields: [shopId], references: [id]) - user User @relation(fields: [userId], references: [userId]) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model Request { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - shopId String @db.Uuid - userId String @unique - dateFrom DateTime - dateTo DateTime - done Boolean @default(false) - shop Shop @relation(fields: [shopId], references: [id]) - user User @relation(fields: [userId], references: [userId]) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model TimeCard { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - shopId String @db.Uuid - userId String @unique - workFrom DateTime - workTo DateTime - breakFrom DateTime - breakTo DateTime - shop Shop @relation(fields: [shopId], references: [id]) - user User @relation(fields: [userId], references: [userId]) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model TemporaryClosed { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - organizationId String @db.Uuid - shopId String @db.Uuid - date DateTime - organization Organization @relation(fields: [organizationId], references: [id]) - shop Shop @relation(fields: [shopId], references: [id]) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model Announce { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - organizationId String @db.Uuid - shopId String @db.Uuid - message String - organization Organization @relation(fields: [organizationId], references: [id]) - shop Shop @relation(fields: [shopId], references: [id]) - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) -} - -model ShopInvitation { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - token String @unique @default(dbgenerated("uuid_generate_v4()")) - shopId String @db.Uuid - shop Shop @relation(fields: [shopId], references: [id]) - expiresAt DateTime - createdBy String // 管理者のuserId - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - usedBy InvitationUse[] - - @@index([token]) - @@index([shopId]) -} - -model InvitationUse { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - invitationId String @db.Uuid - invitation ShopInvitation @relation(fields: [invitationId], references: [id]) - userId String @db.Uuid - user User @relation(fields: [userId], references: [id]) - usedAt DateTime @default(now()) - - @@unique([invitationId, userId]) -} \ No newline at end of file diff --git a/src/components/features/MyPage/index.stories.tsx b/src/components/features/MyPage/index.stories.tsx deleted file mode 100644 index b9480c9e..00000000 --- a/src/components/features/MyPage/index.stories.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { MyPage } from "@/src/components/features/MyPage"; - -const meta = { - title: "features/MyPage", - component: MyPage, -} satisfies Meta; -export default meta; - -export const Basic: StoryObj = {}; diff --git a/src/components/features/MyPage/index.tsx b/src/components/features/MyPage/index.tsx deleted file mode 100644 index f7e23426..00000000 --- a/src/components/features/MyPage/index.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { Badge, Box, Button, Flex, Grid, HStack, Icon, Text, VStack } from "@chakra-ui/react"; -import { - HiCalendar, - HiChevronRight, - HiClock, - HiExclamation, - HiLogin, - HiLogout, - HiPaperAirplane, - HiUser, -} from "react-icons/hi"; - -const user = { - name: "テストユーザー", - role: "マネージャー", -}; - -// サマリーデータ -const summary = [ - { - title: "今週の勤務時間", - value: "24.5時間", - subtext: "残り 15.5h", - icon: HiClock, - colorPalette: "teal", - }, - { - title: "今月の出勤日数", - value: "18日", - subtext: "目標 20日", - icon: HiCalendar, - colorPalette: "blue", - }, - { - title: "承認待ちシフト", - value: "3件", - subtext: "要確認", - icon: HiExclamation, - colorPalette: "orange", - }, -]; - -// クイックアクション -const quickActions = [ - { - label: "出勤", - icon: HiLogin, - colorPalette: "teal", - variant: "solid" as const, - }, - { - label: "退勤", - icon: HiLogout, - colorPalette: "gray", - variant: "solid" as const, - }, - { - label: "シフト申請", - icon: HiPaperAirplane, - colorPalette: "gray", - variant: "outline" as const, - }, - { - label: "勤怠確認", - icon: HiUser, - colorPalette: "gray", - variant: "outline" as const, - }, -]; - -// 今週のシフト(固定データ) -const weeklyShifts = [ - { date: "11/8", day: "金", shift: "14:00-22:00", status: "勤務中", isToday: true }, - { date: "11/9", day: "土", shift: "10:00-18:00", status: "確定", isToday: false }, - { date: "11/10", day: "日", shift: "休み", status: "休日", isToday: false }, - { date: "11/11", day: "月", shift: "14:00-22:00", status: "確定", isToday: false }, - { date: "11/12", day: "火", shift: "10:00-16:00", status: "確定", isToday: false }, - { date: "11/13", day: "水", shift: "休み", status: "休日", isToday: false }, - { date: "11/14", day: "木", shift: "14:00-22:00", status: "未定", isToday: false }, -]; - -// お知らせ -const announcements = [ - { - id: 1, - title: "年末年始の営業について", - date: "2日前", - isImportant: true, - }, - { - id: 2, - title: "シフト提出のお願い", - date: "4日前", - isImportant: false, - }, -]; - -export const MyPage = () => { - const today = new Date(); - const todayString = `${today.getMonth() + 1}/${today.getDate()}(${["日", "月", "火", "水", "木", "金", "土"][today.getDay()]})`; - - return ( - - - {/* ヘッダー */} - - - {user.name}さん! - - - {todayString} 今日も一日頑張りましょう - - - - {/* サマリーカード */} - - {summary.map((item, index) => ( - - - - - - - - - {item.title} - - - {item.value} - - - {item.subtext} - - - - ))} - - - {/* お知らせ */} - - - お知らせ - - - - {announcements.map((announcement) => ( - - ))} - - - - {/* クイックアクション */} - - - クイックアクション - - - {quickActions.map((action, index) => ( - - ))} - - - - {/* 今週のシフト */} - - - 今週のシフト - - - - - {weeklyShifts.map((shift, index) => ( - - - - - {shift.date} - - - {shift.day} - - - - {shift.shift} - - - - {shift.status === "勤務中" && ( - - 勤務中 - - )} - {shift.status === "確定" && ( - - 確定 - - )} - {shift.status === "未定" && ( - - 未定 - - )} - {shift.status === "休日" && ( - - 休日 - - )} - - - ))} - - - - - - ); -}; diff --git a/src/components/features/Setting/UserSetting/ShopSetting/index.stories.tsx b/src/components/features/Setting/UserSetting/ShopSetting/index.stories.tsx deleted file mode 100644 index 0f9b977d..00000000 --- a/src/components/features/Setting/UserSetting/ShopSetting/index.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ShopSetting } from "./index"; - -const meta = { - title: "features/Setting/UserSetting/ShopSetting", - component: ShopSetting, - args: { - storeName: "本店", - templateCount: 3, - }, -} satisfies Meta; -export default meta; - -export const Basic: StoryObj = {}; diff --git a/src/components/features/Setting/UserSetting/ShopSetting/index.tsx b/src/components/features/Setting/UserSetting/ShopSetting/index.tsx deleted file mode 100644 index ad6c9ef2..00000000 --- a/src/components/features/Setting/UserSetting/ShopSetting/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Badge, Box, Flex, Icon, Text, VStack } from "@chakra-ui/react"; -import { Link } from "@tanstack/react-router"; -import { IoChevronForwardSharp, IoTimeSharp } from "react-icons/io5"; -import { LuStore } from "react-icons/lu"; -import { FormCard } from "@/src/components/ui/FormCard"; - -type ShopSettingProps = { - storeName: string; - templateCount: number; -}; - -export const ShopSetting = ({ storeName, templateCount }: ShopSettingProps) => { - return ( - - - - - - - - - - - - - よく使うシフト - - - {templateCount}件 - - - - {storeName}でよく使うシフトを管理 - - - - - - - - - - - - ); -}; diff --git a/src/components/features/Setting/UserSetting/UserProfile/index.stories.tsx b/src/components/features/Setting/UserSetting/UserProfile/index.stories.tsx deleted file mode 100644 index ff77fc44..00000000 --- a/src/components/features/Setting/UserSetting/UserProfile/index.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { UserProfile } from "./index"; - -const meta = { - title: "features/Setting/UserSetting/UserProfile", - component: UserProfile, - args: { - userName: "田中太郎", - email: "tanaka@example.com", - onChangeUserName: () => {}, - onSave: () => {}, - }, -} satisfies Meta; -export default meta; - -export const Basic: StoryObj = {}; diff --git a/src/components/features/Setting/UserSetting/UserProfile/index.tsx b/src/components/features/Setting/UserSetting/UserProfile/index.tsx deleted file mode 100644 index 4b58ca27..00000000 --- a/src/components/features/Setting/UserSetting/UserProfile/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button, Field, Icon, Input, VStack } from "@chakra-ui/react"; -import { IoSaveSharp } from "react-icons/io5"; -import { LuUser } from "react-icons/lu"; -import { FormCard } from "@/src/components/ui/FormCard"; - -type UserProfileProps = { - userName: string; - email: string; - onChangeUserName: (name: string) => void; - onSave: () => void; -}; - -export const UserProfile = ({ userName, email, onChangeUserName, onSave }: UserProfileProps) => { - return ( - - - - 名前 - onChangeUserName(e.target.value)} placeholder="名前を入力" /> - 店舗名称は各店舗の設定から修正してください - - - メールアドレス - - メールアドレスは変更できません - - - - - ); -}; diff --git a/src/components/features/Setting/UserSetting/index.stories.tsx b/src/components/features/Setting/UserSetting/index.stories.tsx deleted file mode 100644 index ea097b4c..00000000 --- a/src/components/features/Setting/UserSetting/index.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { Doc, Id } from "@/convex/_generated/dataModel"; -import { UserSetting } from "./index"; - -const meta = { - title: "features/Setting/UserSetting", - component: UserSetting, - args: { - user: { - _id: "user1" as Id<"users">, - _creationTime: Date.now(), - name: "田中太郎", - email: "tanaka@example.com", - authId: "auth_123", - status: "active", - createdAt: Date.now(), - } as Doc<"users">, - }, -} satisfies Meta; -export default meta; - -export const Basic: StoryObj = {}; diff --git a/src/components/features/Setting/UserSetting/index.tsx b/src/components/features/Setting/UserSetting/index.tsx deleted file mode 100644 index a1ef0275..00000000 --- a/src/components/features/Setting/UserSetting/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Container, VStack } from "@chakra-ui/react"; -import { useMutation } from "convex/react"; -import { useSetAtom } from "jotai"; -import { useState } from "react"; -import { api } from "@/convex/_generated/api"; -import type { Doc } from "@/convex/_generated/dataModel"; -import { Title } from "@/src/components/ui/Title"; -import { toaster } from "@/src/components/ui/toaster"; -import { userAtom } from "@/src/stores/user"; -import { ShopSetting } from "./ShopSetting"; -import { UserProfile } from "./UserProfile"; - -// モックデータ(店舗関連は後で置き換え) -const mockStores = [ - { id: "1", name: "本店" }, - { id: "2", name: "駅前店" }, - { id: "3", name: "ショッピングモール店" }, -]; - -const mockShiftTemplateCounts: Record = { - "1": 3, - "2": 2, - "3": 0, -}; - -type UserSettingProps = { - user: Doc<"users">; -}; - -export const UserSetting = ({ user }: UserSettingProps) => { - const [userName, setUserName] = useState(user.name); - const setUserAtom = useSetAtom(userAtom); - const updateUser = useMutation(api.user.mutations.update); - - const selectedStoreId = "1"; - const selectedStore = mockStores.find((s) => s.id === selectedStoreId); - const templateCount = mockShiftTemplateCounts[selectedStoreId] || 0; - - const handleSaveUserName = async () => { - try { - await updateUser({ - id: user._id, - name: userName, - }); - - // userAtomも同期 - setUserAtom((prev) => ({ - ...prev, - name: userName, - })); - - toaster.create({ - description: "ユーザー名を更新しました", - type: "success", - }); - } catch { - toaster.create({ - description: "ユーザー名の更新に失敗しました", - type: "error", - }); - } - }; - - return ( - - 個人設定 - - - - - - - - ); -}; diff --git a/src/components/features/Shift/PeakBandSettings/ModeConfirmDialog.tsx b/src/components/features/Shift/PeakBandSettings/ModeConfirmDialog.tsx deleted file mode 100644 index fc068a30..00000000 --- a/src/components/features/Shift/PeakBandSettings/ModeConfirmDialog.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Flex, Icon, Text } from "@chakra-ui/react"; -import { LuInfo, LuTriangleAlert } from "react-icons/lu"; -import { Dialog } from "@/src/components/ui/Dialog"; - -type ModeConfirmDialogProps = { - isOpen: boolean; - onOpenChange: (details: { open: boolean }) => void; - onConfirm: () => void; - onCancel: () => void; -}; - -export const ModeConfirmDialog = ({ isOpen, onOpenChange, onConfirm, onCancel }: ModeConfirmDialogProps) => { - return ( - - - - - - モード切替の確認 - - - - かんたんモードに切り替えると、曜日ごとの個別設定は「平日」「休日」の設定で上書きされます。 - - - - - この操作は取り消せません - - - - - ); -}; diff --git a/src/components/features/Shift/PeakBandSettings/index.stories.tsx b/src/components/features/Shift/PeakBandSettings/index.stories.tsx deleted file mode 100644 index 9b6bb5cb..00000000 --- a/src/components/features/Shift/PeakBandSettings/index.stories.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { withDummyRouter } from "../../../../../.storybook/withDummyRouter"; -import type { InitialDayData } from "./index"; -import { PeakBandSettings } from "./index"; - -const meta = { - title: "features/Shift/PeakBandSettings", - component: PeakBandSettings, - decorators: [withDummyRouter("/")], - parameters: { - layout: "padded", - }, - args: { - shopId: "shop-1", - shopName: "渋谷センター店", - onSave: async (params) => { - console.log("onSave called:", params); - }, - isSaving: false, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -// 空の状態 -export const Default: Story = {}; - -// かんたんモード(初期データあり) -const simpleInitialData: InitialDayData[] = [ - // 平日(月〜金)は同じ設定 - ...[1, 2, 3, 4, 5].map((day) => ({ - dayOfWeek: day, - peakBands: [ - { startTime: "11:00", endTime: "14:00", requiredCount: 3 }, - { startTime: "17:00", endTime: "21:00", requiredCount: 4 }, - ], - minimumStaff: 2, - })), - // 休日(日,土,祝)は同じ設定 - ...[0, 6, 7].map((day) => ({ - dayOfWeek: day, - peakBands: [ - { startTime: "11:00", endTime: "14:00", requiredCount: 4 }, - { startTime: "17:00", endTime: "21:00", requiredCount: 5 }, - ], - minimumStaff: 3, - })), -]; - -export const SimpleMode: Story = { - args: { - initialData: simpleInitialData, - }, -}; - -// 詳細モード(曜日ごとに異なる設定) -const detailedInitialData: InitialDayData[] = [ - { - dayOfWeek: 1, - peakBands: [{ startTime: "11:00", endTime: "14:00", requiredCount: 3 }], - minimumStaff: 2, - }, - { - dayOfWeek: 5, - peakBands: [ - { startTime: "11:00", endTime: "14:00", requiredCount: 4 }, - { startTime: "17:00", endTime: "22:00", requiredCount: 5 }, - ], - minimumStaff: 2, - }, -]; - -export const DetailedMode: Story = { - args: { - initialData: detailedInitialData, - }, -}; - -// 保存中 -export const Saving: Story = { - args: { - initialData: simpleInitialData, - isSaving: true, - }, -}; diff --git a/src/components/features/Shift/PeakBandSettings/index.tsx b/src/components/features/Shift/PeakBandSettings/index.tsx deleted file mode 100644 index ffbd2b99..00000000 --- a/src/components/features/Shift/PeakBandSettings/index.tsx +++ /dev/null @@ -1,519 +0,0 @@ -import { Box, Button, Container, Flex, Heading, Icon, IconButton, Input, Tabs, Text, VStack } from "@chakra-ui/react"; -import { useCallback, useMemo, useState } from "react"; -import { LuCircle, LuMinus, LuPlus, LuTrash2 } from "react-icons/lu"; -import { useDialog } from "@/src/components/ui/Dialog"; -import { Title } from "@/src/components/ui/Title"; -import type { PeakBand } from "../ShiftForm/types"; -import { HOLIDAY_DAYS, WEEKDAY_DAYS } from "../StaffingRequirement/constants"; -import { DayTabs } from "../StaffingRequirement/DayTabs"; -import { ModeConfirmDialog } from "./ModeConfirmDialog"; - -type DaySettings = { - peakBands: PeakBand[]; - minimumStaff: number; -}; - -export type InitialDayData = { - dayOfWeek: number; - peakBands?: PeakBand[]; - minimumStaff?: number; -}; - -type Mode = "simple" | "detailed"; -type SimpleGroup = "weekday" | "holiday"; - -type PeakBandSettingsProps = { - shopId: string; - shopName: string; - initialData?: InitialDayData[]; - onSave: (params: { dayOfWeeks: readonly number[]; peakBands: PeakBand[]; minimumStaff: number }) => Promise; - isSaving?: boolean; -}; - -// ============================================================ -// 定数 -// ============================================================ - -const DEFAULT_PEAK_BAND: PeakBand = { startTime: "11:00", endTime: "14:00", requiredCount: 3 }; -const DEFAULT_DAY_SETTINGS: DaySettings = { peakBands: [], minimumStaff: 1 }; - -// ============================================================ -// ユーティリティ -// ============================================================ - -/** 初期データからdaySettingsMapを構築 */ -const buildDaySettingsMap = (data: InitialDayData[]): Record => { - const map: Record = {}; - for (const d of data) { - if (d.peakBands && d.peakBands.length > 0) { - map[d.dayOfWeek] = { - peakBands: d.peakBands, - minimumStaff: d.minimumStaff ?? 1, - }; - } - } - return map; -}; - -/** 初期データからモードを推定 */ -const inferMode = (data: InitialDayData[]): Mode => { - if (data.length === 0) return "simple"; - - const hasPeakBands = data.filter((d) => d.peakBands && d.peakBands.length > 0); - if (hasPeakBands.length === 0) return "simple"; - - // 平日が全て同じ設定で、休日も全て同じ設定ならsimple - const weekdays = hasPeakBands.filter((d) => (WEEKDAY_DAYS as readonly number[]).includes(d.dayOfWeek)); - const holidays = hasPeakBands.filter((d) => (HOLIDAY_DAYS as readonly number[]).includes(d.dayOfWeek)); - - const allSame = (items: InitialDayData[]) => { - if (items.length <= 1) return true; - const first = JSON.stringify({ peakBands: items[0].peakBands, minimumStaff: items[0].minimumStaff }); - return items.every( - (item) => JSON.stringify({ peakBands: item.peakBands, minimumStaff: item.minimumStaff }) === first, - ); - }; - - return allSame(weekdays) && allSame(holidays) ? "simple" : "detailed"; -}; - -/** simpleグループの代表dayOfWeekを取得 */ -const getRepresentativeDay = (group: SimpleGroup): number => (group === "weekday" ? 1 : 0); - -/** simpleグループのdayOfWeek配列を取得 */ -const getGroupDays = (group: SimpleGroup): readonly number[] => (group === "weekday" ? WEEKDAY_DAYS : HOLIDAY_DAYS); - -// ============================================================ -// メインコンポーネント -// ============================================================ - -export const PeakBandSettings = ({ - shopId, - shopName, - initialData = [], - onSave, - isSaving = false, -}: PeakBandSettingsProps) => { - const [mode, setMode] = useState(() => inferMode(initialData)); - const [selectedDay, setSelectedDay] = useState(1); - const [selectedGroup, setSelectedGroup] = useState("weekday"); - const [daySettingsMap, setDaySettingsMap] = useState>(() => - buildDaySettingsMap(initialData), - ); - const [hasChanges, setHasChanges] = useState(false); - const confirmDialog = useDialog(); - - // 現在の編集対象dayOfWeek - const activeDayOfWeek = mode === "simple" ? getRepresentativeDay(selectedGroup) : selectedDay; - const currentSettings = daySettingsMap[activeDayOfWeek] ?? DEFAULT_DAY_SETTINGS; - - // 設定済み曜日の一覧 - const configuredDays = useMemo( - () => - Object.keys(daySettingsMap) - .map(Number) - .filter((day) => { - const s = daySettingsMap[day]; - return s && s.peakBands.length > 0; - }), - [daySettingsMap], - ); - - // ============================================================ - // ハンドラ - // ============================================================ - - const updateCurrentDay = useCallback( - (updater: (prev: DaySettings) => DaySettings) => { - setDaySettingsMap((prev) => ({ - ...prev, - [activeDayOfWeek]: updater(prev[activeDayOfWeek] ?? DEFAULT_DAY_SETTINGS), - })); - setHasChanges(true); - }, - [activeDayOfWeek], - ); - - const handleAddBand = useCallback(() => { - updateCurrentDay((prev) => ({ - ...prev, - peakBands: [...prev.peakBands, { ...DEFAULT_PEAK_BAND }], - })); - }, [updateCurrentDay]); - - const handleRemoveBand = useCallback( - (index: number) => { - updateCurrentDay((prev) => ({ - ...prev, - peakBands: prev.peakBands.filter((_, i) => i !== index), - })); - }, - [updateCurrentDay], - ); - - const handleBandChange = useCallback( - (index: number, field: keyof PeakBand, value: string | number) => { - updateCurrentDay((prev) => ({ - ...prev, - peakBands: prev.peakBands.map((band, i) => (i === index ? { ...band, [field]: value } : band)), - })); - }, - [updateCurrentDay], - ); - - const handleMinimumStaffChange = useCallback( - (value: number) => { - updateCurrentDay((prev) => ({ ...prev, minimumStaff: Math.max(0, value) })); - }, - [updateCurrentDay], - ); - - const handleSave = useCallback(async () => { - try { - const dayOfWeeks = mode === "simple" ? getGroupDays(selectedGroup) : [selectedDay]; - await onSave({ - dayOfWeeks, - peakBands: currentSettings.peakBands, - minimumStaff: currentSettings.minimumStaff, - }); - setHasChanges(false); - } catch { - // エラーは親でハンドリング - } - }, [mode, selectedGroup, selectedDay, currentSettings, onSave]); - - // モード切替 - const handleModeChange = useCallback( - (newMode: Mode) => { - if (newMode === mode) return; - if (newMode === "simple") { - // 詳細→かんたん: 確認ダイアログ表示 - confirmDialog.open(); - } else { - // かんたん→詳細: そのまま切替 - setMode("detailed"); - setSelectedDay(1); - } - }, - [mode, confirmDialog], - ); - - const handleConfirmModeSwitch = useCallback(() => { - setMode("simple"); - setSelectedGroup("weekday"); - confirmDialog.close(); - }, [confirmDialog]); - - // ============================================================ - // レンダリング - // ============================================================ - - return ( - - {/* ヘッダー */} - - <Heading as="h2" size={{ base: "lg", md: "xl" }} color="gray.900"> - 必要人員設定 - </Heading> - <Text color="gray.500" fontSize="sm" display={{ base: "none", md: "block" }}> - {shopName} - </Text> - - - {/* モード切替タブ */} - handleModeChange(e.value as Mode)} - variant="enclosed" - size="sm" - mb={2} - > - - - かんたんモード - - - 詳細モード - - - - - {/* モード説明 */} - - {mode === "simple" - ? "平日・休日の2パターンでかんたんに設定できます" - : "曜日ごとに細かくピーク帯と人数を設定できます"} - - - {/* 曜日セレクタ */} - - {mode === "simple" ? ( - - ) : ( - - )} - - - {/* ピーク帯設定 */} - - {/* SP版: セクションヘッダー */} - - ピーク帯設定 - - - {/* ピーク帯リスト */} - - {currentSettings.peakBands.map((band, index) => ( - - ))} - - - {/* ピーク帯追加ボタン */} - - - - - {/* 最低人員 */} - - - - {/* 保存バー */} - - - - - {/* モード切替確認ダイアログ */} - - - ); -}; - -// ============================================================ -// サブコンポーネント: かんたんモードのタブ(平日/休日) -// ============================================================ - -const SimpleDayTabs = ({ - selectedGroup, - onChange, -}: { - selectedGroup: SimpleGroup; - onChange: (group: SimpleGroup) => void; -}) => ( - onChange(e.value as SimpleGroup)} - variant="line" - colorPalette="teal" - size="sm" - > - - - 平日 - - - 休日 - - - -); - -// ============================================================ -// サブコンポーネント: ピーク帯行 -// ============================================================ - -const PeakBandRow = ({ - band, - index, - onBandChange, - onRemove, -}: { - band: PeakBand; - index: number; - onBandChange: (index: number, field: keyof PeakBand, value: string | number) => void; - onRemove: (index: number) => void; -}) => ( - - {/* PC版レイアウト */} - - - 時間帯 - - onBandChange(index, "startTime", e.target.value)} - w="130px" - /> - - 〜 - - onBandChange(index, "endTime", e.target.value)} - w="130px" - /> - - 必要人数 - - onBandChange(index, "requiredCount", Number(e.target.value))} - w="70px" - textAlign="center" - /> - - 人 - - - onRemove(index)}> - - - - - {/* SP版レイアウト */} - - {/* 時間帯 + 削除ボタン */} - - onBandChange(index, "startTime", e.target.value)} - flex={1} - /> - - 〜 - - onBandChange(index, "endTime", e.target.value)} - flex={1} - /> - onRemove(index)}> - - - - - {/* 人数ステッパー */} - - onBandChange(index, "requiredCount", Math.max(1, band.requiredCount - 1))} - disabled={band.requiredCount <= 1} - > - - - - {band.requiredCount} - - onBandChange(index, "requiredCount", band.requiredCount + 1)} - > - - - - 人 - - - - -); - -// ============================================================ -// サブコンポーネント: 最低人員セクション -// ============================================================ - -const MinimumStaffSection = ({ value, onChange }: { value: number; onChange: (value: number) => void }) => ( - - {/* SP版: セクションヘッダー */} - - - - 最低人員設定 - - - - - - 常に最低 - - - {/* PC版: number input */} - onChange(Number(e.target.value))} - w="70px" - textAlign="center" - /> - - {/* SP版: stepper */} - - onChange(Math.max(0, value - 1))} - disabled={value <= 0} - > - - - - {value} - - onChange(value + 1)} - > - - - - - - 人を配置 - - - -); diff --git a/src/components/features/Shift/RecruitmentDetail/index.stories.tsx b/src/components/features/Shift/RecruitmentDetail/index.stories.tsx deleted file mode 100644 index bd3352fe..00000000 --- a/src/components/features/Shift/RecruitmentDetail/index.stories.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RecruitmentDetail } from "."; - -const meta = { - title: "Features/Shift/RecruitmentDetail", - component: RecruitmentDetail, - parameters: { - layout: "fullscreen", - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -const mockStaffs = [ - { id: "staff_1", name: "田中太郎", isSubmitted: true }, - { id: "staff_2", name: "山田花子", isSubmitted: true }, - { id: "staff_3", name: "佐藤一郎", isSubmitted: false }, -]; - -const mockPositions = [ - { id: "pos_hall", name: "ホール", color: "#3b82f6" }, - { id: "pos_kitchen", name: "キッチン", color: "#f97316" }, - { id: "pos_register", name: "レジ", color: "#10b981" }, -]; - -const mockDates = ["2025-12-01", "2025-12-02", "2025-12-03", "2025-12-04", "2025-12-05", "2025-12-06", "2025-12-07"]; - -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" }, - ], - }, -]; - -export const Open: Story = { - args: { - shopId: "shop_1", - recruitmentId: "recruitment_1", - recruitmentStatus: "open", - staffs: mockStaffs, - positions: mockPositions, - shifts: mockShifts, - dates: mockDates, - timeRange: { start: 9, end: 22, unit: 30 }, - 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 deleted file mode 100644 index 8f78940e..00000000 --- a/src/components/features/Shift/RecruitmentDetail/index.tsx +++ /dev/null @@ -1,206 +0,0 @@ -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"); - -const formatDateRange = (startDate: string, endDate: string) => { - const start = dayjs(startDate); - const end = dayjs(endDate); - return `${start.format("M/D(ddd)")} 〜 ${end.format("M/D(ddd)")}`; -}; - -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[]; - dates: string[]; - timeRange: TimeRange; - holidays: string[]; -}; - -export const RecruitmentDetail = ({ - shopId, - recruitmentId, - recruitmentStatus, - staffs, - positions, - shifts, - dates, - timeRange, - 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 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 ( - - {/* ヘッダー */} - {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> - <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> - - - - {/* 提出状況サマリー */} - - - - - 提出済み {submittedCount}名 - - - 未提出 {unsubmittedCount}名 - - - - - - {/* モバイル用アクションボタン */} - {mobileActionButton} - - {/* ShiftForm: 一覧モード固定、readOnly、シフト希望順 */} - - - - {/* 締切確認ダイアログ */} - - 締め切ると、スタッフは新たにシフト希望を提出できなくなります。 - - - ); -}; diff --git a/src/components/features/Shift/RecruitmentForm/index.stories.tsx b/src/components/features/Shift/RecruitmentForm/index.stories.tsx deleted file mode 100644 index 438c9888..00000000 --- a/src/components/features/Shift/RecruitmentForm/index.stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useForm } from "react-hook-form"; -import { RecruitmentForm } from "./index"; -import { type RecruitmentFormSchemaType, recruitmentFormSchema } from "./schema"; - -const meta: Meta = { - title: "features/Shift/RecruitmentForm", - component: RecruitmentForm, -}; - -export default meta; -type Story = StoryObj; - -export const Basic: Story = { - render: () => { - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(recruitmentFormSchema), - }); - - const onSubmit = (data: RecruitmentFormSchemaType) => { - console.log("Submit:", data); - }; - - return ( - - ); - }, -}; - -export const WithDefaultValues: Story = { - render: () => { - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(recruitmentFormSchema), - defaultValues: { - startDate: "2025-12-01", - endDate: "2025-12-07", - deadline: "2025-11-25", - }, - }); - - const onSubmit = (data: RecruitmentFormSchemaType) => { - console.log("Submit:", data); - }; - - return ( - - ); - }, -}; diff --git a/src/components/features/Shift/RecruitmentForm/index.tsx b/src/components/features/Shift/RecruitmentForm/index.tsx deleted file mode 100644 index bc2b412b..00000000 --- a/src/components/features/Shift/RecruitmentForm/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Box, Button, Field, Flex, Grid, GridItem, Input, VStack } from "@chakra-ui/react"; -import type { FieldErrors, UseFormRegister } from "react-hook-form"; -import { LuCalendar, LuClock } from "react-icons/lu"; -import { FormCard } from "@/src/components/ui/FormCard"; -import type { RecruitmentFormSchemaType } from "./schema"; - -type RecruitmentFormProps = { - register: UseFormRegister; - errors: FieldErrors; - isSubmitting: boolean; - onSubmit: (e: React.FormEvent) => void; -}; - -export const RecruitmentForm = ({ register, errors, isSubmitting, onSubmit }: RecruitmentFormProps) => { - return ( - - - {/* 募集期間 */} - - - - - - 開始日 - - {errors.startDate?.message} - - - - - 終了日 - - {errors.endDate?.message} - - - - - - - {/* 申請締切 */} - - - - 締切日 - スタッフがシフト希望を申請できる期限です - - {errors.deadline?.message} - - - - - {/* 送信ボタン */} - - - - - - ); -}; diff --git a/src/components/features/Shift/RecruitmentForm/schema.ts b/src/components/features/Shift/RecruitmentForm/schema.ts deleted file mode 100644 index 2cef8de1..00000000 --- a/src/components/features/Shift/RecruitmentForm/schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; - -export const recruitmentFormSchema = z - .object({ - startDate: z.string().min(1), - endDate: z.string().min(1), - deadline: z.string().min(1), - }) - .refine((data) => data.startDate <= data.endDate, { - message: "終了日は開始日以降を指定してください", - path: ["endDate"], - }) - .refine((data) => data.deadline < data.startDate, { - message: "締切日は開始日より前を指定してください", - path: ["deadline"], - }); - -export type RecruitmentFormSchemaType = z.infer; diff --git a/src/components/features/Shift/RecruitmentList/index.stories.tsx b/src/components/features/Shift/RecruitmentList/index.stories.tsx deleted file mode 100644 index c7d0f48a..00000000 --- a/src/components/features/Shift/RecruitmentList/index.stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RecruitmentList } from "."; - -const meta: Meta = { - component: RecruitmentList, - title: "Features/Shift/RecruitmentList", -}; - -export default meta; - -type Story = StoryObj; - -const mockShop = { - _id: "shop_1", - shopName: "サンプル店舗", -}; - -const mockRecruitments = [ - { - _id: "recruitment_1", - startDate: "2025-12-01", - endDate: "2025-12-07", - deadline: "2025-11-25", - status: "open" as const, - appliedCount: 5, - totalStaffCount: 8, - }, - { - _id: "recruitment_2", - startDate: "2025-12-08", - endDate: "2025-12-14", - deadline: "2025-12-01", - status: "closed" as const, - appliedCount: 8, - totalStaffCount: 8, - }, - { - _id: "recruitment_3", - startDate: "2025-12-15", - endDate: "2025-12-21", - deadline: "2025-12-08", - status: "confirmed" as const, - appliedCount: 8, - totalStaffCount: 8, - confirmedAt: 1733788800000, - }, -]; - -export const Basic: Story = { - args: { - shop: mockShop, - recruitments: mockRecruitments, - }, -}; - -export const Empty: Story = { - args: { - shop: mockShop, - recruitments: [], - }, -}; - -export const OnlyOpen: Story = { - args: { - shop: mockShop, - recruitments: [mockRecruitments[0]], - }, -}; diff --git a/src/components/features/Shift/RecruitmentList/index.tsx b/src/components/features/Shift/RecruitmentList/index.tsx deleted file mode 100644 index c08d8a92..00000000 --- a/src/components/features/Shift/RecruitmentList/index.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { Badge, Box, Button, Card, Container, Flex, Heading, HStack, Icon, Text } from "@chakra-ui/react"; -import { Link, useNavigate } from "@tanstack/react-router"; -import dayjs from "dayjs"; -import "dayjs/locale/ja"; -import { useState } from "react"; -import { LuCalendar, LuCalendarPlus, LuChevronRight, LuSettings } from "react-icons/lu"; -import { Animation } from "@/src/components/templates/Animation"; -import { Empty } from "@/src/components/ui/Empty"; -import { LoadingState } from "@/src/components/ui/LoadingState"; -import { Select } from "@/src/components/ui/Select"; -import { Title } from "@/src/components/ui/Title"; - -dayjs.locale("ja"); - -type RecruitmentType = { - _id: string; - startDate: string; - endDate: string; - deadline: string; - status: "open" | "closed" | "confirmed"; - appliedCount: number; - totalStaffCount: number; - confirmedAt?: number; -}; - -type ShopType = { - _id: string; - shopName: string; -}; - -type RecruitmentListProps = { - shop: ShopType; - recruitments: RecruitmentType[]; -}; - -const STATUS_CONFIG = { - open: { label: "募集中", colorPalette: "teal", iconBg: "teal.50" }, - closed: { label: "締切済み", colorPalette: "orange", iconBg: "orange.50" }, - confirmed: { label: "確定済み", colorPalette: "blue", iconBg: "blue.50" }, -} as const; - -const formatDateRange = (startDate: string, endDate: string) => { - const start = dayjs(startDate); - const end = dayjs(endDate); - return `${start.format("M/D(ddd)")} 〜 ${end.format("M/D(ddd)")}`; -}; - -const formatDate = (date: string) => { - return dayjs(date).format("M/D(ddd)"); -}; - -export const RecruitmentList = ({ shop, recruitments }: RecruitmentListProps) => { - const navigate = useNavigate(); - const [statusFilter, setStatusFilter] = useState("all"); - - // ステータスフィルター(バックエンドで開始日の降順ソート済み) - const filteredRecruitments = recruitments.filter((recruitment) => { - if (statusFilter === "all") return true; - return recruitment.status === statusFilter; - }); - - return ( - - {/* ヘッダー */} - - <Link to="/shops/$shopId/shifts/settings" params={{ shopId: shop._id }}> - <Button variant="outline" colorPalette="gray" gap={2}> - <Icon as={LuSettings} boxSize={4} /> - 必要人員設定 - </Button> - </Link> - <Link to="/shops/$shopId/shifts/recruitments/new" params={{ shopId: shop._id }}> - <Button colorPalette="teal" gap={2}> - <Icon as={LuCalendarPlus} boxSize={4} /> - 新規募集作成 - </Button> - </Link> - </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> - <Heading as="h2" size="xl" color="gray.900"> - シフト管理 - </Heading> - <Text fontSize="sm" color="gray.500"> - {shop.shopName} - </Text> - </Box> - </Flex> - - - - {/* フィルター */} - - - {/* ステータスフィルター */} -