Skip to content

Commit 0c1884d

Browse files
yn1323claude
andauthored
feat: スタッフ用シフト提出ページ + 募集メール送信機能 (#267)
* fix: スタッフ一覧のマネージャーバッジを上下中央寄せ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: シフト募集CREATE バックエンド実装 recruitmentsテーブル追加・create mutation作成・フロントエンド接続 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: スタッフ詳細ページの重複表示を解消しレイアウト修正 Title内の重複アバター・名前を削除し、編集ボタンをStaffDetailContentの ヘッダー行(アバター+名前の右端)に移動。スキル表示をチップスタイルに変更。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: シフト管理画面をDB接続(モックデータ削除) recruitment queries作成・ShiftsPageでuseQueryに切り替え Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: シフト募集一覧を開始日の降順ソートに変更・募集詳細UIリファクタ - recruitmentsテーブルにby_shop_and_startDateインデックス追加 - listByShopクエリでバックエンド側降順ソートを実装 - フロントエンドの冗長なソート処理を削除 - RecruitmentDetailのレイアウトをTitle/Card/Animation構成に統一 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: シフト募集詳細レイアウト改善・テーブル背景色被り修正 - RecruitmentDetailにContainer/Title/Animation標準パターンを適用 - ボタンを「締切・編集へ」1つに統合(LuPencilLine + teal) - テーブル構造要素のbg="gray.50"→"white"に変更(ページ背景との境界明確化) - ShiftForm内部のpx={4}を削除しpadding責務を呼び出し側に移動 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: シフト募集開始時のメール送信機能を追加 Resend + Convex Action で募集作成時にスタッフへメール通知する基盤を構築。 - convex/email/actions.ts: Resend APIによるメール送信internalAction - convex/recruitment/mutations.ts: magicLink生成・メール送信スケジュール追加 - scripts/setupEnv.ts: .envからConvex環境変数を一括設定するスクリプト Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: implement shift submit page flow and UX copy * feat: ShiftSubmit関連コンポーネントのStorybook追加 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Storybookから未インストールの@storybook/test依存を削除 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c26053a commit 0c1884d

File tree

44 files changed

+2070
-342
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2070
-342
lines changed

.vscode/tasks.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@
7171
"panel": "new"
7272
}
7373
},
74+
{
75+
"label": "Convex環境変数セットアップ",
76+
"type": "shell",
77+
"command": "pnpm convex:env:setup",
78+
"problemMatcher": [],
79+
"presentation": {
80+
"reveal": "always",
81+
"panel": "dedicated"
82+
}
83+
},
7484
{
7585
"label": "difit",
7686
"type": "shell",

convex/_generated/api.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@
99
*/
1010

1111
import type * as constants from "../constants.js";
12+
import type * as email_actions from "../email/actions.js";
1213
import type * as helpers from "../helpers.js";
1314
import type * as invite_mutations from "../invite/mutations.js";
1415
import type * as invite_queries from "../invite/queries.js";
1516
import type * as position_mutations from "../position/mutations.js";
1617
import type * as position_queries from "../position/queries.js";
18+
import type * as recruitment_mutations from "../recruitment/mutations.js";
19+
import type * as recruitment_queries from "../recruitment/queries.js";
1720
import type * as requiredStaffing_mutations from "../requiredStaffing/mutations.js";
1821
import type * as requiredStaffing_queries from "../requiredStaffing/queries.js";
22+
import type * as shiftRequest_mutations from "../shiftRequest/mutations.js";
23+
import type * as shiftRequest_queries from "../shiftRequest/queries.js";
1924
import type * as shop_mutations from "../shop/mutations.js";
2025
import type * as shop_queries from "../shop/queries.js";
2126
import type * as staffSkill_mutations from "../staffSkill/mutations.js";
@@ -32,13 +37,18 @@ import type {
3237

3338
declare const fullApi: ApiFromModules<{
3439
constants: typeof constants;
40+
"email/actions": typeof email_actions;
3541
helpers: typeof helpers;
3642
"invite/mutations": typeof invite_mutations;
3743
"invite/queries": typeof invite_queries;
3844
"position/mutations": typeof position_mutations;
3945
"position/queries": typeof position_queries;
46+
"recruitment/mutations": typeof recruitment_mutations;
47+
"recruitment/queries": typeof recruitment_queries;
4048
"requiredStaffing/mutations": typeof requiredStaffing_mutations;
4149
"requiredStaffing/queries": typeof requiredStaffing_queries;
50+
"shiftRequest/mutations": typeof shiftRequest_mutations;
51+
"shiftRequest/queries": typeof shiftRequest_queries;
4252
"shop/mutations": typeof shop_mutations;
4353
"shop/queries": typeof shop_queries;
4454
"staffSkill/mutations": typeof staffSkill_mutations;

convex/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ export type StaffRoleType = (typeof STAFF_ROLES)[number];
3333
// 招待関連
3434
export const INVITE_EXPIRY_DAYS = 14;
3535
export const INVITE_EXPIRY_MS = INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
36+
37+
// 募集ステータス定義
38+
export const RECRUITMENT_STATUS = ["open", "closed", "confirmed"] as const;
39+
export type RecruitmentStatusType = (typeof RECRUITMENT_STATUS)[number];

convex/email/actions.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* メール送信ドメイン - アクション(外部API呼び出し)
3+
*
4+
* 責務:
5+
* - Resend APIを使用したメール送信
6+
* - シフト募集通知メールの送信
7+
*/
8+
import { v } from "convex/values";
9+
import { Resend } from "resend";
10+
import { internalAction } from "../_generated/server";
11+
12+
const FROM_EMAIL = "onboarding@resend.dev";
13+
14+
// シフト募集通知メール送信
15+
export const sendRecruitmentNotification = internalAction({
16+
args: {
17+
shopName: v.string(),
18+
startDate: v.string(),
19+
endDate: v.string(),
20+
deadline: v.string(),
21+
recipients: v.array(
22+
v.object({
23+
email: v.string(),
24+
magicLinkToken: v.string(),
25+
}),
26+
),
27+
},
28+
handler: async (_ctx, args) => {
29+
const apiKey = process.env.RESEND_API_KEY;
30+
if (!apiKey) {
31+
console.error("RESEND_API_KEY が設定されていません");
32+
return;
33+
}
34+
35+
const resend = new Resend(apiKey);
36+
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
37+
38+
const subject = `【${args.shopName}】シフト募集のお知らせ(${args.startDate}${args.endDate})`;
39+
40+
for (const recipient of args.recipients) {
41+
try {
42+
const magicLinkUrl = `${appUrl}/shift-submit?token=${recipient.magicLinkToken}`;
43+
44+
await resend.emails.send({
45+
from: FROM_EMAIL,
46+
to: recipient.email,
47+
subject,
48+
html: buildEmailHtml({
49+
shopName: args.shopName,
50+
startDate: args.startDate,
51+
endDate: args.endDate,
52+
deadline: args.deadline,
53+
magicLinkUrl,
54+
}),
55+
});
56+
} catch (e) {
57+
console.error(`メール送信失敗: ${recipient.email}`, e);
58+
}
59+
}
60+
},
61+
});
62+
63+
// メールHTML組み立て
64+
const buildEmailHtml = (params: {
65+
shopName: string;
66+
startDate: string;
67+
endDate: string;
68+
deadline: string;
69+
magicLinkUrl: string;
70+
}) => {
71+
return `
72+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
73+
<h2>${params.shopName} のシフト募集が開始されました</h2>
74+
<table style="margin: 20px 0; border-collapse: collapse;">
75+
<tr>
76+
<td style="padding: 8px 16px 8px 0; font-weight: bold;">募集期間</td>
77+
<td style="padding: 8px 0;">${params.startDate}${params.endDate}</td>
78+
</tr>
79+
<tr>
80+
<td style="padding: 8px 16px 8px 0; font-weight: bold;">申請締切</td>
81+
<td style="padding: 8px 0;">${params.deadline}</td>
82+
</tr>
83+
</table>
84+
<p>以下のリンクからシフトを申請してください:</p>
85+
<a href="${params.magicLinkUrl}" style="display: inline-block; background: #0d9488; color: #fff; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin: 12px 0;">シフトを申請する</a>
86+
<p style="color: #666; font-size: 14px; margin-top: 24px;">※ このリンクはあなた専用です。他の方と共有しないでください。</p>
87+
</div>
88+
`.trim();
89+
};

convex/recruitment/mutations.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* 募集ドメイン - ミューテーション(書き込み操作)
3+
*
4+
* 責務:
5+
* - シフト募集の作成
6+
*/
7+
import { ConvexError, v } from "convex/values";
8+
import { internal } from "../_generated/api";
9+
import { mutation } from "../_generated/server";
10+
import { RECRUITMENT_STATUS } from "../constants";
11+
import { generateToken, requireShop, requireShopOwnerOrManager } from "../helpers";
12+
13+
// 日付形式バリデーション(YYYY-MM-DD形式)
14+
const isValidDateFormat = (date: string) => {
15+
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
16+
if (!dateRegex.test(date)) return false;
17+
const parsed = new Date(date);
18+
return !Number.isNaN(parsed.getTime());
19+
};
20+
21+
// シフト募集作成
22+
export const create = mutation({
23+
args: {
24+
shopId: v.id("shops"),
25+
authId: v.string(),
26+
startDate: v.string(),
27+
endDate: v.string(),
28+
deadline: v.string(),
29+
},
30+
handler: async (ctx, args) => {
31+
// 店舗存在チェック
32+
await requireShop(ctx, args.shopId);
33+
34+
// 権限チェック(オーナーまたはマネージャーのみ)
35+
await requireShopOwnerOrManager(ctx, args.shopId, args.authId);
36+
37+
// 日付形式バリデーション
38+
if (!isValidDateFormat(args.startDate)) {
39+
throw new ConvexError({ message: "開始日の形式が不正です", code: "INVALID_START_DATE" });
40+
}
41+
if (!isValidDateFormat(args.endDate)) {
42+
throw new ConvexError({ message: "終了日の形式が不正です", code: "INVALID_END_DATE" });
43+
}
44+
if (!isValidDateFormat(args.deadline)) {
45+
throw new ConvexError({ message: "締切日の形式が不正です", code: "INVALID_DEADLINE" });
46+
}
47+
48+
// ビジネスルールバリデーション(フロントエンドのzodスキーマと一致)
49+
if (args.startDate > args.endDate) {
50+
throw new ConvexError({ message: "終了日は開始日以降を指定してください", code: "END_BEFORE_START" });
51+
}
52+
if (args.deadline >= args.startDate) {
53+
throw new ConvexError({
54+
message: "締切日は開始日より前を指定してください",
55+
code: "DEADLINE_NOT_BEFORE_START",
56+
});
57+
}
58+
59+
// アクティブスタッフ数を取得(非削除かつ非退職)
60+
const activeStaffs = await ctx.db
61+
.query("staffs")
62+
.withIndex("by_shop", (q) => q.eq("shopId", args.shopId))
63+
.filter((q) => q.and(q.neq(q.field("isDeleted"), true), q.neq(q.field("status"), "resigned")))
64+
.collect();
65+
66+
const totalStaffCount = activeStaffs.length;
67+
68+
// 各スタッフにマジックリンクトークンを生成・更新
69+
const deadlineEnd = new Date(`${args.deadline}T23:59:59`).getTime();
70+
const recipients: { email: string; magicLinkToken: string }[] = [];
71+
72+
for (const staff of activeStaffs) {
73+
const token = generateToken();
74+
await ctx.db.patch(staff._id, {
75+
magicLinkToken: token,
76+
magicLinkExpiresAt: deadlineEnd,
77+
});
78+
recipients.push({ email: staff.email, magicLinkToken: token });
79+
}
80+
81+
// 募集作成
82+
const recruitmentId = await ctx.db.insert("recruitments", {
83+
shopId: args.shopId,
84+
startDate: args.startDate,
85+
endDate: args.endDate,
86+
deadline: args.deadline,
87+
status: RECRUITMENT_STATUS[0], // "open"
88+
appliedCount: 0,
89+
totalStaffCount,
90+
createdBy: args.authId,
91+
createdAt: Date.now(),
92+
isDeleted: false,
93+
});
94+
95+
// 店舗情報を取得してメール送信をスケジュール
96+
const shop = await requireShop(ctx, args.shopId);
97+
if (recipients.length > 0) {
98+
await ctx.scheduler.runAfter(0, internal.email.actions.sendRecruitmentNotification, {
99+
shopName: shop.shopName,
100+
startDate: args.startDate,
101+
endDate: args.endDate,
102+
deadline: args.deadline,
103+
recipients,
104+
});
105+
}
106+
107+
return { success: true, data: { recruitmentId, totalStaffCount } };
108+
},
109+
});

convex/recruitment/queries.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* 募集ドメイン - クエリ(読み取り操作)
3+
*
4+
* 責務:
5+
* - 店舗のシフト募集一覧取得
6+
*/
7+
import { v } from "convex/values";
8+
import { query } from "../_generated/server";
9+
import type { RecruitmentStatusType } from "../constants";
10+
11+
// 店舗の募集一覧取得
12+
export const listByShop = query({
13+
args: { shopId: v.id("shops") },
14+
handler: async (ctx, args) => {
15+
const recruitments = await ctx.db
16+
.query("recruitments")
17+
.withIndex("by_shop_and_startDate", (q) => q.eq("shopId", args.shopId))
18+
.order("desc")
19+
.filter((q) => q.neq(q.field("isDeleted"), true))
20+
.collect();
21+
22+
return recruitments.map((r) => ({
23+
_id: r._id,
24+
startDate: r.startDate,
25+
endDate: r.endDate,
26+
deadline: r.deadline,
27+
status: r.status as RecruitmentStatusType,
28+
appliedCount: r.appliedCount,
29+
totalStaffCount: r.totalStaffCount,
30+
confirmedAt: r.confirmedAt,
31+
}));
32+
},
33+
});

convex/schema.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,52 @@ const requiredStaffing = defineTable({
120120
updatedAt: v.number(),
121121
}).index("by_shop", ["shopId"]);
122122

123+
// シフト提出テーブル(スタッフがマジックリンクから提出)
124+
const shiftRequests = defineTable({
125+
recruitmentId: v.id("recruitments"),
126+
staffId: v.id("staffs"),
127+
entries: v.array(
128+
v.object({
129+
date: v.string(), // "YYYY-MM-DD"
130+
isAvailable: v.boolean(),
131+
startTime: v.optional(v.string()), // "09:00"(isAvailable=true時)
132+
endTime: v.optional(v.string()), // "17:00"(isAvailable=true時)
133+
}),
134+
),
135+
submittedAt: v.number(),
136+
updatedAt: v.optional(v.number()),
137+
})
138+
.index("by_recruitment", ["recruitmentId"])
139+
.index("by_staff", ["staffId"])
140+
.index("by_recruitment_and_staff", ["recruitmentId", "staffId"]);
141+
142+
// シフト募集テーブル
143+
const recruitments = defineTable({
144+
shopId: v.id("shops"),
145+
startDate: v.string(), // YYYY-MM-DD
146+
endDate: v.string(), // YYYY-MM-DD
147+
deadline: v.string(), // YYYY-MM-DD
148+
status: v.string(), // "open" | "closed" | "confirmed"
149+
appliedCount: v.number(), // 申請済みスタッフ数(初期値: 0)
150+
totalStaffCount: v.number(), // 作成時のアクティブスタッフ数
151+
confirmedAt: v.optional(v.number()),
152+
createdBy: v.string(), // authId
153+
createdAt: v.number(),
154+
isDeleted: v.boolean(),
155+
})
156+
.index("by_shop", ["shopId"])
157+
.index("by_shop_and_status", ["shopId", "status"])
158+
.index("by_shop_and_startDate", ["shopId", "startDate"]);
159+
123160
const schema = defineSchema({
124161
users,
125162
shops,
126163
staffs,
127164
shopPositions,
128165
staffSkills,
129166
requiredStaffing,
167+
shiftRequests,
168+
recruitments,
130169
});
131170

132171
// テーブル名を型安全にエクスポート(testing.tsで使用)

0 commit comments

Comments
 (0)