diff --git a/.claude/skills/create-design/SKILL.md b/.claude/skills/create-design/SKILL.md new file mode 100644 index 00000000..cb59d7f6 --- /dev/null +++ b/.claude/skills/create-design/SKILL.md @@ -0,0 +1,201 @@ +--- +name: create-design +description: Create mock screen designs as Storybook stories using this project's design system (Chakra UI v3 + `src/components/ui/` wrappers). Triggers only when the user explicitly invokes `/create-design` or says "デザイン作って" / "モック作って" / "make a mock" / "design this screen". **Never auto-trigger**. Handles both new-screen proposals and existing-screen redesigns. +--- + +# /create-design — Storybook Mock Design Generator + +You are an expert designer working with the user as a manager. You produce design artifacts on their behalf — **as Storybook stories inside this project's codebase**, using its existing design system. + +You must embody an expert in the relevant domain: UX designer, prototyper, visual designer. Avoid generic web design tropes. Match the visual vocabulary of what already exists here. + +## Output contract + +- Output is always a `*.stories.tsx` file (plus any supporting mock files it imports). +- **No real logic.** No Convex queries/mutations, no Router definitions, no auth wrappers, no tests. Dummy data + inline state only. +- Use Chakra UI v3 style props. No Tailwind, no raw CSS. +- Storybook is `@storybook/react-vite` — import `Meta`/`StoryObj` from there (not `@storybook/react`). +- `@storybook/test` is not installed — do not use `fn()`. Pass callbacks as `() => {}`. + +## Workflow + +1. Ask clarifying questions +2. Gather design context from the codebase +3. Verbalize the system you'll use (junior-designer-to-manager review) +4. Build +5. Verify visually with Playwright MCP +6. Short end-of-turn summary (1–2 sentences) + +Run file-exploration tools concurrently when possible. For any scope beyond a single screen or beyond a few hours of work, use a todo list to track progress. + +--- + +### Step 1: Ask clarifying questions + +Asking good questions is **essential**. Bad context produces bad design. Always confirm the starting point — a design system, an existing page, a spec doc — before building. + +For every request, confirm at minimum: + +- **What is being designed**: a new screen, a redesign of an existing one, or a single section/component +- **Target device**: PC (max content width 1024px), SP (design baseline 390px), or both +- **Variation count**: single proposal, or 2–3 proposals to compare + +For **existing-screen redesigns**, go deeper. This is a conversation, not a checklist — ask in batches of 2–3 and keep iterating: + +- Why redesign? What concretely isn't working? +- Who uses it now, how often, and has that changed? +- What must survive the redesign? What should be cut? +- Is the scope visual only, information architecture, or full rethink? +- Can data structure and navigation change? +- Any reference products or inspirations? +- What does "done" look like for them? + +Skip questions only for obvious tweaks or when the user has already provided everything. + +### Step 2: Gather design context + +**Mocking a full product from scratch is a last resort.** Before writing any component, read what already exists. Do this in parallel: + +- `src/components/ui/` — generic UI wrappers (Button, FormCard, BottomSheet, Empty, Title, Select…) +- `src/components/templates/` — layout shells (BottomMenu, SideMenu, ContentWrapper) +- `src/components/features/` — domain UI (always read this when redesigning) +- `src/components/pages/` — existing page-level compositions +- `design/designIndex.md` — index of `.pen` design files +- `doc/features/.md` — feature specs + +While reading, observe and think out loud about the **visual vocabulary**: color palette, spacing rhythm, corner radii, shadow depth, density, hover/click states, copy tone, iconography. You will match it unless the user explicitly asks to break from it. + +**Prefer code over screenshots.** When redesigning an existing screen, read the actual source of the current page/feature — not just a screenshot of it. Code tells you exact tokens, spacing, and structural choices; screenshots only tell you appearance. Use screenshots as supplements when the user provides them, not as a substitute for reading the source. + +If the context you need isn't here, ask the user to point you at it (a screenshot, a `.pen` file, a reference product, a URL). Don't invent from thin air. Be proactive — list directories, grep for relevant names, open adjacent files. + +### Step 3: Verbalize the system + +Before writing components, state — like a junior designer briefing their manager — in a few short lines: + +- The visual system you'll use (one line: e.g. "calm neutral surface, single teal accent, generous card radii") +- Layout approach for the main sections (3–5 bullets) +- Which existing components you'll reuse +- What you'll placeholder vs. render fully +- Any intentional breaks from existing vocabulary, and why + +If the direction is obvious, keep moving. If there's a real fork, pause and confirm. + +### Step 4: Build + +**Output location**: + +- New screen → default `src/components/mocks//index.stories.tsx`. Confirm placement with the user once; match whatever the project has already established for mocks. +- Redesign → place alongside the existing story as `.v2.stories.tsx` (or similar), so old and new coexist for comparison. + +**File organization** — for multi-variant screen mocks, this layout has worked well in this repo: + +``` +src/components/mocks// + index.stories.tsx # meta + imports + exports only (thin) + VariantA.tsx # one variant per file + VariantB.tsx + mockData.ts # shared dummy data +``` + +Keep each variant file under ~300 lines — split nested components out if you're above that. + +**Show early, iterate.** Don't disappear for an hour and return with a finished mock. As soon as the skeleton loads cleanly in Storybook, tell the user it's viewable — even if sections are placeholder boxes. Take one round of reaction, then fill in. Iterative feedback beats one big reveal. + +**Implementation rules**: + +- Use Chakra UI v3 style props (``). Recipes, compound components, and `ContentWrapper` are your friends. +- Handlers are `() => {}`. State that needs to demo interaction uses `useState`. +- Put mock data at the top of the file or in a sibling `mockData.ts`. Keep it realistic — real-looking names, times, counts — not `"foo"`/`"bar"`. +- **Placeholders beat bad attempts**. If you don't have an icon, illustration, logo, or chart, a labeled `` or a plain text label is always better than a hand-drawn SVG or AI-guessed rendering. +- For full-screen mocks: one variant per source file, all re-exported as `Variant_X` stories from the thin `index.stories.tsx` (see File organization above). +- For small UI components: collapse multiple states into a single story with a `` grid — cheaper on VRT capture count. + +**Project-specific rules (non-negotiable)**: + +- MVP screens do not include `SideMenu`. Prefer SP-first; use `BottomMenu` if navigation is needed. +- Avoid border duplication. A header bottom-border stacked against a content card border reads as a fat double line. +- **Status color palette**: `teal` = brand, `orange` = needs attention, `green` = achievement, `gray` = completed/disabled, `red` = destructive. `yellow` is almost never used. +- Inside modal/BottomSheet, `Select` must be `usePortal={false}` or the dropdown renders behind the surface. If `BottomSheet`'s `overflowY="auto"` clips a dropdown, pass `overflowY="visible"`. +- Never use `new Date()` + `toISOString()` for date strings — TZ drift. Even in mocks, use `dayjs` or fixed `"YYYY-MM-DD"` literals. +- Complex UI (shift tables, grids with many interactive cells) — render as a **placeholder block**, not a pixel-perfect rendition. The feedback loop on those is better in code than in mocks. + +**Copy tone** (from `CLAUDE.md` text guidelines): + +- Titles/subtitles: no commas or periods; mid-register tone (not `です/ます` but not slangy either); 体言止め fine; prefer hiragana density; short. +- Lead with benefit, not pain. No humble-brag ("すごいでしょ?"). No condescension ("小さなお店" → "少人数のお店"). +- One line, multiple jobs (who it's for + what it does). + +### Step 5: Verify with Playwright MCP + +- Make sure Storybook is running (`pnpm storybook`, port 6006). If not, ask the user to start it. +- Story URL format (iframe, no Storybook chrome): `http://localhost:6006/iframe.html?id=--&viewMode=story`. Example: `id=mocks-dashboard--variant-a`. +- `playwright_navigate` to the story URL, then `playwright_screenshot`. +- Capture at SP (390×844) and PC (1280×800) at minimum. For responsive breakpoint work, add a mid width (768×1024). +- Check: layout breaks, overflow, border duplication, text wrapping, tap targets ≥ 44px, vertical rhythm, status color correctness. Match against the intent declared in Step 3. +- Fix and re-capture. Don't report done until clean. + +### Step 6: End-of-turn summary + +1–2 sentences: what was built + the single most useful next action (pick a variant, tune copy, start wiring, etc.). No recap, no self-congratulation. + +--- + +## Design principles + +### Do + +- **Match existing visual vocabulary**: colors, radii, shadow, density, copy tone. Observe before you write. Think out loud about what you see. +- **Use colors from the existing palette.** If you genuinely need a new color, compute it with `oklch()` so it harmonizes — don't invent hex from scratch. +- **Placeholders are a feature**, not a failure. A `gray.200` box labeled "shift grid placeholder" is a clearer design communication than a half-faked table. +- **Declare a system up front.** Pick one approach for section headers, one type scale, 1–2 background colors max. Commit to it across the screen. +- **Use scale deliberately.** SP tap targets ≥ 44px. Body text ≥ 14px. Headings big enough to do the work. +- **Use CSS properly.** `text-wrap: pretty`, CSS Grid, `aspect-ratio`, `min()/max()/clamp()`, container queries — they're there, use them. +- **Surprise the user when it helps.** Users often don't know what HTML/CSS can do. Novel layouts, scale play, layering, and visual rhythm are worth proposing. + +### Avoid + +- **Filler content.** Never pad a design with dummy sections, placeholder paragraphs, or invented stats just because the layout feels empty. Emptiness is a layout problem, solved with composition — not with made-up material. One thousand no's for every yes. +- **Unilateral additions.** If you think a section would help, **ask** before adding it. The user knows the audience better than you. +- **AI slop**, including: + - Aggressive gradient backgrounds + - Emoji that aren't part of the brand + - Rounded containers with a left-border accent color + - SVG illustrations drawn from scratch — use placeholders, or ask for real assets + - Overused typefaces (Inter, Roboto, Arial, system stacks — use what the project ships) + - Data slop (decorative numbers, unearned icons, KPI-shaped nothing) +- **Repetition.** The same point in two sections is a design bug. +- **Cross-project tropes.** Don't design a "web page" unless this is a web page. Don't design an "admin dashboard" unless the user asked for one. + +--- + +## Variations strategy + +When the user asks for 2–3 variations, make the spread meaningful: + +- **Variant A — safe, pattern-faithful.** Uses the existing vocabulary as-is. This is the "you could ship this tomorrow" option. +- **Variant B (and C) — increasingly exploratory.** Push on one dimension at a time: layout metaphor, information hierarchy, type treatment, density, or interaction model. Start close to A and get bolder. +- **Don't waste variants on trivial deltas** (different accent color, slightly different padding). If A and B differ only in hex, you've delivered one variant, not two. +- Mix conservative with novel. Mix text-heavy with imagery-led. Mix dense with airy. Goal: expose the user to as many atomic choices as possible so they can mix-and-match. + +Expose each variant as its own `export const Variant_X: StoryObj = { render: () => }`. + +--- + +## Asking questions — tips from the base + +- **Confirm context via a question, not an assumption.** "I assume you want to reuse the existing form card" is worse than asking. +- When redesigning, ask what they care most about — flows, copy, or visuals — and design variants along that axis. +- Ask whether they want divergent visuals / interactions / copy, or just polish on the existing pattern. +- Err on the side of more questions for ambiguous briefs. For tight briefs, skip questions entirely. +- Before showing variations, ask what dimensions they want explored (novel UX, different visuals, animation, copy). + +--- + +## Do not produce + +- `CLAUDE.md`, `README.md`, or other documentation files +- Real logic (Convex, API calls, auth) +- Route definitions +- Test files +- Exports to PPTX / PDF / video / Canva — this skill builds Storybook stories, nothing else diff --git a/CLAUDE.md b/CLAUDE.md index 575cbc89..b712f3f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,6 +226,7 @@ Convex agent skills for common tasks can be installed by running `npx convex ai- - プロセスの説明より体験を伝える - 上から目線NG(「小さなお店」→「少人数のお店」) - 短く 1行で複数の役割を持たせる(「誰向け」+「何をしてくれる」を合体) +- 日本語の文章は不用意に半角スペースをいれない #### 構造 diff --git a/e2e/pages/StaffSubmitPage.ts b/e2e/pages/StaffSubmitPage.ts index 167ca3df..fa51c0d8 100644 --- a/e2e/pages/StaffSubmitPage.ts +++ b/e2e/pages/StaffSubmitPage.ts @@ -20,7 +20,8 @@ export class StaffSubmitPage { } async expectCompletionVisible() { - await expect(this.page.getByText("提出しました")).toBeVisible(); + await expect(this.page).toHaveURL(/\/shifts\/submit\/completed$/); + await expect(this.page.getByText("提出が完了しました")).toBeVisible(); } async expectReadOnlyVisible() { @@ -52,10 +53,6 @@ export class StaffSubmitPage { await this.page.getByRole("button", { name: /提出する/ }).click(); } - async clickEdit() { - await this.page.getByRole("button", { name: "内容を修正する" }).click(); - } - async expectDayWorking(dateText: string) { const row = this.page.getByText(dateText, { exact: true }).locator(".."); await expect(row.getByText("〜")).toBeVisible(); diff --git a/e2e/scenarios/staff-shift-submission.test.ts b/e2e/scenarios/staff-shift-submission.test.ts index 8442ebdf..f716d594 100644 --- a/e2e/scenarios/staff-shift-submission.test.ts +++ b/e2e/scenarios/staff-shift-submission.test.ts @@ -42,7 +42,7 @@ test.describe("スタッフのシフト希望提出", () => { }); await test.step("Step 3: 修正して再提出する", async () => { - await submitPage.clickEdit(); + await submitPage.goto(token); await submitPage.expectFormVisible(); await submitPage.expectSubmittedBadge(); diff --git a/index.html b/index.html index baa3dd62..5c0cfbd9 100644 --- a/index.html +++ b/index.html @@ -63,10 +63,12 @@ "url": "https://shiftori.app", "applicationCategory": "BusinessApplication", "operatingSystem": "Web", - "offers": { - "@type": "Offer", - "price": "0", - "priceCurrency": "JPY" + "image": "https://shiftori.app/ogp.png", + "inLanguage": "ja", + "provider": { + "@type": "Organization", + "name": "シフトリ", + "url": "https://shiftori.app" } } diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 00000000..abd52e72 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,24 @@ +# シフトリ + +> シフトリは少人数のお店向けのシフト管理SaaSです。スタッフはアカウント登録やアプリのインストール不要で、オーナーが送るリンクを開くだけで希望シフトを提出できます。 + +## 特徴 + +- スタッフはアカウント登録不要(リンクを開いて日付をタップするだけで提出できる) +- アプリのインストール不要(スマホのブラウザで完結) +- オーナーの登録だけで使いはじめられる +- 募集・希望収集・確定通知までワンクリックで進むシンプルな流れ +- LINE・Excel・メモに散っていたシフトづくりの情報をひとつの画面にまとめる +- 2人から20人くらいの少人数のお店(飲食店・美容室・個人経営のサービス業など)を想定 + +## 使い方 + +1. オーナーが募集期間を決めてスタッフにリンクを配る +2. スタッフがスマホのブラウザで希望シフトを提出する +3. 集まった希望を見て調整し、確定シフトをワンクリックで全員に通知する + +## リンク + +- [サービスサイト](https://shiftori.app/) +- [利用規約](https://shiftori.app/terms) +- [プライバシーポリシー](https://shiftori.app/privacy) diff --git a/src/components/features/Dashboard/DashboardContent/formatShiftTimeRange.test.ts b/src/components/features/Dashboard/DashboardContent/formatShiftTimeRange.test.ts index db12b58a..38bc0d51 100644 --- a/src/components/features/Dashboard/DashboardContent/formatShiftTimeRange.test.ts +++ b/src/components/features/Dashboard/DashboardContent/formatShiftTimeRange.test.ts @@ -10,7 +10,7 @@ describe("formatShiftTimeRange", () => { expect(formatShiftTimeRange("14:00", "25:00")).toBe("14:00〜翌1:00"); }); - it("24:00 ちょうどは 翌0:00 になる", () => { + it("24:00ちょうどは翌0:00になる", () => { expect(formatShiftTimeRange("09:00", "24:00")).toBe("09:00〜翌0:00"); }); diff --git a/src/components/features/Dashboard/HeroSummary/index.stories.tsx b/src/components/features/Dashboard/HeroSummary/index.stories.tsx index 93fb5b8d..00a80b83 100644 --- a/src/components/features/Dashboard/HeroSummary/index.stories.tsx +++ b/src/components/features/Dashboard/HeroSummary/index.stories.tsx @@ -61,9 +61,12 @@ export const Variants: Story = {
-
+
+
+ +
{}} />
diff --git a/src/components/features/Dashboard/HeroSummary/index.tsx b/src/components/features/Dashboard/HeroSummary/index.tsx index 00bab13d..e3e43e19 100644 --- a/src/components/features/Dashboard/HeroSummary/index.tsx +++ b/src/components/features/Dashboard/HeroSummary/index.tsx @@ -1,7 +1,6 @@ -import { Box, Button, Flex, HStack, IconButton, Stack, Text } from "@chakra-ui/react"; -import type { ReactNode } from "react"; +import { Box, Button, Flex, Heading, HStack, IconButton, Stack, Text } from "@chakra-ui/react"; import type { IconType } from "react-icons"; -import { LuArrowRight, LuCalendarClock, LuCircleAlert, LuSettings, LuSparkles } from "react-icons/lu"; +import { LuArrowRight, LuCalendarClock, LuCircleAlert, LuPlus, LuSettings, LuSparkles } from "react-icons/lu"; import { formatShiftTimeRange } from "@/src/components/features/Dashboard/DashboardContent/formatShiftTimeRange"; import type { Recruitment } from "@/src/components/features/Dashboard/types"; import { formatDateShort } from "@/src/components/features/Shift/ShiftForm/utils/dateUtils"; @@ -25,40 +24,31 @@ export const HeroSummary = ({ shop, recruitments, onEditClick, onOpenShiftBoard, const action = pickNextAction(recruitments); return ( - - - - - ダッシュボード - - {shop.name} - - - シフト時間帯 {formatShiftTimeRange(shop.shiftStartTime, shop.shiftEndTime)} - - - - - - + + + + + {shop.name} + + + {formatShiftTimeRange(shop.shiftStartTime, shop.shiftEndTime)} + + + + + + - - - + + ); }; @@ -67,13 +57,22 @@ type WelcomeHeroProps = { }; export const WelcomeHero = ({ onSetupClick }: WelcomeHeroProps) => ( - - - はじめに - - - まずは お店のことを 教えてください + + + + + はじめに + + まずはお店のことを教えてください + - -); - -const HeroShell = ({ padding, children }: { padding: { base: number; lg: number }; children: ReactNode }) => ( - - - {children} ); -type ActionPanelProps = { +// ---------- ActionCard ---------- + +type ActionCardProps = { action: NextAction; onOpenShiftBoard: (recruitmentId: string) => void; onCreateRecruitment: () => void; }; -const ActionPanel = ({ action, onOpenShiftBoard, onCreateRecruitment }: ActionPanelProps) => { +const ActionCard = ({ action, onOpenShiftBoard, onCreateRecruitment }: ActionCardProps) => { + if (action.kind === "idle") { + return ( + + ); + } const view = describeAction(action); - const onClick = action.kind === "idle" ? onCreateRecruitment : () => onOpenShiftBoard(action.recruitment._id); - return ( - + onOpenShiftBoard(getRecruitmentId(action))} + /> + ); +}; + +const getRecruitmentId = (action: Exclude) => action.recruitment._id; + +type SlimCardProps = { + icon: IconType; + iconBg: string; + iconFg: string; + border: string; + title: string; + sub: string; + cta: { label: string; icon?: IconType; palette: "teal" | "orange"; variant: "solid" | "outline" }; + onClick: () => void; +}; + +const SlimCard = ({ icon: Icon, iconBg, iconFg, border, title, sub, cta, onClick }: SlimCardProps) => ( + + - - - - - - - {view.title} - - - {view.subtitle} - - - - + - - ); -}; + + + {title} + + + {sub} + + + + + +); type ActionView = { icon: IconType; iconBg: string; - iconColor: string; - borderColor: string; + iconFg: string; + border: string; title: string; - subtitle: string; - ctaLabel: string; - ctaPalette: "teal" | "orange"; - ctaVariant: "solid" | "outline"; + sub: string; + cta: { label: string; palette: "teal" | "orange"; variant: "solid" | "outline" }; }; -function describeAction(action: NextAction): ActionView { +function describeAction(action: Exclude): ActionView { switch (action.kind) { case "past-deadline": { const { periodStart, periodEnd, deadline, responseCount } = action.recruitment; return { icon: LuCircleAlert, iconBg: "orange.100", - iconColor: "orange.600", - borderColor: "orange.200", - title: `${formatDateShort(periodStart)}〜${formatDateShort(periodEnd)} の シフト調整がまだ`, - subtitle: `締切は ${formatDateShort(deadline)} 提出 ${responseCount}人`, - ctaLabel: "シフトを見る", - ctaPalette: "orange", - ctaVariant: "solid", + iconFg: "orange.600", + border: "orange.200", + title: `${formatDateShort(periodStart)}〜${formatDateShort(periodEnd)}のシフトを組もう`, + sub: `提出${responseCount}人・締切${formatDateShort(deadline)}超過`, + cta: { label: "シフトを見る", palette: "orange", variant: "solid" }, }; } case "deadline-today": { const { periodStart, periodEnd, responseCount } = action.recruitment; return { - icon: LuCalendarClock, + icon: LuCircleAlert, iconBg: "orange.100", - iconColor: "orange.600", - borderColor: "orange.200", - title: `${formatDateShort(periodStart)}〜${formatDateShort(periodEnd)} は 今日が締切`, - subtitle: `提出 ${responseCount}人`, - ctaLabel: "シフトを見る", - ctaPalette: "orange", - ctaVariant: "solid", + iconFg: "orange.600", + border: "orange.200", + title: `${formatDateShort(periodStart)}〜${formatDateShort(periodEnd)}は今日が締切`, + sub: `提出${responseCount}人・もうすぐシフトを組めます`, + cta: { label: "シフトを見る", palette: "orange", variant: "solid" }, }; } case "deadline-soon": { @@ -214,72 +226,24 @@ function describeAction(action: NextAction): ActionView { return { icon: LuCalendarClock, iconBg: "teal.100", - iconColor: "teal.700", - borderColor: "teal.200", - title: `${formatDateShort(periodStart)}〜${formatDateShort(periodEnd)} は あと${action.daysLeft}日で締切`, - subtitle: `提出 ${responseCount}人`, - ctaLabel: "シフトを見る", - ctaPalette: "teal", - ctaVariant: "outline", + iconFg: "teal.700", + border: "teal.200", + title: `${formatDateShort(periodStart)}〜${formatDateShort(periodEnd)}はあと${action.daysLeft}日で締切`, + sub: `提出${responseCount}人・もうすぐ締まります`, + cta: { label: "シフトを見る", palette: "teal", variant: "outline" }, }; } - case "idle": + case "collecting": { + const { periodStart, periodEnd, deadline, responseCount } = action.recruitment; return { - icon: LuSparkles, - iconBg: "teal.100", - iconColor: "teal.700", - borderColor: "teal.100", - title: "今日 やることはありません", - subtitle: "次の募集を作って シフト希望を集めよう", - ctaLabel: "募集を作る", - ctaPalette: "teal", - ctaVariant: "solid", + icon: LuCalendarClock, + iconBg: "teal.50", + iconFg: "teal.700", + border: "teal.100", + title: `${formatDateShort(periodStart)}〜${formatDateShort(periodEnd)}の提出待ち`, + sub: `提出${responseCount}人・締切${formatDateShort(deadline)}まであと${action.daysLeft}日`, + cta: { label: "募集を見る", palette: "teal", variant: "outline" }, }; + } } } - -const Decoration = () => ( - <> - - - -); - -const EyebrowPill = ({ children }: { children: string }) => ( - - - {children} - -); diff --git a/src/components/features/Dashboard/HeroSummary/pickNextAction.test.ts b/src/components/features/Dashboard/HeroSummary/pickNextAction.test.ts index 8eeaf5c8..a5e64223 100644 --- a/src/components/features/Dashboard/HeroSummary/pickNextAction.test.ts +++ b/src/components/features/Dashboard/HeroSummary/pickNextAction.test.ts @@ -27,8 +27,9 @@ describe("pickNextAction", () => { expect(pickNextAction([make({ status: "confirmed", deadline: "2026-04-15" })], NOW)).toEqual({ kind: "idle" }); }); - it("returns idle when collecting deadline is more than 3 days away", () => { - expect(pickNextAction([make({ deadline: "2026-04-25" })], NOW)).toEqual({ kind: "idle" }); + it("returns collecting when open recruitment deadline is more than 3 days away", () => { + const r = make({ deadline: "2026-04-25" }); + expect(pickNextAction([r], NOW)).toEqual({ kind: "collecting", recruitment: r, daysLeft: 7 }); }); it("prioritizes past-deadline over collecting", () => { diff --git a/src/components/features/Dashboard/HeroSummary/pickNextAction.ts b/src/components/features/Dashboard/HeroSummary/pickNextAction.ts index f7d45cab..da43edc0 100644 --- a/src/components/features/Dashboard/HeroSummary/pickNextAction.ts +++ b/src/components/features/Dashboard/HeroSummary/pickNextAction.ts @@ -5,6 +5,7 @@ export type NextAction = | { kind: "past-deadline"; recruitment: Recruitment } | { kind: "deadline-today"; recruitment: Recruitment } | { kind: "deadline-soon"; recruitment: Recruitment; daysLeft: number } + | { kind: "collecting"; recruitment: Recruitment; daysLeft: number } | { kind: "idle" }; const SOON_THRESHOLD_DAYS = 3; @@ -20,13 +21,15 @@ export function pickNextAction(recruitments: Recruitment[], now: Dayjs = dayjs() const upcoming = open .map((r) => ({ r, daysLeft: dayjs(r.deadline).startOf("day").diff(today, "day") })) - .filter((x) => x.daysLeft >= 0 && x.daysLeft <= SOON_THRESHOLD_DAYS) + .filter((x) => x.daysLeft >= 0) .sort((a, b) => a.daysLeft - b.daysLeft); const top = upcoming[0]; if (top) { if (top.daysLeft === 0) return { kind: "deadline-today", recruitment: top.r }; - return { kind: "deadline-soon", recruitment: top.r, daysLeft: top.daysLeft }; + if (top.daysLeft <= SOON_THRESHOLD_DAYS) + return { kind: "deadline-soon", recruitment: top.r, daysLeft: top.daysLeft }; + return { kind: "collecting", recruitment: top.r, daysLeft: top.daysLeft }; } return { kind: "idle" }; diff --git a/src/components/features/Dashboard/RecruitmentBoard/index.stories.tsx b/src/components/features/Dashboard/RecruitmentBoard/index.stories.tsx index e6237afd..9c07966a 100644 --- a/src/components/features/Dashboard/RecruitmentBoard/index.stories.tsx +++ b/src/components/features/Dashboard/RecruitmentBoard/index.stories.tsx @@ -38,7 +38,7 @@ export const Variants: Story = { - もっと見る ありの状態 + もっと見るありの状態 - 募集の進み具合を まとめて確認 + 募集の進み具合をまとめて確認
-
+
- 法令に基づく場合を除き、本人の同意なく個人情報を第三者に提供することはありません。前項の外部サービスは、本サービスの運営に必要な業務委託先として利用しています。 + 本サービスでは、サイトの利用状況を把握し、サービスの改善に役立てるため、以下のアクセス解析ツールを利用しています。これらのツールはCookieを使用して情報を収集し、収集された情報は各提供元のサーバー(日本国外を含む)に送信されます。 + + + Google Analytics(Google Tag Manager経由): + + + Googleのプライバシーポリシーの詳細は + + https://policies.google.com/privacy + + をご確認ください。 + + + Microsoft Clarity: + + + Microsoftのプライバシーポリシーの詳細は + + https://privacy.microsoft.com/privacystatement + + をご確認ください。 + +
+ +
+ + 法令に基づく場合を除き、本人の同意なく個人情報を第三者に提供することはありません。前各項の外部サービスおよびアクセス解析ツールは、本サービスの運営に必要な業務委託先として利用しています。
-
+
データはConvexのクラウドサーバーに保管されます。不要になったデータは管理者が削除できます。
-
+
個人情報の取り扱いに関するお問い合わせは、以下のフォームからご連絡ください。 { - const [showCompletion, setShowCompletion] = useState(false); - const [submittedEntries, setSubmittedEntries] = useState(null); - // 状態D: 未提出+締切後 if (!data.isBeforeDeadline && !data.hasSubmitted) { return ; @@ -24,22 +19,6 @@ export const ShiftSubmitPage = ({ data, onSubmit }: Props) => { return ; } - // 画面5: 提出完了 - if (showCompletion && submittedEntries) { - return ( - setShowCompletion(false)} /> - ); - } - // 状態A/B: 締切前(編集可能) - return ( - { - await onSubmit(entries); - setSubmittedEntries(entries); - setShowCompletion(true); - }} - /> - ); + return ; }; diff --git a/src/components/features/StaffSubmit/SubmitCompleteView/index.stories.tsx b/src/components/features/StaffSubmit/SubmitCompleteView/index.stories.tsx deleted file mode 100644 index 48865e16..00000000 --- a/src/components/features/StaffSubmit/SubmitCompleteView/index.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { DayEntry } from "../DayCard"; -import { SubmitCompleteView } from "./index"; - -const mockEntries: DayEntry[] = [ - { date: "2026-04-07", isWorking: true, startTime: "09:00", endTime: "18:00" }, - { date: "2026-04-08", isWorking: true, startTime: "09:00", endTime: "18:00" }, - { date: "2026-04-09", isWorking: true, startTime: "10:00", endTime: "15:00" }, - { date: "2026-04-10", isWorking: false, startTime: "09:00", endTime: "22:00" }, - { date: "2026-04-11", isWorking: true, startTime: "09:00", endTime: "22:00" }, - { date: "2026-04-12", isWorking: false, startTime: "09:00", endTime: "22:00" }, - { date: "2026-04-13", isWorking: false, startTime: "09:00", endTime: "22:00" }, -]; - -const meta = { - title: "features/StaffSubmit/SubmitCompleteView", - component: SubmitCompleteView, - parameters: { - layout: "fullscreen", - }, - globals: { - viewport: { value: "mobile2", isRotated: false }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Basic: Story = { - args: { - shopName: "居酒屋さくら", - entries: mockEntries, - onEdit: () => {}, - }, -}; diff --git a/src/components/features/StaffSubmit/SubmitCompleteView/index.tsx b/src/components/features/StaffSubmit/SubmitCompleteView/index.tsx deleted file mode 100644 index d7baf055..00000000 --- a/src/components/features/StaffSubmit/SubmitCompleteView/index.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Box, Button, Circle, Flex, Icon, Text, VStack } from "@chakra-ui/react"; -import { LuCheck } from "react-icons/lu"; -import { formatDateWithWeekday } from "@/src/components/features/Shift/ShiftForm/utils/dateUtils"; -import type { DayEntry } from "../DayCard"; -import { SubmitPageContent, SubmitPageLayout } from "../SubmitPageLayout"; -import { formatTime, getDateColor } from "../utils/timeOptions"; - -type Props = { - shopName: string; - entries: DayEntry[]; - onEdit: () => void; -}; - -export const SubmitCompleteView = ({ shopName, entries, onEdit }: Props) => { - return ( - - {/* Header (full-width bg) */} - - - - {shopName} - - - シフト希望を提出 - - - - - {/* Success Area (full-width bg) */} - - - - - - - - - 提出しました - - - シフトが確定したらメールでお知らせします - - - - - - {/* Summary */} - - - {entries.map((entry, i) => ( - - - {formatDateWithWeekday(entry.date)} - - {entry.isWorking ? ( - - {formatTime(entry.startTime)} 〜 {formatTime(entry.endTime)} - - ) : ( - - 休み - - )} - - ))} - - - - {/* Edit Button */} - - - - - - ); -}; diff --git a/src/components/features/StaffSubmit/SubmittedView/index.stories.tsx b/src/components/features/StaffSubmit/SubmittedView/index.stories.tsx new file mode 100644 index 00000000..139ec1fd --- /dev/null +++ b/src/components/features/StaffSubmit/SubmittedView/index.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SubmittedView } from "./index"; + +const meta = { + title: "features/StaffSubmit/SubmittedView", + component: SubmittedView, + parameters: { + layout: "fullscreen", + }, + globals: { + viewport: { value: "mobile2", isRotated: false }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = {}; diff --git a/src/components/features/StaffSubmit/SubmittedView/index.tsx b/src/components/features/StaffSubmit/SubmittedView/index.tsx new file mode 100644 index 00000000..ff32dfd7 --- /dev/null +++ b/src/components/features/StaffSubmit/SubmittedView/index.tsx @@ -0,0 +1,38 @@ +import { Box, Circle, Flex, Icon, Text, VStack } from "@chakra-ui/react"; +import { LuCheck } from "react-icons/lu"; +import { SubmitPageLayout } from "../SubmitPageLayout"; + +export const SubmittedView = () => { + return ( + + + + + シフト希望を提出 + + + + + + + + + + + + + 提出が完了しました + + + + 店長からの連絡をお待ちください + + + このページは閉じて大丈夫です + + + + + + ); +}; diff --git a/src/components/mocks/ShiftForm/DailyView.tsx b/src/components/mocks/ShiftForm/DailyView.tsx new file mode 100644 index 00000000..85b7719f --- /dev/null +++ b/src/components/mocks/ShiftForm/DailyView.tsx @@ -0,0 +1,757 @@ +import { Box, Flex, Grid } from "@chakra-ui/react"; +import { useMemo, useState } from "react"; +import { LuCheck, LuChevronLeft, LuChevronRight, LuInfo, LuPencil, LuTriangleAlert } from "react-icons/lu"; +import { + countWorkingAt, + DATES, + dayIsSatisfied, + dowColor, + PEAK_BANDS, + positionById, + requiredAt, + STAFFS, + shiftOf, + TIME_RANGE, + timeToPct, + unsubmittedStaff, +} from "./mockData"; +import { Avatar } from "./parts"; + +const HOURS = Array.from({ length: TIME_RANGE.end - TIME_RANGE.start + 1 }, (_, i) => i + TIME_RANGE.start); + +const formatJp = (iso: string) => { + const [, m, d] = iso.split("-"); + return `${Number(m)}月${Number(d)}日`; +}; + +const weekdayLong: Record = { + 月: "月曜", + 火: "火曜", + 水: "水曜", + 木: "木曜", + 金: "金曜", + 土: "土曜", + 日: "日曜", +}; + +// ---- PC (Pattern A, polished) -------------------------------------------- + +export const DailyPC = () => { + const [selected, setSelected] = useState("2026-01-21"); + const selectedDate = DATES.find((d) => d.iso === selected) ?? DATES[0]; + + const rows = useMemo( + () => + STAFFS.filter((s) => s.status !== "not_submitted").map((s) => ({ + staff: s, + shift: shiftOf(s.id, selected), + })), + [selected], + ); + const working = rows.filter((r) => r.shift?.asn); + const requesting = rows.filter((r) => r.shift?.req); + const unassigned = rows.filter((r) => r.shift?.req && !r.shift?.asn); + + return ( + + {/* Left: day rail */} + + {DATES.map((d) => { + const active = d.iso === selected; + const satisfied = dayIsSatisfied(d.iso); + return ( + setSelected(d.iso)} + mx={2} + py="8px" + textAlign="center" + borderRadius="8px" + cursor="pointer" + bg={active ? "teal.50" : "transparent"} + borderWidth="1px" + borderColor={active ? "teal.300" : "transparent"} + transition="all 120ms" + _hover={{ bg: active ? "teal.50" : "gray.50" }} + > + + {d.w} + + + {d.dd ?? d.d.split("/")[1]} + + + + + + ); + })} + + + {/* Center: timeline */} + + + + + + + + + {weekdayLong[selectedDate.w]} + + + {formatJp(selectedDate.iso)} + + + + + + 出勤 + + + {working.length} + + 人 + + + + + + 希望 + + + {requesting.length} + + 人 + + + + + + 未割当 + + + {unassigned.length} + + 人 + + + + + + + + 希望 + + + + 割当 + + + + + + + + + + {/* Right: staffing meter */} + + + ); +}; + +type TimelineRow = { + staff: (typeof STAFFS)[number]; + shift: ReturnType; +}; + +const TimelineTable = ({ rows, hours }: { rows: TimelineRow[]; hours: number[] }) => { + const NAME_W = 140; + + return ( + + {/* Sticky hour header */} + + + スタッフ + + + {hours.slice(0, -1).map((h, i) => { + const peak = PEAK_BANDS.some((b) => h >= b.startHour && h < b.endHour); + return ( + + {h}時 + + ); + })} + + + 時間 + + + {rows.map((r, idx) => ( + + ))} + + ); +}; + +const StaffRow = ({ + staff, + shift, + nameWidth, + last, +}: { + staff: (typeof STAFFS)[number]; + shift: ReturnType; + nameWidth: number; + last: boolean; +}) => { + const hasReq = !!shift?.req; + const hasAsn = !!shift?.asn; + const mismatch = hasReq && !hasAsn; + const totalHours = shift?.asn ? Number(shift.asn[1].slice(0, 2)) - Number(shift.asn[0].slice(0, 2)) : 0; + + const segments = + shift?.segments ?? (shift?.asn ? [{ positionId: "hall", start: shift.asn[0], end: shift.asn[1] }] : []); + + return ( + + + + + + {staff.name} + + {!hasReq && staff.status === "submitted" && ( + + 休み希望 + + )} + {mismatch && ( + + 未割当 + + )} + + + + {/* Peak band shading */} + {PEAK_BANDS.map((b) => { + const left = ((b.startHour - TIME_RANGE.start) / (TIME_RANGE.end - TIME_RANGE.start)) * 100; + const right = ((b.endHour - TIME_RANGE.start) / (TIME_RANGE.end - TIME_RANGE.start)) * 100; + return ( + + ); + })} + {/* Request (dashed) */} + {hasReq && shift?.req && ( + + )} + {/* Assigned segments colored by position */} + {hasAsn && + segments.map((seg, i) => { + const pos = positionById(seg.positionId); + const leftPct = timeToPct(seg.start); + const widthPct = timeToPct(seg.end) - leftPct; + return ( + + {widthPct > 8 && {pos.name}} + + ); + })} + {/* Assignment time label */} + {hasAsn && shift?.asn && ( + + {shift.asn[0]}〜{shift.asn[1]} + + )} + + + {totalHours > 0 ? ( + + {totalHours}h + + ) : ( + + — + + )} + + + ); +}; + +const StaffingMeter = ({ iso }: { iso: string }) => { + return ( + + + + 時間帯の充足 + + + + + + + + {PEAK_BANDS.map((b) => { + const counts = []; + for (let h = b.startHour; h < b.endHour; h++) counts.push(countWorkingAt(iso, h)); + const min = Math.min(...counts); + const ok = min >= b.required; + return ( + + + + {b.label} + + + {b.startHour}:00–{b.endHour}:00 + + + {ok ? : } + + + + + {min} + + + / {b.required}人 + + + (最小) + + + + ); + })} + + + + 時刻別 + + + {HOURS.slice(0, -1).map((h) => { + const c = countWorkingAt(iso, h); + const r = requiredAt(h); + const ok = c >= r; + const peak = PEAK_BANDS.some((b) => h >= b.startHour && h < b.endHour); + return ( + + + {h}時 + + + + + + {c}/{r} + + + ); + })} + + + ); +}; + +// ---- SP (Pattern A, polished) -------------------------------------------- + +export const DailySP = () => { + const [selected, setSelected] = useState("2026-01-21"); + const selectedDate = DATES.find((d) => d.iso === selected) ?? DATES[0]; + + const rows = STAFFS.filter((s) => s.status !== "not_submitted").map((s) => ({ + staff: s, + shift: shiftOf(s.id, selected), + })); + const working = rows.filter((r) => r.shift?.asn).length; + const satisfied = dayIsSatisfied(selected); + + return ( + + {/* Header summary */} + + + + {formatJp(selectedDate.iso)} + + + ({selectedDate.w}) + + + {satisfied ? "充足 OK" : "要確認"} + + + + 出勤 {working}人・希望 {rows.filter((r) => r.shift?.req).length}人 + + + + {/* Day ribbon */} + + + {DATES.map((d) => { + const active = d.iso === selected; + const ok = dayIsSatisfied(d.iso); + return ( + setSelected(d.iso)} + flexShrink={0} + w="48px" + py="8px" + textAlign="center" + borderRadius="10px" + bg={active ? "teal.50" : "white"} + borderWidth="1px" + borderColor={active ? "teal.400" : "gray.200"} + cursor="pointer" + > + + {d.w} + + + {d.dd ?? d.d.split("/")[1]} + + + + + + ); + })} + + + + {/* Peak band summary */} + + {PEAK_BANDS.map((b) => { + const counts = []; + for (let h = b.startHour; h < b.endHour; h++) counts.push(countWorkingAt(selected, h)); + const min = Math.min(...counts); + const ok = min >= b.required; + return ( + + + + {b.label} + + + {ok ? : } + + + + + {min} + + + /{b.required}人 + + + + {b.startHour}:00–{b.endHour}:00 + + + ); + })} + + + {/* Cards */} + + {rows.map(({ staff, shift }) => { + const req = shift?.req; + const asn = shift?.asn; + const mismatch = req && !asn; + const segments = shift?.segments ?? (asn ? [{ positionId: "hall", start: asn[0], end: asn[1] }] : []); + return ( + + + + + {staff.name} + + + {req ? `希望 ${req[0]}〜${req[1]}` : "休み希望"} + + + + + + {req ? ( + + {/* peak shading */} + {PEAK_BANDS.map((b) => { + const left = ((b.startHour - TIME_RANGE.start) / (TIME_RANGE.end - TIME_RANGE.start)) * 100; + const right = ((b.endHour - TIME_RANGE.start) / (TIME_RANGE.end - TIME_RANGE.start)) * 100; + return ( + + ); + })} + + {segments.map((seg, i) => { + const pos = positionById(seg.positionId); + const left = timeToPct(seg.start); + const width = timeToPct(seg.end) - left; + return ( + + {width > 12 ? pos.name : ""} + + ); + })} + {asn && ( + + {asn[0]}〜{asn[1]} + + )} + + ) : ( + + この日は休み + + )} + {mismatch && ( + + + 希望あり・未割当 + + )} + + ); + })} + + + ); +}; + +export const unsubmittedNames = () => unsubmittedStaff().map((s) => s.name); diff --git a/src/components/mocks/ShiftForm/ListView.tsx b/src/components/mocks/ShiftForm/ListView.tsx new file mode 100644 index 00000000..90d62264 --- /dev/null +++ b/src/components/mocks/ShiftForm/ListView.tsx @@ -0,0 +1,754 @@ +import { Box, Flex } from "@chakra-ui/react"; +import { useMemo, useState } from "react"; +import { LuCalendar, LuChevronLeft, LuChevronRight } from "react-icons/lu"; +import { buildMonthDates, dowColor, type MonthDate, monthShiftOf, STAFFS } from "./mockData"; +import { Avatar } from "./parts"; + +type Period = "1w" | "2w" | "1m"; +const periodWeeks: Record = { "1w": 1, "2w": 2, "1m": 4 }; + +const shiftHours = (asn: [string, string] | null) => + !asn ? 0 : Number(asn[1].slice(0, 2)) - Number(asn[0].slice(0, 2)); + +// ---- PC (L1b) ----------------------------------------------------------- + +export const ListPC = () => { + const [period, setPeriod] = useState("1m"); + const dates = useMemo(() => buildMonthDates(periodWeeks[period]), [period]); + const weekCount = Math.ceil(dates.length / 7); + + const initialOpen = () => { + const o: Record = {}; + for (let i = 0; i < weekCount; i++) o[i] = i < 2; + return o; + }; + const [open, setOpen] = useState>(initialOpen); + + const periodLabel = period === "1w" ? "1/5 – 1/11" : period === "2w" ? "1/5 – 1/18" : "2026年 1月"; + const toggleAll = (v: boolean) => { + const o: Record = {}; + for (let i = 0; i < weekCount; i++) o[i] = v; + setOpen(o); + }; + + return ( + + + + + + + + + + {weekCount > 1 && ( + + {weekCount}週間 + + + + )} + + {Array.from({ length: weekCount }).map((_, wi) => { + const wkDates = dates.filter((d) => d.weekIdx === wi); + const isOpen = !!open[wi]; + const isCurrent = wi === 0; + return ( + setOpen({ ...open, [wi]: !isOpen })} + /> + ); + })} + + + ); +}; + +const DateNav = ({ label }: { label: string }) => ( + + + + + {label} + + + +); + +const PeriodSwitcher = ({ period, setPeriod }: { period: Period; setPeriod: (p: Period) => void }) => { + const options: { k: Period; label: string }[] = [ + { k: "1w", label: "1週" }, + { k: "2w", label: "2週" }, + { k: "1m", label: "1ヶ月" }, + ]; + return ( + + {options.map((o, i) => { + const active = period === o.k; + return ( + + ); + })} + + ); +}; + +const ModeTabs = ({ active }: { active: "L1a" | "L1b" | "L1c" }) => { + const modes = [ + { k: "L1a", l: "週表" }, + { k: "L1b", l: "週折" }, + { k: "L1c", l: "密度" }, + ] as const; + return ( + + {modes.map((m) => { + const a = active === m.k; + return ( + + {m.l} + + ); + })} + + ); +}; + +const WeekCard = ({ + wi, + wkDates, + isOpen, + isCurrent, + onToggle, +}: { + wi: number; + wkDates: MonthDate[]; + isOpen: boolean; + isCurrent: boolean; + onToggle: () => void; +}) => { + let weekHours = 0; + let weekSlots = 0; + const dayCounts = wkDates.map((_, i) => STAFFS.filter((s) => monthShiftOf(s.id, wi * 7 + i)).length); + STAFFS.forEach((s) => { + wkDates.forEach((_, i) => { + const asn = monthShiftOf(s.id, wi * 7 + i); + if (asn) { + weekHours += shiftHours(asn); + weekSlots += 1; + } + }); + }); + const activeStaff = STAFFS.filter((s) => wkDates.some((_, i) => monthShiftOf(s.id, wi * 7 + i))).length; + + return ( + + + + {isOpen ? "▾" : "▸"} + + + + + Week {wi + 1} + + {isCurrent && ( + + 今週 + + )} + + + {wkDates[0].d} – {wkDates[wkDates.length - 1].d}({wkDates.length}日) + + + + {!isOpen && ( + + {dayCounts.map((c, i) => { + const max = Math.max(...dayCounts, 1); + return ( + + + + {wkDates[i].w} + + + ); + })} + + )} + + + + + 合計時間 + + + {weekHours} + + h + + + + + + 出勤 + + + {weekSlots} + + コマ + + + + + + 稼働スタッフ + + + {activeStaff} + + 人 + + + + + + + {isOpen && } + + ); +}; + +const WeekTable = ({ wi, wkDates, weekHours }: { wi: number; wkDates: MonthDate[]; weekHours: number }) => { + return ( + + + + + + スタッフ + + {wkDates.map((d) => ( + + + {d.w} + + + {d.d} + + + ))} + + 計 + + + + + {STAFFS.map((s) => { + let total = 0; + const isUnsub = s.status === "not_submitted"; + return ( + + + + + + + {s.name} + + {isUnsub && ( + + 未提出 + + )} + + + + {wkDates.map((d, i) => { + const asn = monthShiftOf(s.id, wi * 7 + i); + if (asn) total += shiftHours(asn); + return ( + + {asn ? ( + + {asn[0]}–{asn[1]} + + ) : ( + + — + + )} + + ); + })} + + {total ? `${total}h` : "—"} + + + ); + })} + + + + + 出勤人数 + + {wkDates.map((d, i) => { + const n = STAFFS.filter((s) => monthShiftOf(s.id, wi * 7 + i)).length; + return ( + + {n}人 + + ); + })} + + {weekHours}h + + + + + + ); +}; + +// ---- SP (L1b) ----------------------------------------------------------- + +export const ListSP = () => { + const [period, setPeriod] = useState("1m"); + const dates = useMemo(() => buildMonthDates(periodWeeks[period]), [period]); + const weekCount = Math.ceil(dates.length / 7); + const [open, setOpen] = useState>({ 0: true }); + + const periodLabel = period === "1w" ? "1/5 – 1/11" : period === "2w" ? "1/5 – 1/18" : "2026年 1月"; + + return ( + + + + + + {periodLabel} + + + + + + + + + + + {Array.from({ length: weekCount }).map((_, wi) => { + const wkDates = dates.filter((d) => d.weekIdx === wi); + const isOpen = !!open[wi]; + const isCurrent = wi === 0; + return ( + setOpen({ ...open, [wi]: !isOpen })} + /> + ); + })} + + + ); +}; + +const WeekCardSP = ({ + wi, + wkDates, + isOpen, + isCurrent, + onToggle, +}: { + wi: number; + wkDates: MonthDate[]; + isOpen: boolean; + isCurrent: boolean; + onToggle: () => void; +}) => { + let weekHours = 0; + let weekSlots = 0; + STAFFS.forEach((s) => { + wkDates.forEach((_, i) => { + const asn = monthShiftOf(s.id, wi * 7 + i); + if (asn) { + weekHours += shiftHours(asn); + weekSlots += 1; + } + }); + }); + + return ( + + + + {isOpen ? "▾" : "▸"} + + + + + Week {wi + 1} + + {isCurrent && ( + + 今週 + + )} + + + {wkDates[0].d} – {wkDates[wkDates.length - 1].d} + + + + + {weekHours}h + + + {weekSlots}コマ + + + + + {isOpen && ( + + {wkDates.map((d, i) => { + const working = STAFFS.filter((s) => monthShiftOf(s.id, wi * 7 + i)); + return ( + 0 ? "1px" : "0"} + borderColor="gray.100" + align="flex-start" + > + + + {d.w} + + + {d.d} + + + + {working.length > 0 ? ( + + {working.map((s) => { + const asn = monthShiftOf(s.id, wi * 7 + i); + return ( + + + + {s.name} + + + {asn?.[0]}–{asn?.[1]} + + + ); + })} + + ) : ( + + 出勤なし + + )} + + + ); + })} + + )} + + ); +}; diff --git a/src/components/mocks/ShiftForm/index.stories.tsx b/src/components/mocks/ShiftForm/index.stories.tsx new file mode 100644 index 00000000..4b4fb1c3 --- /dev/null +++ b/src/components/mocks/ShiftForm/index.stories.tsx @@ -0,0 +1,114 @@ +import { Box, Flex } from "@chakra-ui/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { DailyPC, DailySP, unsubmittedNames } from "./DailyView"; +import { ListPC, ListSP } from "./ListView"; +import { DATES } from "./mockData"; +import { + AppHeader, + AppHeaderSP, + FilterButton, + FrameBox, + HeaderActions, + PageTabs, + type TabKey, + UnsubmittedStrip, + UnsubmittedStripSP, +} from "./parts"; + +const rangeLabel = `${DATES[0].d}(${DATES[0].w}) – ${DATES[DATES.length - 1].d}(${DATES[DATES.length - 1].w})`; + +const PCApp = ({ initialTab = "daily" as TabKey }: { initialTab?: TabKey }) => { + const [tab, setTab] = useState(initialTab); + return ( + + + + + シフト作成 + + + {rangeLabel} + + + 下書き + + + + + + + + + {tab === "daily" ? : } + + {tab === "daily" && } + + ); +}; + +const SPApp = ({ initialTab = "daily" as TabKey }: { initialTab?: TabKey }) => { + const [tab, setTab] = useState(initialTab); + return ( + + + + + {rangeLabel} + + + + + + + + {tab === "daily" ? : } + + {tab === "daily" && } + + ); +}; + +const meta = { + title: "Mocks/ShiftForm", + parameters: { layout: "fullscreen" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const PC_Daily: Story = { render: () => }; +export const PC_List: Story = { render: () => }; +export const SP_Daily: Story = { + render: () => , + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; +export const SP_List: Story = { + render: () => , + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; diff --git a/src/components/mocks/ShiftForm/mockData.ts b/src/components/mocks/ShiftForm/mockData.ts new file mode 100644 index 00000000..05665aa3 --- /dev/null +++ b/src/components/mocks/ShiftForm/mockData.ts @@ -0,0 +1,278 @@ +import dayjs from "dayjs"; + +export type StaffStatus = "submitted" | "no_request" | "not_submitted"; + +export type MockStaff = { + id: string; + name: string; + status: StaffStatus; +}; + +export type PositionSegment = { + positionId: string; + start: string; + end: string; +}; + +export type ShiftEntry = { + req: [string, string] | null; + asn: [string, string] | null; + segments?: PositionSegment[]; +}; + +export type MockPosition = { + id: string; + name: string; + color: string; + tint: string; +}; + +export const POSITIONS: MockPosition[] = [ + { id: "hall", name: "ホール", color: "#14b8a6", tint: "#99f6e4" }, + { id: "kitchen", name: "キッチン", color: "#f59e0b", tint: "#fde68a" }, + { id: "register", name: "レジ", color: "#6366f1", tint: "#c7d2fe" }, +]; + +export const TIME_RANGE = { start: 9, end: 22 }; + +export type DateRow = { + iso: string; + d: string; + dd: number; + w: string; + sat?: boolean; + sun?: boolean; +}; + +export const DATES: DateRow[] = [ + { iso: "2026-01-20", d: "1/20", dd: 20, w: "火" }, + { iso: "2026-01-21", d: "1/21", dd: 21, w: "水" }, + { iso: "2026-01-22", d: "1/22", dd: 22, w: "木" }, + { iso: "2026-01-23", d: "1/23", dd: 23, w: "金" }, + { iso: "2026-01-24", d: "1/24", dd: 24, w: "土", sat: true }, + { iso: "2026-01-25", d: "1/25", dd: 25, w: "日", sun: true }, + { iso: "2026-01-26", d: "1/26", dd: 26, w: "月" }, +]; + +export const STAFFS: MockStaff[] = [ + { id: "s1", name: "鈴木太郎", status: "submitted" }, + { id: "s2", name: "佐藤花子", status: "submitted" }, + { id: "s3", name: "田中次郎", status: "not_submitted" }, + { id: "s4", name: "山田美咲", status: "submitted" }, + { id: "s5", name: "高橋翔太", status: "submitted" }, + { id: "s6", name: "渡辺優子", status: "submitted" }, + { id: "s7", name: "伊藤健一", status: "no_request" }, + { id: "s8", name: "中村真理", status: "submitted" }, + { id: "s9", name: "小林大輔", status: "not_submitted" }, + { id: "s10", name: "加藤美穂", status: "submitted" }, +]; + +const s = (start: string, end: string, segments?: PositionSegment[]): ShiftEntry => ({ + req: [start, end], + asn: [start, end], + segments, +}); +const reqOnly = (start: string, end: string): ShiftEntry => ({ req: [start, end], asn: null }); +const off = (): ShiftEntry => ({ req: null, asn: null }); + +export const SHIFTS: Record> = { + s1: { + "2026-01-20": s("10:00", "18:00", [ + { positionId: "hall", start: "10:00", end: "14:00" }, + { positionId: "register", start: "14:00", end: "18:00" }, + ]), + "2026-01-21": s("10:00", "18:00", [ + { positionId: "hall", start: "10:00", end: "13:00" }, + { positionId: "register", start: "13:00", end: "18:00" }, + ]), + "2026-01-22": s("10:00", "18:00", [{ positionId: "hall", start: "10:00", end: "18:00" }]), + "2026-01-23": s("10:00", "14:00", [{ positionId: "hall", start: "10:00", end: "14:00" }]), + "2026-01-24": s("10:00", "18:00", [ + { positionId: "hall", start: "10:00", end: "14:00" }, + { positionId: "register", start: "14:00", end: "18:00" }, + ]), + "2026-01-25": off(), + "2026-01-26": off(), + }, + s2: { + "2026-01-20": off(), + "2026-01-21": s("11:00", "19:00", [{ positionId: "kitchen", start: "11:00", end: "19:00" }]), + "2026-01-22": s("11:00", "19:00", [{ positionId: "kitchen", start: "11:00", end: "19:00" }]), + "2026-01-23": off(), + "2026-01-24": s("11:00", "19:00", [{ positionId: "kitchen", start: "11:00", end: "19:00" }]), + "2026-01-25": off(), + "2026-01-26": s("11:00", "19:00", [{ positionId: "kitchen", start: "11:00", end: "19:00" }]), + }, + s3: {}, + s4: { + "2026-01-20": s("14:00", "21:00", [{ positionId: "hall", start: "14:00", end: "21:00" }]), + "2026-01-21": off(), + "2026-01-22": s("14:00", "21:00", [{ positionId: "hall", start: "14:00", end: "21:00" }]), + "2026-01-23": s("14:00", "21:00", [ + { positionId: "hall", start: "14:00", end: "18:00" }, + { positionId: "register", start: "18:00", end: "21:00" }, + ]), + "2026-01-24": s("14:00", "21:00", [{ positionId: "hall", start: "14:00", end: "21:00" }]), + "2026-01-25": off(), + "2026-01-26": s("14:00", "21:00", [{ positionId: "hall", start: "14:00", end: "21:00" }]), + }, + s5: { + "2026-01-20": s("10:00", "15:00", [{ positionId: "register", start: "10:00", end: "15:00" }]), + "2026-01-21": s("10:00", "15:00", [{ positionId: "register", start: "10:00", end: "15:00" }]), + "2026-01-22": off(), + "2026-01-23": s("10:00", "15:00", [{ positionId: "register", start: "10:00", end: "15:00" }]), + "2026-01-24": reqOnly("10:00", "15:00"), + "2026-01-25": off(), + "2026-01-26": off(), + }, + s6: { + "2026-01-20": s("09:00", "17:00", [ + { positionId: "kitchen", start: "09:00", end: "13:00" }, + { positionId: "hall", start: "13:00", end: "17:00" }, + ]), + "2026-01-21": off(), + "2026-01-22": s("09:00", "17:00", [{ positionId: "kitchen", start: "09:00", end: "17:00" }]), + "2026-01-23": off(), + "2026-01-24": s("09:00", "17:00", [{ positionId: "kitchen", start: "09:00", end: "17:00" }]), + "2026-01-25": off(), + "2026-01-26": off(), + }, + s7: {}, + s8: { + "2026-01-20": s("10:00", "16:00", [{ positionId: "hall", start: "10:00", end: "16:00" }]), + "2026-01-21": s("10:00", "16:00", [{ positionId: "hall", start: "10:00", end: "16:00" }]), + "2026-01-22": s("10:00", "16:00", [{ positionId: "hall", start: "10:00", end: "16:00" }]), + "2026-01-23": s("10:00", "16:00", [{ positionId: "hall", start: "10:00", end: "16:00" }]), + "2026-01-24": s("10:00", "16:00", [{ positionId: "hall", start: "10:00", end: "16:00" }]), + "2026-01-25": off(), + "2026-01-26": off(), + }, + s9: {}, + s10: { + "2026-01-20": s("11:00", "18:00", [{ positionId: "register", start: "11:00", end: "18:00" }]), + "2026-01-21": off(), + "2026-01-22": s("11:00", "18:00", [{ positionId: "register", start: "11:00", end: "18:00" }]), + "2026-01-23": off(), + "2026-01-24": s("11:00", "18:00", [{ positionId: "register", start: "11:00", end: "18:00" }]), + "2026-01-25": off(), + "2026-01-26": s("11:00", "18:00", [{ positionId: "register", start: "11:00", end: "18:00" }]), + }, +}; + +export const PEAK_BANDS = [ + { startHour: 12, endHour: 14, label: "ランチ", required: 4 }, + { startHour: 18, endHour: 21, label: "ディナー", required: 4 }, +]; + +export function requiredAt(hour: number): number { + for (const b of PEAK_BANDS) if (hour >= b.startHour && hour < b.endHour) return b.required; + return 2; +} + +export function timeToPct(hhmm: string): number { + const [h, m] = hhmm.split(":").map(Number); + const total = TIME_RANGE.end - TIME_RANGE.start; + return ((h + m / 60 - TIME_RANGE.start) / total) * 100; +} + +export function minutesBetween(a: string, b: string): number { + const [ah, am] = a.split(":").map(Number); + const [bh, bm] = b.split(":").map(Number); + return bh * 60 + bm - (ah * 60 + am); +} + +export function hoursBetween(a: string, b: string): number { + return minutesBetween(a, b) / 60; +} + +export function shiftOf(staffId: string, iso: string): ShiftEntry | null { + return SHIFTS[staffId]?.[iso] ?? null; +} + +export function countWorkingAt(iso: string, hour: number): number { + let n = 0; + for (const sid of Object.keys(SHIFTS)) { + const sh = SHIFTS[sid][iso]; + if (!sh?.asn) continue; + const [s1] = sh.asn[0].split(":").map(Number); + const [e1] = sh.asn[1].split(":").map(Number); + if (hour >= s1 && hour < e1) n += 1; + } + return n; +} + +export function dayIsSatisfied(iso: string): boolean { + for (let h = TIME_RANGE.start; h < TIME_RANGE.end; h++) { + if (countWorkingAt(iso, h) < requiredAt(h)) return false; + } + return true; +} + +export function positionById(id: string): MockPosition { + return POSITIONS.find((p) => p.id === id) ?? POSITIONS[0]; +} + +export function dowColor(date: DateRow): string { + if (date.sat) return "#2563eb"; + if (date.sun) return "#dc2626"; + return "#3f3f46"; +} + +export function workingStaffFor(iso: string) { + return STAFFS.filter((st) => SHIFTS[st.id]?.[iso]?.asn); +} + +export function unsubmittedStaff() { + return STAFFS.filter((st) => st.status === "not_submitted"); +} + +// ---- Month (L1b) dataset --------------------------------------------------- +// 4 weeks starting Mon 2026-01-05, derived deterministically from STAFFS. + +export type MonthDate = { + iso: string; + d: string; + w: string; + dd: number; + weekIdx: number; + sat: boolean; + sun: boolean; +}; + +const WK = ["日", "月", "火", "水", "木", "金", "土"]; + +export function buildMonthDates(weeks: number): MonthDate[] { + const start = dayjs("2026-01-05"); + const out: MonthDate[] = []; + for (let i = 0; i < weeks * 7; i++) { + const d = start.add(i, "day"); + const dow = d.day(); + out.push({ + iso: d.format("YYYY-MM-DD"), + d: `${d.month() + 1}/${d.date()}`, + w: WK[dow], + dd: d.date(), + weekIdx: Math.floor(i / 7), + sat: dow === 6, + sun: dow === 0, + }); + } + return out; +} + +export function monthShiftOf(staffId: string, dayIdx: number): [string, string] | null { + const staff = STAFFS.find((st) => st.id === staffId); + if (!staff || staff.status === "not_submitted") return null; + const num = Number.parseInt(staffId.slice(1), 10); + const seed = (num * 7 + dayIdx) % 11; + if (seed < 3) return null; + const starts: [string, string][] = [ + ["09:00", "15:00"], + ["10:00", "18:00"], + ["11:00", "19:00"], + ["14:00", "21:00"], + ["13:00", "20:00"], + ]; + const idx = (num + dayIdx) % starts.length; + return starts[idx]; +} diff --git a/src/components/mocks/ShiftForm/parts.tsx b/src/components/mocks/ShiftForm/parts.tsx new file mode 100644 index 00000000..0c5e3f75 --- /dev/null +++ b/src/components/mocks/ShiftForm/parts.tsx @@ -0,0 +1,304 @@ +import { Box, Flex } from "@chakra-ui/react"; +import type { ReactNode } from "react"; +import { LuBell, LuChevronDown, LuDownload, LuFilter, LuSave } from "react-icons/lu"; + +export const Avatar = ({ name, size = 28 }: { name: string; size?: number }) => { + const hue = Math.abs(name.charCodeAt(0) * 13 + name.charCodeAt(1 % name.length) * 7) % 360; + const bg = `oklch(0.94 0.04 ${hue})`; + const fg = `oklch(0.42 0.12 ${hue})`; + return ( + + {name.slice(0, 1)} + + ); +}; + +export const AppHeader = ({ shopName = "カフェ モカ 渋谷店" }: { shopName?: string }) => ( + + + シフトリ + + + + {shopName} + + + + + + + 店 + + + +); + +export const AppHeaderSP = ({ shopName = "カフェ モカ" }: { shopName?: string }) => ( + + + シフトリ + + + {shopName} + + +); + +export type TabKey = "daily" | "list"; + +export const PageTabs = ({ + active, + onChange, + compact = false, +}: { + active: TabKey; + onChange: (k: TabKey) => void; + compact?: boolean; +}) => { + const tabs: { k: TabKey; label: string }[] = [ + { k: "daily", label: "割当" }, + { k: "list", label: "一覧" }, + ]; + return ( + + {tabs.map((t) => { + const is = active === t.k; + return ( + onChange(t.k)} + cursor="pointer" + flex={compact ? 1 : "none"} + textAlign="center" + px={compact ? 0 : 6} + py={compact ? "11px" : "13px"} + fontSize={compact ? "13px" : "13px"} + fontWeight={is ? 700 : 500} + color={is ? "teal.700" : "gray.500"} + borderBottomWidth="2px" + borderColor={is ? "teal.600" : "transparent"} + mb="-1px" + transition="color 120ms" + > + {t.label} + + ); + })} + + ); +}; + +type ActionsProps = { + compact?: boolean; + onSave?: () => void; + onConfirm?: () => void; + onExport?: () => void; +}; + +export const HeaderActions = ({ + compact = false, + onSave = () => {}, + onConfirm = () => {}, + onExport = () => {}, +}: ActionsProps) => ( + + + + + +); + +export const FilterButton = ({ compact = false }: { compact?: boolean }) => ( + +); + +export const UnsubmittedStrip = ({ names }: { names: string[] }) => { + if (!names.length) return null; + return ( + + + 未提出 {names.length}人 + + + {names.map((n) => ( + + {n} + + ))} + + + + ); +}; + +export const UnsubmittedStripSP = ({ names }: { names: string[] }) => { + if (!names.length) return null; + return ( + + + + 未提出 {names.length}人 + + + タップで催促 + + + › + + + ); +}; + +export const FrameBox = ({ children }: { children: ReactNode }) => ( + + {children} + +); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 84ce9ed8..1f7d32ea 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as UnregisteredShiftsViewRouteImport } from './routes/_unregister import { Route as UnregisteredShiftsSubmitRouteImport } from './routes/_unregistered/shifts.submit' import { Route as UnregisteredShiftsReissueRouteImport } from './routes/_unregistered/shifts.reissue' import { Route as AuthShiftboardRecruitmentIdRouteImport } from './routes/_auth/shiftboard.$recruitmentId' +import { Route as UnregisteredShiftsSubmitCompletedRouteImport } from './routes/_unregistered/shifts.submit_.completed' const TermsRoute = TermsRouteImport.update({ id: '/terms', @@ -77,6 +78,12 @@ const AuthShiftboardRecruitmentIdRoute = path: '/shiftboard/$recruitmentId', getParentRoute: () => AuthRoute, } as any) +const UnregisteredShiftsSubmitCompletedRoute = + UnregisteredShiftsSubmitCompletedRouteImport.update({ + id: '/shifts/submit_/completed', + path: '/shifts/submit/completed', + getParentRoute: () => UnregisteredRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -88,6 +95,7 @@ export interface FileRoutesByFullPath { '/shifts/reissue': typeof UnregisteredShiftsReissueRoute '/shifts/submit': typeof UnregisteredShiftsSubmitRoute '/shifts/view': typeof UnregisteredShiftsViewRoute + '/shifts/submit/completed': typeof UnregisteredShiftsSubmitCompletedRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -99,6 +107,7 @@ export interface FileRoutesByTo { '/shifts/reissue': typeof UnregisteredShiftsReissueRoute '/shifts/submit': typeof UnregisteredShiftsSubmitRoute '/shifts/view': typeof UnregisteredShiftsViewRoute + '/shifts/submit/completed': typeof UnregisteredShiftsSubmitCompletedRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -113,6 +122,7 @@ export interface FileRoutesById { '/_unregistered/shifts/reissue': typeof UnregisteredShiftsReissueRoute '/_unregistered/shifts/submit': typeof UnregisteredShiftsSubmitRoute '/_unregistered/shifts/view': typeof UnregisteredShiftsViewRoute + '/_unregistered/shifts/submit_/completed': typeof UnregisteredShiftsSubmitCompletedRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -126,6 +136,7 @@ export interface FileRouteTypes { | '/shifts/reissue' | '/shifts/submit' | '/shifts/view' + | '/shifts/submit/completed' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -137,6 +148,7 @@ export interface FileRouteTypes { | '/shifts/reissue' | '/shifts/submit' | '/shifts/view' + | '/shifts/submit/completed' id: | '__root__' | '/' @@ -150,6 +162,7 @@ export interface FileRouteTypes { | '/_unregistered/shifts/reissue' | '/_unregistered/shifts/submit' | '/_unregistered/shifts/view' + | '/_unregistered/shifts/submit_/completed' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -239,6 +252,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthShiftboardRecruitmentIdRouteImport parentRoute: typeof AuthRoute } + '/_unregistered/shifts/submit_/completed': { + id: '/_unregistered/shifts/submit_/completed' + path: '/shifts/submit/completed' + fullPath: '/shifts/submit/completed' + preLoaderRoute: typeof UnregisteredShiftsSubmitCompletedRouteImport + parentRoute: typeof UnregisteredRoute + } } } @@ -259,6 +279,7 @@ interface UnregisteredRouteChildren { UnregisteredShiftsReissueRoute: typeof UnregisteredShiftsReissueRoute UnregisteredShiftsSubmitRoute: typeof UnregisteredShiftsSubmitRoute UnregisteredShiftsViewRoute: typeof UnregisteredShiftsViewRoute + UnregisteredShiftsSubmitCompletedRoute: typeof UnregisteredShiftsSubmitCompletedRoute } const UnregisteredRouteChildren: UnregisteredRouteChildren = { @@ -266,6 +287,8 @@ const UnregisteredRouteChildren: UnregisteredRouteChildren = { UnregisteredShiftsReissueRoute: UnregisteredShiftsReissueRoute, UnregisteredShiftsSubmitRoute: UnregisteredShiftsSubmitRoute, UnregisteredShiftsViewRoute: UnregisteredShiftsViewRoute, + UnregisteredShiftsSubmitCompletedRoute: + UnregisteredShiftsSubmitCompletedRoute, } const UnregisteredRouteWithChildren = UnregisteredRoute._addFileChildren( diff --git a/src/routes/_unregistered/shifts.submit.tsx b/src/routes/_unregistered/shifts.submit.tsx index 1ef794f2..238f800c 100644 --- a/src/routes/_unregistered/shifts.submit.tsx +++ b/src/routes/_unregistered/shifts.submit.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -44,6 +44,7 @@ function ShiftSubmitRoute() { } function ShiftSubmitContent({ session }: { session: { sessionToken: string; recruitmentId: string } }) { + const navigate = useNavigate(); const data = useQuery(api.shiftSubmission.queries.getSubmissionPageData, { sessionToken: session.sessionToken, recruitmentId: session.recruitmentId as Id<"recruitments">, @@ -62,6 +63,7 @@ function ShiftSubmitContent({ session }: { session: { sessionToken: string; recr recruitmentId: session.recruitmentId as Id<"recruitments">, requests, }); + await navigate({ to: "/shifts/submit/completed" }); }; return ; diff --git a/src/routes/_unregistered/shifts.submit_.completed.tsx b/src/routes/_unregistered/shifts.submit_.completed.tsx new file mode 100644 index 00000000..b2c61cb1 --- /dev/null +++ b/src/routes/_unregistered/shifts.submit_.completed.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SubmittedView } from "@/src/components/features/StaffSubmit/SubmittedView"; +import { buildMeta } from "@/src/helpers/seo"; + +export const Route = createFileRoute("/_unregistered/shifts/submit_/completed")({ + head: () => ({ + meta: buildMeta({ title: "シフト希望の提出完了", noindex: true }), + }), + component: ShiftSubmitCompletedRoute, +}); + +function ShiftSubmitCompletedRoute() { + return ; +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 4ce55032..84e9bc0c 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -10,7 +10,7 @@ export const Route = createFileRoute("/")({ ...buildMeta({ title: "シフトリ", description: - "少人数のお店のシフト作成をもっとラクに リンクを送るだけで希望シフトを収集 スタッフのアカウント登録も不要 無料ではじめられるシフト管理ツール", + "少人数のお店のシフト作成をもっとラクに。リンクを送るだけで希望シフトを収集、スタッフのアカウント登録も不要、無料ではじめられるシフト管理ツール", canonical: "/", }), ...jsonLdMeta({