first release commit

This commit is contained in:
system_master 2025-10-11 17:31:24 +09:00
commit 2cd12da2f6
56 changed files with 17249 additions and 0 deletions

69
.gitignore vendored Normal file
View File

@ -0,0 +1,69 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
# Vscode
.vscode
# Prisma local databases / journals
/prisma/*.db
/prisma/*.db-journal
/prisma/migrations/
!.keep_migrations
# Tailwind / PostCSS cache
*.css.map
# Project-specific backups and temp files
/backup/
/tmp/
/*.bak
/*.tmp
# OS / editor files
Thumbs.db
ehthumbs.db
Desktop.ini
.idea/
*.swp
*.swo

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 4,
"useTabs": true
}

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

40
eslint.config.mjs Normal file
View File

@ -0,0 +1,40 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends(
"next/core-web-vitals",
"next/typescript",
"plugin:prettier/recommended" // ← Prettier 統合
),
{
rules: {
//"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
"react-hooks/exhaustive-deps": "warn",
"prettier/prettier": "warn", // ← Prettier エラーを警告扱いに
},
},
{
ignores: [
"**/node_modules/**",
"**/.next/**",
"**/out/**",
"**/build/**",
"**/next-env.d.ts",
"**/src/generated/**", // ← ワイルドカードを前に追加してみる
"**/backup/**",
],
},
];
export default eslintConfig;

8
next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import "dotenv/config";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

8693
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "childcafe",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3004",
"build": "next build",
"start": "dotenv -e .env.production -- next start",
"lint": "eslint"
},
"dependencies": {
"@headlessui/react": "^2.2.8",
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.16.2",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-table": "^8.21.3",
"datatables.net": "^2.3.4",
"datatables.net-bs5": "^2.3.4",
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"framer-motion": "^12.23.23",
"jquery": "^3.7.1",
"lucide-react": "^0.545.0",
"mysql2": "^3.15.0",
"next": "15.5.3",
"nodemailer": "^7.0.9",
"react": "19.1.0",
"react-datepicker": "^8.7.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.63.0",
"react-hot-toast": "^2.6.0",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.11"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/jquery": "^3.5.33",
"@types/node": "^20",
"@types/nodemailer": "^7.0.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2",
"prisma": "^6.16.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

52
prisma/schema.prisma Normal file
View File

@ -0,0 +1,52 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model ReserveCustomer {
autonum Int @id @default(autoincrement())
reserve_num Int
reserve_time String @db.VarChar(8)
cust_name String @db.VarChar(255)
cust_email String @default("none") @db.VarChar(255)
cust_parent Int @default(0)
cust_child Int @default(0)
fix_parent Int @default(0)
fix_child Int @default(0)
fix_money Int @default(0)
fix_all Int @default(0) @db.TinyInt
cancel_flag Int @default(0)
reserve_datetime DateTime @default(dbgenerated("CURRENT_TIMESTAMP")) @db.DateTime(0)
reserve_result String? @db.VarChar(255)
thanks_token String? @db.VarChar(255)
setting ReserveSetting @relation("SettingToCustomers", fields: [reserve_num], references: [autonum])
@@map("reserve_customer")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model ReserveSetting {
autonum Int @id @default(autoincrement())
diner_date DateTime @db.Date
reserve_startdate DateTime @db.DateTime(0)
reserve_limitdate DateTime @db.DateTime(0)
start_time String @db.VarChar(8)
end_time String @db.VarChar(8)
time_range Int @db.TinyInt
reserve_limit Int @default(0)
limit_method String @default("block") @db.VarChar(5)
limit_type String @default("set") @db.VarChar(10)
menu String? @db.VarChar(255)
parent_money Int @default(0)
child_money Int @default(0)
note String? @db.Text
delete_flag Int @default(0) @db.TinyInt
createdatetime DateTime @default(now()) @db.Timestamp(0)
lastupdate DateTime @default(now()) @db.Timestamp(0)
customers ReserveCustomer[] @relation("SettingToCustomers")
@@map("reserve_setting")
}

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,48 @@
// src/app/api/manage/[reserve_num]/customer/[id]/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
// 更新アクションの型
type CustomerAction = "checkin" | "cancel" | "cancel_revert" | "fix_revert";
// リクエストBody型
interface CustomerPutBody {
action: CustomerAction;
fix_parent?: number;
fix_child?: number;
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const customerId = parseInt(id, 10);
const body: CustomerPutBody = await req.json();
const { action, fix_parent, fix_child } = body;
let updateData: Partial<{
fix_all: number;
fix_parent: number;
fix_child: number;
cancel_flag: number;
}> = {};
if (action === "checkin") {
updateData = {
fix_all: 1,
fix_parent: fix_parent ?? 0,
fix_child: fix_child ?? 0,
};
} else if (action === "cancel") {
updateData = { fix_all: 1, fix_parent: 0, fix_child: 0, cancel_flag: 1 };
} else if (action === "cancel_revert") {
updateData = { fix_all: 0, cancel_flag: 0 };
} else if (action === "fix_revert") {
updateData = { fix_all: 0, fix_parent: 0, fix_child: 0 };
}
const updated = await prisma.reserveCustomer.update({
where: { autonum: customerId },
data: updateData,
});
return NextResponse.json({ updated });
}

View File

@ -0,0 +1,88 @@
// src/app/api/manage/[reserve_num]/customer/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import dayjs from "dayjs";
// リクエストBody用の型
interface CustomerPostBody {
parent: number;
child: number;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ reserve_num: string }> }
) {
const { reserve_num } = await params;
const reserveNum = parseInt(reserve_num, 10);
const body: CustomerPostBody = await req.json();
const { parent, child } = body;
// 開催設定を取得
const setting = await prisma.reserveSetting.findUnique({
where: { autonum: reserveNum },
});
if (!setting) {
return NextResponse.json(
{ error: "対象の予約情報が見つかりませんでした" },
{ status: 404 }
);
}
// 時間帯スロットを文字列操作で生成
function generateSlots(start: string, end: string, range: number): string[] {
const slots: string[] = [];
let [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
while (sh < eh || (sh === eh && sm <= em)) {
const hh = String(sh).padStart(2, "0");
const mm = String(sm).padStart(2, "0");
slots.push(`${hh}:${mm}`);
sm += range;
if (sm >= 60) {
sh += Math.floor(sm / 60);
sm = sm % 60;
}
}
return slots;
}
// スロット生成
const slots = generateSlots(setting.start_time, setting.end_time, setting.time_range);
// 現在時刻HH:mm
const now = dayjs().format("HH:mm");
// 直前スロットを選択
let chosen = slots[0];
for (const slot of slots) {
if (slot <= now) {
chosen = slot;
} else {
break;
}
}
// 保存用は HH:mm:ss に統一
const reserve_time = `${chosen}:00`; // 必ず8文字になる
// 保存
const customer = await prisma.reserveCustomer.create({
data: {
reserve_num: reserveNum,
reserve_time, // 例: "12:30:00"
cust_name: "当日来場",
cust_email: "none",
cust_parent: parent,
cust_child: child,
fix_parent: parent,
fix_child: child,
fix_all: 1, // 精算済みとして登録
cancel_flag: 0,
// reserve_datetime は DB default(now()) に任せる
},
});
return NextResponse.json({ customer });
}

View File

@ -0,0 +1,66 @@
// src/app/api/manage/[reserve_num]/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ reserve_num: string }> }
) {
const { reserve_num } = await params;
const reserveNum = parseInt(reserve_num, 10);
try {
const setting = await prisma.reserveSetting.findUnique({
where: { autonum: reserveNum },
include: { customers: true },
});
if (!setting) {
return NextResponse.json({ error: "予約はまだありません" }, { status: 404 });
}
const pending = setting.customers
.filter((c) => c.fix_all === 0 && c.cancel_flag === 0)
.map((c) => ({
id: c.autonum,
name: c.cust_name,
email: c.cust_email,
reserve_time: c.reserve_time,
cust_parent: c.cust_parent,
cust_child: c.cust_child,
amount: setting.parent_money * c.cust_parent + setting.child_money * c.cust_child,
}));
const fixed = setting.customers
.filter((c) => c.fix_all === 1)
.map((c) => ({
id: c.autonum,
name: c.cust_name,
email: c.cust_email,
reserve_time: c.reserve_time,
fix_parent: c.fix_parent,
fix_child: c.fix_child,
amount: setting.parent_money * c.fix_parent + setting.child_money * c.fix_child,
cancel_flag: c.cancel_flag,
}));
return NextResponse.json({
setting: {
autonum: setting.autonum,
date: setting.diner_date,
start_time: setting.start_time,
end_time: setting.end_time,
parent_money: setting.parent_money,
child_money: setting.child_money,
menu: setting.menu,
},
customers: {
pending,
fixed,
},
});
} catch (error) {
console.error("GET Error:", error);
return NextResponse.json({ error: "取得に失敗しました" }, { status: 500 });
}
}

View File

@ -0,0 +1,186 @@
// src/app/api/reserve-setting/[id]/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import dayjs from "dayjs";
import { validateReserveSetting } from "@/lib/validators/reserveSettingValidator";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const settingId = parseInt(id, 10);
const setting = await prisma.reserveSetting.findUnique({
where: { autonum: settingId },
select: {
autonum: true,
diner_date: true,
start_time: true,
end_time: true,
time_range: true,
limit_method: true,
limit_type: true,
reserve_limit: true,
},
});
if (!setting) {
return NextResponse.json({ error: "開催が見つかりません" }, { status: 404 });
}
// 予約数集計
let allCount = 0;
const blockCounts: Record<string, number> = {};
if (setting.limit_method === "all") {
if (setting.limit_type === "set") {
allCount = await prisma.reserveCustomer.count({
where: { reserve_num: settingId, cancel_flag: 0 },
});
} else {
const agg = await prisma.reserveCustomer.aggregate({
where: { reserve_num: settingId, cancel_flag: 0 },
_sum: { cust_parent: true, cust_child: true },
});
allCount = (agg._sum.cust_parent ?? 0) + (agg._sum.cust_child ?? 0);
}
} else {
if (setting.limit_type === "set") {
const rows = await prisma.reserveCustomer.groupBy({
by: ["reserve_time"],
where: { reserve_num: settingId, cancel_flag: 0 },
_count: { _all: true },
});
rows.forEach((r) => (blockCounts[r.reserve_time] = r._count._all));
} else {
const rows = await prisma.reserveCustomer.groupBy({
by: ["reserve_time"],
where: { reserve_num: settingId, cancel_flag: 0 },
_sum: { cust_parent: true, cust_child: true },
});
rows.forEach((r) => {
blockCounts[r.reserve_time] =
(r._sum.cust_parent ?? 0) + (r._sum.cust_child ?? 0);
});
}
}
return NextResponse.json({
...setting,
current: { all: allCount, blocks: blockCounts },
});
} catch (error) {
console.error("GET Error:", error);
return NextResponse.json({ error: "取得に失敗しました" }, { status: 500 });
}
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const settingId = Number(id);
const data = await req.json();
// 必須チェック
if (!data.dinerDate || !data.reserveStartDate || !data.reserveLimitDate) {
return NextResponse.json({ error: "必須項目が入力されていません" }, { status: 400 });
}
const dinerDate = dayjs(data.dinerDate);
if (dinerDate.isBefore(dayjs())) {
return NextResponse.json({ error: "過去の日付は登録できません" }, { status: 400 });
}
// 開催日重複チェック(自分以外)
const checkDate = dinerDate.format("YYYY-MM-DD");
const exists = await prisma.$queryRawUnsafe<{ dummy: number }[]>(
`SELECT 1 as dummy FROM reserve_setting WHERE diner_date = ? AND delete_flag = 0 AND autonum <> ? LIMIT 1`,
checkDate,
data.id
);
console.log([checkDate, data.id, exists]);
if (exists.length > 0) {
return NextResponse.json(
{ error: "同じ開催日が既に登録されています" },
{ status: 400 }
);
}
// 共通バリデーション
const validationError = validateReserveSetting(data);
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 });
}
// JST文字列に変換
const dinerDateStr = dayjs(data.dinerDate).format("YYYY-MM-DD");
const startTimeStr = dayjs(data.dinerDate).format("HH:mm:ss");
const endTimeStr = dayjs(`${data.dinerDate.split("T")[0]}T${data.endTime}`).format(
"HH:mm:ss"
);
const reserveStartStr = dayjs(data.reserveStartDate).format("YYYY-MM-DD HH:mm:ss");
const reserveLimitStr = dayjs(data.reserveLimitDate).format("YYYY-MM-DD HH:mm:ss");
// Raw SQL UPDATE
await prisma.$executeRawUnsafe(
`UPDATE reserve_setting
SET diner_date = ?, start_time = ?, end_time = ?, time_range = ?,
reserve_startdate = ?, reserve_limitdate = ?,
parent_money = ?, child_money = ?, limit_method = ?, limit_type = ?,
reserve_limit = ?, note = ?, lastupdate = NOW()
WHERE autonum = ?`,
dinerDateStr,
startTimeStr,
endTimeStr,
Number(data.timeRange),
reserveStartStr,
reserveLimitStr,
Number(data.priceAdult),
Number(data.priceChild),
data.limitMethod,
data.limitUnit,
Number(data.limitValue),
data.note || null,
settingId
);
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("PUT Error:", error);
return NextResponse.json({ error: "更新に失敗しました" }, { status: 500 });
}
}
interface DeleteExists {
autonum: number;
}
// DELETE: 論理削除Raw SQL版
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const settingId = parseInt(id, 10);
// 存在確認
const rows = (await prisma.$queryRawUnsafe(
`SELECT autonum FROM reserve_setting WHERE autonum = ? AND delete_flag = 0 LIMIT 1`,
settingId
)) as DeleteExists[];
if (rows.length === 0) {
return NextResponse.json({ error: "対象データが存在しません" }, { status: 404 });
}
// 論理削除
await prisma.$executeRawUnsafe(
`UPDATE reserve_setting
SET delete_flag = 1, lastupdate = NOW()
WHERE autonum = ?`,
settingId
);
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("DELETE Error:", error);
return NextResponse.json({ error: "削除に失敗しました" }, { status: 500 });
}
}

View File

@ -0,0 +1,43 @@
// src/app/api/reserve-setting/active/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import dayjs from "@/lib/dayjs";
export async function GET() {
try {
// サーバの JST 現在日時を文字列化
const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
// Raw SQL で直接比較
const settings = await prisma.$queryRawUnsafe<
{
autonum: number;
diner_date: string;
start_time: string;
end_time: string;
time_range: number;
}[]
>(
`
SELECT
autonum,
diner_date,
start_time,
end_time,
time_range
FROM reserve_setting
WHERE reserve_startdate <= ?
AND reserve_limitdate >= ?
AND delete_flag = 0
ORDER BY diner_date ASC
`,
now,
now
);
return NextResponse.json(settings);
} catch (error) {
console.error("active settings error:", error);
return NextResponse.json({ error: "開催日程の取得に失敗しました" }, { status: 500 });
}
}

View File

@ -0,0 +1,175 @@
// src/app/api/reserve-setting/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import dayjs from "@/lib/dayjs";
import { validateReserveSetting } from "@/lib/validators/reserveSettingValidator";
// DB結果用の型
interface ReserveRow {
autonum: number;
diner_date: Date;
start_time: string;
end_time: string;
time_range: number;
reserve_startdate: Date;
reserve_limitdate: Date;
reserve_start_str: string | null;
reserve_limit_str: string | null;
parent_money: number;
child_money: number;
limit_method: string;
limit_type: string;
reserve_limit: number;
note: string | null;
total_adults: number;
total_children: number;
total_sets: number;
}
export async function GET() {
try {
const rows = (await prisma.$queryRawUnsafe(`
SELECT
rs.autonum,
rs.diner_date,
rs.start_time,
rs.end_time,
rs.time_range,
rs.reserve_startdate,
rs.reserve_limitdate,
DATE_FORMAT(rs.reserve_startdate, '%Y-%m-%d %H:%i:%s') AS reserve_start_str,
DATE_FORMAT(rs.reserve_limitdate, '%Y-%m-%d %H:%i:%s') AS reserve_limit_str,
rs.parent_money,
rs.child_money,
rs.limit_method,
rs.limit_type,
rs.reserve_limit,
rs.note,
COALESCE(SUM(CASE WHEN c.cancel_flag = 0 THEN c.cust_parent ELSE 0 END), 0) AS total_adults,
COALESCE(SUM(CASE WHEN c.cancel_flag = 0 THEN c.cust_child ELSE 0 END), 0) AS total_children,
COALESCE(SUM(CASE WHEN c.cancel_flag = 0 THEN 1 ELSE 0 END), 0) AS total_sets
FROM reserve_setting rs
LEFT JOIN reserve_customer c ON c.reserve_num = rs.autonum
WHERE rs.delete_flag = 0
GROUP BY rs.autonum
ORDER BY rs.diner_date DESC
`)) as ReserveRow[];
const data = rows.map((row) => {
const dinerDate = dayjs(row.diner_date).format("YYYY-MM-DD");
const startHM = String(row.start_time).substring(0, 5); // "HH:mm"
const endHM = String(row.end_time).substring(0, 5);
const limitUnitLabel = row.limit_type === "set" ? "組" : "人";
const limitMethodText = row.limit_method == "block" ? "枠" : "全体";
const adults = Number(row.total_adults) || 0;
const children = Number(row.total_children) || 0;
const sets = Number(row.total_sets) || 0;
return {
autonum: row.autonum,
diner_datetime: `${dinerDate}T${startHM}`,
diner_range: `${dayjs(row.diner_date).format("M/DD")}\n${startHM}~${endHM}`,
end_hm: endHM,
// ここは SQL 側で '%Y-%m-%d %H:%i:%s' の文字列にして返しているので
// その文字列を dayjs のフォーマットで ISO 風に成形
reserve_start: row.reserve_start_str
? dayjs(row.reserve_start_str, "YYYY-MM-DD HH:mm:ss").format("YYYY-MM-DDTHH:mm")
: "",
reserve_limit: row.reserve_limit_str
? dayjs(row.reserve_limit_str, "YYYY-MM-DD HH:mm:ss").format("YYYY-MM-DDTHH:mm")
: "",
time_range: row.time_range,
time_range_display: `${row.time_range}`,
reserve_limit_value: row.reserve_limit,
limit_method: row.limit_method,
limit_type: row.limit_type,
parent_money: row.parent_money,
child_money: row.child_money,
note: row.note,
reserve_status: `予約組数: ${sets}組 / ${adults + children}\n大人:${adults}人 / こども:${children}`,
reserve_limit_display:
row.reserve_limit == 0
? "-"
: `${row.reserve_limit} ${limitUnitLabel}${limitMethodText}`,
};
});
return NextResponse.json(data);
} catch (error) {
console.error("GET Error:", error);
return NextResponse.json({ error: "Failed to fetch" }, { status: 500 });
}
}
// POST: 新規作成
export async function POST(req: NextRequest) {
try {
const data = await req.json();
console.log("受信:", data.timeRange);
// 必須チェック
if (!data.dinerDate || !data.reserveStartDate || !data.reserveLimitDate) {
return NextResponse.json({ error: "必須項目が入力されていません" }, { status: 400 });
}
// 過去日禁止
const now = dayjs();
const dinerDate = dayjs(data.dinerDate);
if (dinerDate.isBefore(now)) {
return NextResponse.json({ error: "過去の日付は登録できません" }, { status: 400 });
}
// --- 同日開催禁止Raw SQL---
const checkDate = dinerDate.format("YYYY-MM-DD");
const exists = await prisma.$queryRawUnsafe<{ dummy: number }[]>(
`SELECT 1 as dummy FROM reserve_setting WHERE diner_date = ? AND delete_flag = 0 LIMIT 1`,
checkDate
);
if (exists.length > 0) {
return NextResponse.json({ error: "同じ開催日で既に登録があります" }, { status: 400 });
}
// 追加バリデーション
const validationError = validateReserveSetting(data);
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 });
}
// JST文字列に変換
const dinerDateStr = dayjs(data.dinerDate).format("YYYY-MM-DD");
const startTimeStr = dayjs(data.dinerDate).format("HH:mm:ss");
const endTimeStr = dayjs(`${data.dinerDate.split("T")[0]}T${data.endTime}`).format(
"HH:mm:ss"
);
const reserveStartStr = dayjs(data.reserveStartDate).format("YYYY-MM-DD HH:mm:ss");
const reserveLimitStr = dayjs(data.reserveLimitDate).format("YYYY-MM-DD HH:mm:ss");
// Raw SQL で保存
await prisma.$executeRawUnsafe(
`INSERT INTO reserve_setting
(diner_date, start_time, end_time, time_range,
reserve_startdate, reserve_limitdate,
parent_money, child_money, limit_method, limit_type,
reserve_limit, delete_flag, createdatetime, lastupdate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())`,
dinerDateStr,
startTimeStr,
endTimeStr,
parseInt(data.timeRange, 10),
reserveStartStr,
reserveLimitStr,
parseInt(data.priceAdult, 10),
parseInt(data.priceChild, 10),
data.limitMethod,
data.limitUnit,
parseInt(data.limitValue, 10)
);
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
console.error("POST Error:", error);
return NextResponse.json({ error: "サーバエラーが発生しました" }, { status: 500 });
}
}

View File

@ -0,0 +1,20 @@
// src/app/api/reserve/[token]/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const record = await prisma.reserveCustomer.findFirst({
where: { thanks_token: token },
include: {
setting: true, // 開催情報も返す
},
});
if (!record) {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}
// thanks_token は削除せずにそのまま返す
return NextResponse.json(record);
}

View File

@ -0,0 +1,93 @@
/**
*
* ----------------------------------------
*
* DB登録直後に呼び出される想定
*/
import dayjs from "@/lib/dayjs";
/**
*
* reserve
*/
export type ReserveMailData = {
// --- 基本情報DB登録由来 ---
cust_name: string;
cust_email: string;
cust_parent: number;
cust_child: number;
// --- 開催情報(フロント送信由来) ---
event_date?: string | Date | null;
event_start?: string | null;
event_end?: string | null;
// --- 任意フィールド ---
event_name?: string;
reserve_time: string;
};
/**
*
*/
export const createReserveMail = (reserve: ReserveMailData) => {
console.log(reserve);
const dateDisplay = reserve.event_date
? dayjs(reserve.event_date).format("YYYY年MM月DD日 (ddd)")
: "未設定";
const timeDisplay = reserve.reserve_time ? reserve.reserve_time.slice(0, 5) : "時間未定";
const subject = `【予約完了】みんなの Bettakuごはん こども食堂 ${dateDisplay} のご予約ありがとうございます`;
const text = `
${reserve.cust_name}
"みんなの Bettakuごはん こども食堂"
📅 ${dateDisplay}
${timeDisplay ?? "未設定"}
👨👩👧👦 ${reserve.cust_parent ?? 0} ${reserve.cust_child ?? 0}
📨 ${reserve.cust_email}
Bettakuごはん
`;
return { subject, text };
};
/**
*
*/
export const createAdminNotifyMail = (reserve: ReserveMailData) => {
const subject = `【通知】新しい予約が入りました - ${reserve.cust_name}`;
const dateDisplay = reserve.event_date
? dayjs(reserve.event_date).format("YYYY年MM月DD日 (ddd)")
: "未設定";
const timeDisplay = reserve.reserve_time ? reserve.reserve_time.slice(0, 5) : "未設定";
const text = `
👤 ${reserve.cust_name}
📧 ${reserve.cust_email}
📅 ${dateDisplay}
${timeDisplay}
👨👩👧👦 ${reserve.cust_parent} ${reserve.cust_child}
Bettakuごはん|
`;
return { subject, text };
};

View File

@ -0,0 +1,108 @@
// src/app/api/reserve/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { reserveServerSchema } from "@/lib/validators/reserveServerValidator";
import { randomUUID } from "crypto";
import { checkReserveLimit } from "@/lib/validators/reserveLimitValidator";
import { sendMail } from "@/lib/mail";
import { createReserveMail, createAdminNotifyMail } from "./mailTemplate";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const data = reserveServerSchema.parse(body); // 型は Zod から推論
// --- 1. 予約制限のチェック(既存ロジック維持)---
const limitResult = await checkReserveLimit({
reserve_num: data.reserve_num,
reserve_time: data.reserve_time,
});
if (!limitResult.ok) {
return NextResponse.json(
{ success: false, error: limitResult.message },
{ status: 409 }
);
}
// --- 2. 重複チェック(既存ロジック維持)---
const exists = await prisma.reserveCustomer.findFirst({
where: {
reserve_num: data.reserve_num,
cust_email: data.cust_email,
cancel_flag: 0,
},
});
if (exists) {
return NextResponse.json(
{
success: false,
error: "既に予約されています。\nキャンセルをご希望の場合はLINEにてメールアドレスとキャンセル希望の旨をメッセージください",
},
{ status: 409 }
);
}
// --- 3. ワンタイム thanks_token 発行(既存ロジック維持)---
const token = randomUUID();
// await prisma.reserveCustomer.create({
// data: {
// reserve_num: data.reserve_num,
// cust_name: data.cust_name,
// cust_parent: data.cust_parent,
// cust_child: data.cust_child,
// reserve_time: data.reserve_time,
// cust_email: data.cust_email,
// thanks_token: token, // ← 既存通り thanks_token に保存
// },
// });
const setting = await prisma.reserveSetting.findUnique({
where: { autonum: data.reserve_num },
select: { diner_date: true },
});
data.thanks_token = token;
const reserve = await prisma.reserveCustomer.create({ data });
// --- メール本文生成 ---
const mail = createReserveMail({ ...reserve, ...data, event_date: setting?.diner_date });
// --- メール送信 ---
try {
await sendMail({
from: `みんなのBettakuごはん子ども食堂 <bettaku@basecafe.jp>`,
to: data.cust_email,
subject: mail.subject,
html: mail.text.replace(/\n/g, "<br>"), // ✅ HTML形式対応
text: mail.text,
});
} catch (mailError) {
console.error("❌ メール送信エラー:", mailError);
// メール送信に失敗しても予約登録は成功として扱う
}
// 管理者宛
const adminMail = createAdminNotifyMail({
...reserve,
...data,
event_date: setting?.diner_date,
});
await sendMail({
from: `bc-sys <corp@basecafe.jp>`,
to: process.env.MAIL_ADMIN ?? "",
subject: adminMail.subject,
html: adminMail.text.replace(/\n/g, "<br>"),
text: adminMail.text,
});
// --- 4. 返り値形式(既存仕様を厳守)---
return NextResponse.json({ success: true, token });
} catch (error: unknown) {
console.error("reserve POST error:", error);
return NextResponse.json(
{ success: false, error: "予約処理に失敗しました" },
{ status: 500 }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
src/app/globals.css Normal file
View File

@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

37
src/app/layout.tsx Normal file
View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
// import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "react-hot-toast";
import "./globals.css";
import { M_PLUS_Rounded_1c } from "next/font/google";
// const geistSans = Geist({
// variable: "--font-geist-sans",
// subsets: ["latin"],
// });
// const geistMono = Geist_Mono({
// variable: "--font-geist-mono",
// subsets: ["latin"],
// });
const mplus = M_PLUS_Rounded_1c({
subsets: ["latin"],
weight: ["300", "400", "500", "700"],
display: "swap",
});
export const metadata: Metadata = {
title: "BaseBettaku",
description: "こども食堂",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" className={mplus.className}>
<body className={`${mplus.className} antialiased`}>
{children}
<Toaster position="top-right" />
</body>
</html>
);
}

View File

@ -0,0 +1,270 @@
// src/app/manage/[reserve_num]/CommonModal.tsx
"use client";
import { Dialog } from "@headlessui/react";
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmDialog from "@/components/ConfirmDialog";
import ErrorDialog from "@/components/ErrorDialog";
import ModalBackdrop from "@/components/ui/ModalBackdrop";
import { Button } from "@/components/ui/Button";
type Mode = "add" | "checkin" | "edit";
export interface Customer {
id: number;
cust_name: string;
cust_email: string;
cust_parent: number;
cust_child: number;
fix_parent: number;
fix_child: number;
}
interface CommonModalProps {
isOpen: boolean;
onClose: () => void;
mode: Mode;
reserve_num: string;
setting: { parent_money: number; child_money: number };
customer?: Customer; // checkin / edit のときだけ渡す
onSaved: () => void; // 保存成功後に再読込するコールバック
}
export default function CommonModal({
isOpen,
onClose,
mode,
reserve_num,
setting,
customer,
onSaved,
}: CommonModalProps) {
const initialParent =
mode === "add"
? 0
: mode === "checkin"
? (customer?.cust_parent ?? 0)
: (customer?.fix_parent ?? 0);
const initialChild =
mode === "add"
? 0
: mode === "checkin"
? (customer?.cust_child ?? 0)
: (customer?.fix_child ?? 0);
const [parent, setParent] = useState(initialParent);
const [child, setChild] = useState(initialChild);
const [loading, setLoading] = useState(false);
// UI バリデーション
const [errorText, setErrorText] = useState("");
// 確認ダイアログ
const [confirmOpen, setConfirmOpen] = useState(false);
// エラーダイアログ(サーバーエラー)
const [errorOpen, setErrorOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const amount = setting.parent_money * parent + setting.child_money * child;
useEffect(() => {
if (isOpen) {
setParent(initialParent);
setChild(initialChild);
setErrorText("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// 登録処理本体
const doSubmit = async () => {
setLoading(true);
try {
if (mode === "add") {
const res = await fetch(`/api/manage/${reserve_num}/customer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent, child }),
});
if (!res.ok) throw new Error("API Error");
} else {
const res = await fetch(`/api/manage/${reserve_num}/customer/${customer!.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "checkin",
fix_parent: parent,
fix_child: child,
}),
});
if (!res.ok) throw new Error("API Error");
}
await onSaved();
setConfirmOpen(false);
onClose();
} catch (err) {
console.error(err);
setErrorMessage("登録処理に失敗しました。時間をおいて再試行してください。");
setErrorOpen(true);
} finally {
setLoading(false);
}
};
// 「登録」クリック時
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (parent + child === 0) {
setErrorText("大人またはこども人数を1以上にしてください。");
return;
}
setErrorText("");
setConfirmOpen(true);
};
return (
<>
<AnimatePresence>
{isOpen && (
<Dialog
open={isOpen}
onClose={onClose}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
{/* 背景(共有コンポーネント) */}
<ModalBackdrop />
{/* モーダル本体 */}
<motion.div
key="common-modal"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto z-10"
>
<Dialog.Title className="block text-sm font-medium text-gray-700 mb-1">
{mode === "add"
? "当日来場者の登録"
: mode === "checkin"
? "受付処理"
: "実績変更"}
</Dialog.Title>
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="block text-sm"></label>
<input
value={mode === "add" ? "当日来場" : customer?.cust_name}
disabled
className="w-full border border-gray-300 rounded-lg p-2 bg-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
email
</label>
<input
value={
mode === "add"
? "-"
: customer?.cust_email === "none"
? "-"
: customer?.cust_email
}
disabled
className="w-full border border-gray-300 rounded-lg p-2 bg-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
min={0}
value={parent}
onChange={(e) => setParent(Number(e.target.value))}
onFocus={(e) => e.target.select()}
className="w-full border border-gray-300 rounded-lg p-2 text-right focus:ring-2 focus:ring-sky-300 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
min={0}
value={child}
onChange={(e) => setChild(Number(e.target.value))}
onFocus={(e) => e.target.select()}
className="w-full border border-gray-300 rounded-lg p-2 text-right focus:ring-2 focus:ring-sky-300 focus:outline-none"
/>
{errorText && (
<p className="text-red-500 text-sm mt-1">{errorText}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
value={new Intl.NumberFormat("ja-JP").format(amount)}
readOnly
className="w-full border border-gray-300 rounded-lg p-2 bg-gray-100 text-right"
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="secondary"
size="md"
onClick={onClose}
disabled={loading}
>
</Button>
<Button
type="submit"
variant="primary"
size="md"
disabled={loading}
>
{loading ? (
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
) : (
"登録"
)}
</Button>
</div>
</form>
</motion.div>
{/* </Dialog.Panel> */}
{/* </div> */}
</Dialog>
)}
</AnimatePresence>
{/* 確認ダイアログ */}
<ConfirmDialog
isOpen={confirmOpen}
title="確認"
message="登録してよろしいですか?"
onConfirm={doSubmit}
onClose={() => setConfirmOpen(false)}
loading={loading}
/>
{/* エラーダイアログ */}
<ErrorDialog
isOpen={errorOpen}
message={errorMessage}
onClose={() => setErrorOpen(false)}
/>
</>
);
}

View File

@ -0,0 +1,433 @@
"use client";
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import CommonModal from "./CommonModal";
import ConfirmDialog from "@/components/ConfirmDialog";
import ErrorDialog from "@/components/ErrorDialog";
import type { Customer } from "./CommonModal";
import { Button } from "@/components/ui/Button";
type Setting = {
autonum: number;
date: string;
start_time: string;
end_time: string;
parent_money: number;
child_money: number;
menu?: string;
};
type PendingCustomer = {
id: number;
name: string;
email: string;
reserve_time: string;
cust_parent: number;
cust_child: number;
amount: number;
};
type FixedCustomer = {
id: number;
name: string;
email: string;
reserve_time: string;
fix_parent: number;
fix_child: number;
amount: number;
cancel_flag: number;
};
type ApiResponse = {
setting: Setting;
customers: {
pending: PendingCustomer[];
fixed: FixedCustomer[];
};
};
const formatNumber = (num: number) => new Intl.NumberFormat("ja-JP").format(num);
function groupByTime<T extends { reserve_time: string }>(list: T[]) {
return list.reduce(
(acc, cur) => {
const time = cur.reserve_time.slice(0, 5);
if (!acc[time]) acc[time] = [];
acc[time].push(cur);
return acc;
},
{} as Record<string, T[]>
);
}
export default function ManageDayClient({ reserve_num }: { reserve_num: string }) {
const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(false);
const [modalMode, setModalMode] = useState<"add" | "checkin" | "edit">("add");
const [modalCustomer, setModalCustomer] = useState<Customer | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [loadingId, setLoadingId] = useState<number | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmMessage, setConfirmMessage] = useState("");
const [pendingAction, setPendingAction] = useState<{ id: number; action: string } | null>(null);
const [errorOpen, setErrorOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const loadData = async () => {
setLoading(true);
try {
const res = await fetch(`/api/manage/${reserve_num}`);
if (!res.ok) throw new Error("データ取得失敗");
const json = await res.json();
setData(json);
} catch (err) {
console.error(err);
setErrorMessage("データ取得に失敗しました。");
setErrorOpen(true);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reserve_num]);
const openConfirm = (id: number, action: string, message: string) => {
setPendingAction({ id, action });
setConfirmMessage(message);
setConfirmOpen(true);
};
const executeAction = async () => {
if (!pendingAction) return;
const { id, action } = pendingAction;
setConfirmOpen(false);
setLoadingId(id);
try {
const res = await fetch(`/api/manage/${reserve_num}/customer/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
if (!res.ok) throw new Error("API Error");
await loadData();
} catch (err) {
console.error(err);
setErrorMessage("操作に失敗しました。時間をおいて再試行してください。");
setErrorOpen(true);
} finally {
setLoadingId(null);
setPendingAction(null);
}
};
if (loading && !data) return <p className="p-6">...</p>;
if (!data) return <p className="p-6"></p>;
const { setting, customers } = data;
const pendingGrouped = groupByTime(customers?.pending || []);
const fixedGrouped = groupByTime(customers?.fixed || []);
return (
<div className="space-y-8">
{/* 開催情報 */}
<header className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-sky-700">
: {dayjs(setting.date).format("M/DD")} {setting.start_time.slice(0, 5)}
{setting.end_time.slice(0, 5)}
</h1>
<p>
料金: おとな {formatNumber(setting.parent_money)} / {" "}
{formatNumber(setting.child_money)}
</p>
<p>: {customers.pending.length + customers.fixed.length}</p>
<Button
variant="primary"
size="lg"
onClick={() => {
setModalMode("add");
setModalCustomer(null);
setModalOpen(true);
}}
className="text-sm"
>
</Button>
</header>
{/* 未精算テーブル */}
<section className="bg-white shadow-md rounded-2xl border border-gray-200 p-4">
<h2 className="text-3xl font-semibold text-gray-700 mb-3"></h2>
{customers?.pending.length === 0 ? (
<p className="text-sm text-gray-500"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-gray-700">
<thead className="bg-sky-50 text-sky-800 border-b-gray-800">
<tr>
<th className="p-3 w-40"></th>
<th className="p-3 w-60">email</th>
<th className="p-3 text-center"></th>
<th className="p-3 text-center"></th>
<th className="p-3 text-center"></th>
<th className="p-3 w-[160px]"></th>
</tr>
</thead>
{Object.entries(pendingGrouped).map(([time, list]) => (
<tbody className="[&>tr:nth-child(even)]:bg-gray-50" key={time}>
<tr className="bg-sky-100 text-sky-800 font-medium text-2xl">
<td colSpan={6} className="p-2 ">
{time}
</td>
</tr>
{list.map((c) => (
<tr
key={c.id}
className="bg-white border-t border-gray-100 hover:bg-sky-200"
>
<td className="p-2">{c.name}</td>
<td className="p-2">
{c.email === "none" ? "-" : c.email}
</td>
<td className="p-2 text-right">
{formatNumber(c.cust_parent)}
</td>
<td className="p-2 text-right">
{formatNumber(c.cust_child)}
</td>
<td className="p-2 text-right">
{formatNumber(c.amount)}
</td>
<td className="p-2 w-60">
<div className="flex justify-left items-left gap-2">
<Button
variant="success"
size="sm"
isLoading={loadingId === c.id}
onClick={() => {
setModalMode("checkin");
setModalCustomer({
id: c.id,
cust_name: c.name,
cust_email: c.email,
cust_parent: c.cust_parent,
cust_child: c.cust_child,
fix_parent: 0,
fix_child: 0,
});
setModalOpen(true);
}}
>
</Button>
<Button
variant="warning"
size="sm"
isLoading={loadingId === c.id}
onClick={() =>
openConfirm(
c.id,
"cancel",
"キャンセルしてよろしいですか?"
)
}
>
</Button>
</div>
</td>
</tr>
))}
</tbody>
))}
</table>
</div>
)}
</section>
{/* 確認・精算済みテーブル */}
<section className="bg-gray-50 rounded-2xl border border-gray-200 p-4">
<h2 className="text-3xl font-medium text-gray-500 mb-3"></h2>
{customers?.fixed.length === 0 ? (
<p className="text-sm text-gray-500"></p>
) : (
<table className="w-full text-sm text-gray-700">
<thead className="bg-gray-100 text-gray-600 border-b-gray-800">
<tr>
<th className="p-2 w-40"></th>
<th className="p-2 w-60">email</th>
<th className="p-2 text-right"></th>
<th className="p-2 text-right"></th>
<th className="p-2 text-right"></th>
<th className="p-2 w-60"></th>
</tr>
</thead>
{Object.entries(fixedGrouped).map(([time, list]) => (
<tbody className="[&>tr:nth-child(even)]:bg-gray-50" key={time}>
<tr className="bg-gray-100 hover:gb-gray-300 text-gray-600">
<td colSpan={6} className="p-2 text-2xl font-medium ">
{time}
</td>
</tr>
{list.map((c) => (
<tr
key={c.id}
className={`border-t border-gray-100 ${
c.cancel_flag === 1
? "bg-gray-100 text-gray-400 italic"
: "bg-gray-50 hover:bg-gray-100 transition-colors"
}`}
>
<td className="p-2">{c.name}</td>
<td className="p-2">
{c.email === "none" ? "-" : c.email}
</td>
<td className="p-2 text-right">
{formatNumber(c.fix_parent)}
</td>
<td className="p-2 text-right">
{formatNumber(c.fix_child)}
</td>
<td className="p-2 text-right">{formatNumber(c.amount)}</td>
<td className="p-2 w-60">
<div className="flex justify-left items-left gap-2">
{c.cancel_flag === 0 ? (
<>
<Button
variant="info"
size="sm"
isLoading={loadingId === c.id}
onClick={() => {
setModalMode("edit");
setModalCustomer({
id: c.id,
cust_name: c.name,
cust_email: c.email,
cust_parent: 0,
cust_child: 0,
fix_parent: c.fix_parent,
fix_child: c.fix_child,
});
setModalOpen(true);
}}
>
</Button>
<Button
variant="warning"
size="sm"
isLoading={loadingId === c.id}
onClick={() =>
openConfirm(
c.id,
"fix_revert",
"精算取消してよろしいですか?"
)
}
>
</Button>
</>
) : (
<Button
variant="muted"
size="sm"
isLoading={loadingId === c.id}
onClick={() =>
openConfirm(
c.id,
"cancel_revert",
"キャンセル取消してよろしいですか?"
)
}
>
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
))}
</table>
)}
</section>
{/* === 開催当日 集計テーブル === */}
<section className="bg-white shadow-md rounded-2xl border border-gray-200 p-4">
<h2 className="text-2xl font-semibold text-gray-700 mb-3"> </h2>
<table className="w-full text-sm text-gray-700">
<thead className="bg-sky-50 text-sky-800">
<tr>
<th className="p-3 text-center w-1/4"></th>
<th className="p-3 text-center w-1/4"></th>
<th className="p-3 text-center w-1/4"></th>
<th className="p-3 text-center w-1/4"></th>
</tr>
</thead>
<tbody>
<tr className="text-center bg-gray-50 font-semibold text-gray-800">
<td className="p-3">
{customers.fixed.filter((f) => f.cancel_flag === 0).length}
</td>
<td className="p-3">
{customers.fixed
.filter((f) => f.cancel_flag === 0)
.reduce((sum, f) => sum + f.fix_parent, 0)
.toLocaleString()}
</td>
<td className="p-3">
{customers.fixed
.filter((f) => f.cancel_flag === 0)
.reduce((sum, f) => sum + f.fix_child, 0)
.toLocaleString()}
</td>
<td className="p-3 text-red-600">
{customers.fixed.filter((f) => f.cancel_flag === 1).length}
</td>
</tr>
</tbody>
</table>
</section>
{/* 共通モーダル */}
<CommonModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
mode={modalMode}
reserve_num={reserve_num}
setting={setting}
customer={modalCustomer ?? undefined}
onSaved={loadData}
/>
{/* 確認ダイアログ */}
<ConfirmDialog
isOpen={confirmOpen}
title="確認"
message={confirmMessage}
onConfirm={executeAction}
onClose={() => setConfirmOpen(false)}
loading={loadingId !== null}
/>
{/* エラーダイアログ */}
<ErrorDialog
isOpen={errorOpen}
message={errorMessage}
onClose={() => setErrorOpen(false)}
/>
</div>
);
}

View File

@ -0,0 +1,12 @@
import "@/app/globals.css";
export const metadata = {
title: "みんなのBettakuごはん | 当日受付管理",
};
export default function ManageDayLayout({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-gray-50 text-gray-800 antialiased max-w-6xl mx-auto px-4 py-8">
{children}
</main>
);
}

View File

@ -0,0 +1,11 @@
// src/app/manage/[reserve_num]/page.tsx
import ManageDayClient from "./ManageDayClient";
export default async function ManageDayPage({
params,
}: {
params: Promise<{ reserve_num: string }>;
}) {
const { reserve_num } = await params; // Promise を unwrap
return <ManageDayClient reserve_num={reserve_num} />;
}

View File

@ -0,0 +1,7 @@
export const metadata = {
title: "みんなのBettakuごはん | 開催管理",
};
export default function ManageSettingLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-white">{children}</div>;
}

View File

@ -0,0 +1,422 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useReactTable, getCoreRowModel, ColumnDef, flexRender } from "@tanstack/react-table";
import dayjs from "dayjs";
import ReserveSettingModal from "@/components/Modals/ReserveSettingModal";
import ConfirmDialog from "@/components/ConfirmDialog";
import { ReserveSettingForm } from "@/types/reserve";
import { Edit3, Trash2, Eye, ClipboardList } from "lucide-react";
import { Button } from "@/components/ui/Button";
/* eslint-disable @typescript-eslint/no-unused-vars */
declare module "@tanstack/react-table" {
interface ColumnMeta<TData, TValue> {
className?: string;
}
}
/* eslint-enable @typescript-eslint/no-unused-vars */
// テーブルカラム meta 用の型拡張
interface ColumnMeta {
className?: string;
}
interface ReserveSetting {
autonum: number;
diner_datetime: string;
diner_range: string;
end_hm: string;
reserve_start: string;
reserve_limit: string;
time_range: number;
time_range_display: string;
reserve_limit_value: number;
limit_method: "block" | "all";
limit_type: "set" | "person";
parent_money: number;
child_money: number;
note: string | null;
reserve_status: string;
reserve_limit_display: string;
}
export default function ManageSettingPage() {
const [data, setData] = useState<ReserveSetting[]>([]);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editData, setEditData] = useState<ReserveSettingForm | null>(null);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const fetchData = () => {
fetch("/api/reserve-setting")
.then((res) => res.json())
.then((json) => setData(json));
};
useEffect(() => {
fetchData();
}, []);
const handleEdit = (row: ReserveSetting) => {
const formData: ReserveSettingForm = {
id: row.autonum,
timeRange: row.time_range,
timeRangeDisplay: row.time_range_display,
dinerDate: row.diner_datetime,
dinerRange: row.diner_range,
endTime: row.end_hm,
reserveStartDate: row.reserve_start,
reserveLimitDate: row.reserve_limit,
priceAdult: row.parent_money,
priceChild: row.child_money,
limitMethod: row.limit_method,
limitUnit: row.limit_type,
limitValue: row.reserve_limit_value,
};
console.log(formData);
setEditData(formData);
setIsCreateOpen(true);
};
const handleDelete = async () => {
if (!deleteId) return;
setLoading(true);
try {
const res = await fetch(`/api/reserve-setting/${deleteId}`, {
method: "DELETE",
});
if (!res.ok) {
const err = await res.json();
alert(err.error || "削除に失敗しました");
} else {
await fetchData(); // リスト更新を先に実行
setIsConfirmOpen(false);
setDeleteId(null);
}
} catch (e) {
console.error("Delete error:", e);
alert("通信エラーが発生しました");
} finally {
setLoading(false);
}
};
const columns = useMemo<ColumnDef<ReserveSetting>[]>(
() => [
{
accessorKey: "autonum",
header: "#",
meta: { className: "text-end w-12" },
},
{
accessorKey: "diner_range",
header: "開催日時",
cell: (info) => (
<div className="whitespace-pre-line">{info.getValue() as string}</div>
),
meta: { className: "text-center" },
},
{
accessorKey: "reserve_start",
header: "受付開始日時",
cell: (info) =>
info.getValue() ? dayjs(info.getValue() as string).format("M/DD HH:mm") : "",
meta: { className: "text-center" },
},
{
accessorKey: "reserve_limit",
header: "受付締切",
cell: (info) =>
info.getValue() ? dayjs(info.getValue() as string).format("M/DD HH:mm") : "",
meta: { className: "text-center" },
},
{
accessorKey: "time_range_display",
header: "受付間隔",
meta: { className: "text-end" },
},
{
accessorKey: "reserve_limit_display",
header: "受付制限",
meta: { className: "text-end" },
},
{
accessorKey: "reserve_status",
header: "受付状況",
cell: (info) => (
<div className="whitespace-pre-line">{info.getValue() as string}</div>
),
},
{
id: "actions",
header: "操作",
meta: { className: "text-center" },
},
],
[]
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4"></h1>
<button
className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 mb-4"
onClick={() => setIsCreateOpen(true)}
>
</button>
{/* ローディング中 */}
{data.length === 0 ? (
<div className="flex items-center justify-center h-40 text-gray-500">
<span className="animate-spin border-4 border-sky-300 border-t-transparent rounded-full w-6 h-6 mr-3"></span>
...
</div>
) : (
<div className="overflow-x-auto rounded-lg shadow border border-gray-200">
<table className="min-w-full bg-white text-md">
<thead className="bg-lime-200 text-sky-700 font-semibold border-b border-sky-100">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-center whitespace-nowrap"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
const now = dayjs();
const start = dayjs(row.original.reserve_start);
const end = dayjs(row.original.reserve_limit);
const diner = dayjs(row.original.diner_datetime);
// ステータス分類
let statusLabel = "";
let statusColor = "";
if (diner.isBefore(now)) {
statusLabel = "開催終了";
statusColor = "bg-gray-200 text-gray-500";
} else if (now.isBefore(start)) {
statusLabel = "受付前";
statusColor = "bg-blue-100 text-blue-700";
} else if (now.isBefore(end)) {
statusLabel = "受付中";
statusColor = "bg-green-100 text-green-700";
} else {
statusLabel = "受付終了";
statusColor = "bg-yellow-100 text-yellow-700";
}
return (
<tr
key={row.id}
className={`transition-colors ${
diner.isBefore(now)
? "bg-gray-100 text-gray-400"
: "bg-white hover:bg-sky-50"
}`}
>
{row.getVisibleCells().map((cell) => {
if (cell.column.id === "reserve_status") {
const reserveText =
row.original.reserve_status || "";
// 改行で分割して解析
const lines = reserveText
.split("\n")
.map((l) => l.trim());
const mainInfo = lines[0] || "";
const adultLine =
lines.find((l) => l.includes("大人")) || "";
const childLine =
lines.find((l) => l.includes("こども")) || "";
// 人数を抽出
const adultMatch =
adultLine.match(/大人[:]?(\d+)/);
const childMatch =
childLine.match(/こども[:]?(\d+)/);
const adultCount = adultMatch
? Number(adultMatch[1])
: 0;
const childCount = childMatch
? Number(childMatch[1])
: 0;
return (
<td
key={cell.id}
className="px-4 py-3 border-b text-center align-middle"
>
<div className="flex flex-col items-center">
{/* ステータスバッジ */}
<span
className={`inline-block px-3 py-[2px] rounded-full text-xs font-semibold ${statusColor}`}
>
{statusLabel}
</span>
{/* 予約集計情報 */}
{mainInfo && (
<div className="mt-1 text-sm text-gray-600 leading-tight text-center">
<p>{mainInfo}</p>
<div className="flex justify-center items-center gap-3 leading-tight mt-1">
<span className="flex items-center gap-1 text-sky-700">
👩 {adultCount}
</span>
<span className="flex items-center gap-1 leading-tight text-pink-500">
👶 {childCount}
</span>
</div>
</div>
)}
</div>
</td>
);
}
// 操作列のみ2行構成に変更
if (cell.column.id === "actions") {
return (
<td key={cell.id} className="p-2 border-b">
<div className="flex flex-col gap-2 items-center">
{/* 上段: 編集 / 削除 */}
<div className="flex justify-center gap-2">
<Button
variant="info"
size="sm"
icon={<Edit3 size={16} />}
onClick={() =>
handleEdit(row.original)
}
className="w-[110px]"
>
</Button>
<Button
asChild
variant="warning"
size="sm"
icon={<Eye size={16} />}
className="w-[110px] text-xs leading-none tracking-tight"
>
<a
href={`/reserve?preview=${row.original.autonum}`}
target="_blank"
rel="noopener noreferrer"
>
</a>
</Button>
</div>
{/* 下段: プレビュー / 当日管理 */}
<div className="flex justify-center gap-2">
<Button
asChild
variant="success"
size="sm"
icon={
<ClipboardList size={16} />
}
className="w-[110px]"
>
<a
href={`/manage/${row.original.autonum}`}
target="_blank"
rel="noopener noreferrer"
>
</a>
</Button>
<Button
variant="danger"
size="sm"
icon={<Trash2 size={16} />}
onClick={() => {
setDeleteId(
row.original.autonum
);
setIsConfirmOpen(true);
}}
className="w-[110px]"
>
</Button>
</div>
</div>
</td>
);
}
// 通常セル
return (
<td
key={cell.id}
className={`px-4 py-3 border-b ${
(cell.column.columnDef.meta as ColumnMeta)
?.className ?? ""
}`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
)}
<ReserveSettingModal
isOpen={isCreateOpen}
onClose={() => {
setIsCreateOpen(false);
setEditData(null);
}}
onSaved={fetchData}
initialData={editData || undefined}
/>
<ConfirmDialog
isOpen={isConfirmOpen}
title="削除の確認"
message="このデータを削除してよろしいですか?"
onClose={() => {
if (!loading) {
setIsConfirmOpen(false);
setDeleteId(null);
}
}}
onConfirm={handleDelete}
loading={loading}
/>
</div>
);
}

83
src/app/page.tsx Normal file
View File

@ -0,0 +1,83 @@
import Image from "next/image";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image aria-hidden src="/file.svg" alt="File icon" width={16} height={16} />
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image aria-hidden src="/window.svg" alt="Window icon" width={16} height={16} />
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image aria-hidden src="/globe.svg" alt="Globe icon" width={16} height={16} />
Go to nextjs.org
</a>
</footer>
</div>
);
}

View File

@ -0,0 +1,581 @@
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { reserveSchema } from "@/lib/validators/reserveCustomerValidator";
import { Dialog, Listbox } from "@headlessui/react";
import dayjs from "dayjs";
import { useRouter, useSearchParams } from "next/navigation";
import ErrorDialog from "@/components/ErrorDialog";
import { z } from "zod";
type ReserveFormInput = z.input<typeof reserveSchema>;
export type ReserveFormData = z.infer<typeof reserveSchema>;
interface ReserveSettingDetail {
autonum: number;
diner_date: string;
start_time: string;
end_time: string;
time_range: number;
limit_method: "block" | "all";
limit_type: "set" | "person";
reserve_limit: number;
current?: {
all: number;
blocks: Record<string, number>;
};
}
interface Schedule {
autonum: number;
diner_date: string;
start_time: string;
end_time: string;
time_range: number;
}
export default function ReservePage() {
const params = useSearchParams();
const previewId = params.get("preview");
const isPreview = !!previewId;
const router = useRouter();
// フォーム定義
const {
register,
handleSubmit,
watch,
formState: { errors },
setValue,
} = useForm<ReserveFormInput, object, ReserveFormData>({
resolver: zodResolver(reserveSchema),
defaultValues: {
reserve_num: "0",
cust_name: "",
cust_parent: 0,
cust_child: 0,
reserve_time: "",
cust_email: "",
},
});
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [isErrorOpen, setIsErrorOpen] = useState(false);
// 開催情報・詳細
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [setting, setSetting] = useState<ReserveSettingDetail | null>(null);
const [currentCounts, setCurrentCounts] = useState<{ [time: string]: number }>({});
const [allFull, setAllFull] = useState(false);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [pendingData, setPendingData] = useState<ReserveFormData | null>(null);
// ローディング管理
const [listLoading, setListLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
// フォントサイズ
const [fontScale, setFontScale] = useState(1);
const selectedScheduleId = watch("reserve_num");
const parentCount = watch("cust_parent");
const childCount = watch("cust_child");
const selectedTime = watch("reserve_time");
// --- 開催一覧取得 ---
useEffect(() => {
const fetchData = async () => {
setListLoading(true);
try {
if (isPreview && previewId) {
const res = await fetch(`/api/reserve-setting/${previewId}`);
if (!res.ok) throw new Error("API error");
const data: Schedule = await res.json();
setSchedules([data]);
setValue("reserve_num", String(data.autonum), { shouldValidate: true });
} else {
const res = await fetch("/api/reserve-setting/active");
if (!res.ok) throw new Error("API error");
const data: Schedule[] = await res.json();
setSchedules(data);
if (data.length === 1) {
setValue("reserve_num", String(data[0].autonum), { shouldValidate: true });
}
}
} catch (err) {
console.error("開催日程の取得に失敗しました:", err);
} finally {
setListLoading(false);
}
};
fetchData();
}, [isPreview, previewId, setValue]);
// --- 詳細取得 ---
useEffect(() => {
const fetchDetail = async () => {
if (!selectedScheduleId || selectedScheduleId === "0") return;
setDetailLoading(true);
try {
const res = await fetch(`/api/reserve-setting/${selectedScheduleId}`);
if (!res.ok) return;
const data = await res.json();
setSetting(data);
setCurrentCounts(data.current?.blocks || {});
setAllFull(
data.limit_method === "all" &&
data.reserve_limit > 0 &&
data.current?.all >= data.reserve_limit
);
} catch (err) {
console.error("設定詳細取得失敗:", err);
} finally {
setDetailLoading(false);
}
};
fetchDetail();
}, [selectedScheduleId]);
// --- 人数制限チェック ---
useEffect(() => {
if (!setting || setting.limit_type !== "person") return;
const inputTotal = parentCount + childCount;
if (inputTotal === 0) return;
let reserved = 0;
if (setting.limit_method === "block") {
if (!selectedTime) return;
reserved = currentCounts[selectedTime] || 0;
} else if (setting.limit_method === "all") {
reserved = setting.current?.all || 0;
}
if (setting.reserve_limit > 0 && reserved + inputTotal > setting.reserve_limit) {
setErrorMessage(
`この開催は人数の上限に達しています(既に ${reserved}人 / 上限 ${setting.reserve_limit}人)`
);
setIsErrorOpen(true);
}
}, [parentCount, childCount, selectedTime, setting, currentCounts]);
// --- 来場時間帯リスト ---
const selectedSchedule = schedules.find((s) => s.autonum === Number(selectedScheduleId));
const timeSlots: string[] = [];
if (selectedSchedule) {
const baseDate = dayjs(selectedSchedule.diner_date).format("YYYY-MM-DD");
const start = dayjs(`${baseDate} ${selectedSchedule.start_time}`, "YYYY-MM-DD HH:mm:ss");
const end = dayjs(`${baseDate} ${selectedSchedule.end_time}`, "YYYY-MM-DD HH:mm:ss");
for (let t = start; t.isBefore(end); t = t.add(selectedSchedule.time_range, "minute")) {
timeSlots.push(t.format("HH:mm"));
}
}
// --- submit ---
const onSubmit = (data: ReserveFormData) => {
setPendingData(data);
setIsConfirmOpen(true);
};
const handleConfirm = async () => {
if (!pendingData) return;
// ✅ 通信中オーバーレイ表示ON
setLoading(true);
try {
// reserve_time を "hh:mm:00" 形式に変換して送信
const payload = {
...pendingData,
reserve_time: pendingData.reserve_time ? `${pendingData.reserve_time}:00` : "",
event_date: setting?.diner_date || null,
event_start: setting?.start_time || null, // ← 開始時間
event_end: setting?.end_time || null, // ← 終了時間
};
const res = await fetch("/api/reserve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await res.json();
if (!res.ok) {
// ❌ エラー時のみローディング解除
setLoading(false);
setErrorMessage(result.error || "予約に失敗しました");
setIsErrorOpen(true);
return;
}
// ✅ 成功時ローディング継続のままthanksへ遷移
router.push(`/thanks?token=${result.token}`);
} catch (err) {
// ❌ 通信失敗時のみローディング解除してフォームに戻す
setLoading(false);
setErrorMessage("通信エラーが発生しました:" + err);
setIsErrorOpen(true);
} finally {
// ✅ 成功時はローディング継続、エラー時はすでに解除済み
// ダイアログのみ閉じるローディングはthanksで自動解除
setIsConfirmOpen(false);
setPendingData(null);
}
};
// --- UI ---
if (listLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-gray-500">
<span className="animate-spin border-4 border-sky-300 border-t-transparent rounded-full w-10 h-10 mb-4" />
<p>...</p>
</div>
);
}
return (
<div
style={{ fontSize: `${fontScale}em` }}
className="min-h-screen bg-gradient-to-b from-sky-50 to-white pb-24 relative"
>
{/* Header */}
<header className="flex justify-between items-center py-3 px-4 border-b border-sky-100">
<h1 className="text-2xl font-bold text-sky-600"></h1>
<button
onClick={() => setFontScale(fontScale === 1 ? 1.5 : 1)}
className={`text-sm border rounded-full px-3 py-1 transition
${fontScale === 1.5 ? "bg-sky-500 text-white border-sky-500" : "text-sky-600 border-sky-400"}`}
>
<span className="text-xs align-top">+</span>
</button>
</header>
<main className="max-w-md mx-auto mt-4 space-y-6 px-3">
{allFull && (
<p className="text-red-500 text-sm mt-2"></p>
)}
<form onSubmit={handleSubmit(onSubmit)}>
{/* 開催日程 */}
<section className="bg-white rounded-2xl shadow-md p-4 relative">
<h2 className="text-lg font-semibold mb-2"></h2>
{schedules.length === 0 ? (
<p className="text-gray-500 text-sm"></p>
) : (
schedules.map((s) => {
const baseDate = dayjs(s.diner_date);
const weekdays = ["日", "月", "火", "水", "木", "金", "土"];
const dateLabel = `${baseDate.format("M月D日")}${weekdays[baseDate.day()]}`;
const startLabel = dayjs(
`${baseDate.format("YYYY-MM-DD")} ${s.start_time}`
).format("H:mm");
const endLabel = dayjs(
`${baseDate.format("YYYY-MM-DD")} ${s.end_time}`
).format("H:mm");
return (
<label key={s.autonum} className="flex items-center gap-2 mb-1">
<input
type="radio"
className="mt-1"
value={String(s.autonum)}
{...register("reserve_num")}
disabled={isPreview}
/>
<span className="whitespace-nowrap overflow-hidden text-ellipsis block">
{`${dateLabel} ${startLabel}${endLabel}`}
</span>
</label>
);
})
)}
{errors.reserve_num && (
<p className="text-red-500 text-sm">{errors.reserve_num.message}</p>
)}
{detailLoading && (
<div className="absolute inset-0 bg-white/70 flex items-center justify-center rounded-2xl">
<span className="animate-spin border-4 border-sky-400 border-t-transparent rounded-full w-8 h-8" />
</div>
)}
</section>
{/* 申込み情報 */}
<section className="bg-white rounded-2xl shadow-md p-4">
<h2 className="text-lg font-semibold mb-2"></h2>
<label className="block mb-3">
<span className="text-sm text-gray-700"></span>
<input
type="text"
className="w-full border rounded-xl px-3 py-2 mt-1"
{...register("cust_name")}
/>
{errors.cust_name && (
<p className="text-red-500 text-sm">{errors.cust_name.message}</p>
)}
</label>
<label className="block">
<span className="text-sm text-gray-700"></span>
<input
type="email"
className="w-full border rounded-xl px-3 py-2 mt-1"
{...register("cust_email")}
/>
{errors.cust_email && (
<p className="text-red-500 text-sm">{errors.cust_email.message}</p>
)}
</label>
</section>
{/* 来場人数 */}
<section className="bg-white rounded-2xl shadow-md p-4 mt-3">
<h2 className="text-lg font-semibold mb-2"></h2>
{/* 1. 通常時は横2列、拡大時は縦1列 */}
<div
className={`grid gap-3 transition-all ${
fontScale === 1.5 ? "grid-cols-1" : "grid-cols-2"
}`}
>
{[
{ label: "おとな", key: "cust_parent" as const },
{ label: "こども", key: "cust_child" as const },
].map(({ label, key }) => (
<div key={key} className="text-center transition-all">
{/* 2. ラベルは拡大時にやや大きめ */}
<p
className={`mb-2 font-medium transition-all ${
fontScale === 1.5 ? "text-base" : "text-sm"
}`}
>
{label}
</p>
{/* 3. 「− 値 +」は常に横並び、拡大時にサイズを調整 */}
<div
className={`flex items-center justify-center gap-3 transition-all ${
fontScale === 1.5 ? "scale-125" : "scale-100"
}`}
>
<button
type="button"
className="w-10 h-10 bg-sky-100 rounded-full text-xl"
onClick={() =>
setValue(
key,
Math.max(0, watch(key) - 1)
// ,{
// shouldValidate: true,
// }
)
}
>
</button>
<span
className={`w-8 text-lg font-semibold ${
fontScale === 1.5 ? "text-xl" : ""
}`}
>
{watch(key)}
</span>
<button
type="button"
className="w-10 h-10 bg-sky-500 text-white rounded-full text-xl"
onClick={() =>
setValue(
key,
Math.min(5, watch(key) + 1)
// , {
// shouldValidate: true,
// }
)
}
>
</button>
</div>
</div>
))}
</div>
</section>
{/* 来場時間帯 */}
<section className="bg-white rounded-2xl shadow-md p-4 mt-3">
<h2 className="text-lg font-semibold mb-2"></h2>
<Listbox value={selectedTime} onChange={(v) => setValue("reserve_time", v)}>
<div className="relative mt-2 z-20">
<Listbox.Button className="w-full border rounded-xl px-4 py-3 text-left">
{selectedTime || "時間を選択してください"}
</Listbox.Button>
<Listbox.Options
className="absolute bottom-full mb-2 w-full
max-h-60 overflow-y-auto
bg-white border rounded-xl shadow-md z-10"
>
{timeSlots.map((slot) => (
<Listbox.Option
key={slot}
value={slot}
className="cursor-pointer px-4 py-2 hover:bg-sky-100"
>
{slot}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox>
</section>
{/* ボタン固定 */}
<footer
className={`
fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg py-3 px-4
transition-all duration-300
${isConfirmOpen ? "z-0 blur-sm pointer-events-none opacity-70" : "z-[60] opacity-100"}
`}
>
<button
type="submit"
className="w-full bg-sky-500 text-white font-semibold py-3 rounded-xl text-lg
active:translate-y-[1px] transition"
disabled={isPreview || allFull}
>
</button>
</footer>
<div className="h-20" /> {/* ページ最下部の余白確保 */}
</form>
</main>
{/* 全画面ローディング(送信中演出) */}
{loading && (
<div className="fixed inset-0 bg-white/90 z-[80] flex flex-col items-center justify-center transition-opacity duration-500">
<span className="animate-spin border-4 border-sky-400 border-t-transparent rounded-full w-12 h-12 mb-4"></span>
<p className="text-sky-600 font-semibold text-lg">...</p>
</div>
)}
{/* 確認モーダル */}
{!isPreview && (
<Dialog
open={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
className="fixed inset-0 z-50 flex items-center justify-center"
>
{/* 背景オーバーレイ */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm"
aria-hidden="true"
/>
{/* モーダル本体 */}
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-auto p-6 z-10">
{/* 見出し */}
<Dialog.Title className="text-xl font-bold text-center text-sky-600 mb-4 flex flex-col items-center">
<span className="text-4xl mb-2">📝</span>
</Dialog.Title>
{/* 内容 */}
{pendingData && (
<div className="text-gray-700 mb-6 space-y-2 text-sm leading-snug">
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span className="pr-4"></span>
<span
className="
font-semibold text-right max-w-[65%]
inline-block truncate
[font-size:clamp(0.875rem,2.2vw,1.5rem)]
text-gray-800
"
title={pendingData.cust_name}
>
{pendingData.cust_name}
</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span></span>
<span className="text-2xl font-semibold break-words text-right max-w-[65%]">
{pendingData.cust_parent}
</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span></span>
<span className="text-2xl font-semibold break-words text-right max-w-[65%]">
{pendingData.cust_child}
</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span></span>
<span className="text-2xl font-semibold break-words text-right max-w-[65%]">
{pendingData.reserve_time}
</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span className="pr-4"></span>
<span
className="
font-medium text-right max-w-[65%]
inline-block truncate
[font-size:clamp(1rem,2vw,1.125rem)]
text-gray-800
"
title={pendingData.cust_email}
>
{pendingData.cust_email}
</span>
</div>
</div>
)}
{/* メッセージ */}
<p className="text-gray-700 text-center text-lg mb-6 leading-relaxed">
<br />
</p>
{/* ボタン群(縦並び) */}
<div className="flex flex-col gap-3">
<button
className="bg-gray-200 text-gray-700 py-3 rounded-xl font-semibold text-lg hover:bg-gray-300 active:translate-y-[1px] transition"
onClick={() => setIsConfirmOpen(false)}
disabled={loading}
>
</button>
<button
className="bg-sky-500 text-white py-3 rounded-xl font-semibold text-lg shadow hover:bg-sky-600 active:translate-y-[1px] transition flex items-center justify-center"
onClick={handleConfirm}
disabled={loading}
>
{loading && (
<span className="animate-spin border-2 border-white border-t-transparent rounded-full w-5 h-5 mr-2"></span>
)}
</button>
</div>
</div>
</Dialog>
)}
{/* 全画面ローディング(送信中演出) */}
{loading && (
<div className="fixed inset-0 bg-white/90 z-[80] flex flex-col items-center justify-center transition-opacity duration-500">
<span className="animate-spin border-4 border-sky-400 border-t-transparent rounded-full w-12 h-12 mb-4"></span>
<p className="text-sky-600 font-semibold text-lg">...</p>
</div>
)}
<ErrorDialog
isOpen={isErrorOpen}
message={errorMessage}
onClose={() => setIsErrorOpen(false)}
/>
</div>
);
}

View File

@ -0,0 +1,7 @@
export const metadata = {
title: "みんなのBettakuごはん | 来場予約",
};
export default function ReserveLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-white">{children}</div>;
}

11
src/app/reserve/page.tsx Normal file
View File

@ -0,0 +1,11 @@
// src/app/reserve/page.tsx
import { Suspense } from "react";
import ReservePageClient from "./ReservePageClient";
export default function ReservePage() {
return (
<Suspense fallback={<p className="text-center mt-10">...</p>}>
<ReservePageClient />
</Suspense>
);
}

View File

@ -0,0 +1,145 @@
"use client";
import { useSearchParams, notFound } from "next/navigation";
import { useEffect, useState } from "react";
import dayjs from "@/lib/dayjs";
import type { ReserveCustomer, ReserveSetting } from "@prisma/client";
type ReserveWithSetting = ReserveCustomer & { setting: ReserveSetting };
export default function ThanksPageClient() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [data, setData] = useState<ReserveWithSetting | null>(null);
const [loading, setLoading] = useState(true);
const [fetched, setFetched] = useState(false);
useEffect(() => {
if (!token || fetched) return;
const fetchData = async () => {
try {
const res = await fetch(`/api/reserve/${token}`);
if (!res.ok) {
setData(null);
setLoading(false);
return;
}
const json = await res.json();
setData(json);
setFetched(true);
} catch (err) {
console.error("thanks fetch error:", err);
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
}, [token, fetched]);
if (!token) notFound();
if (loading) return <p className="text-center mt-10 text-gray-500">...</p>;
if (!data) notFound();
// 開催日時の判定
const eventDateTime = dayjs(
`${dayjs(data.setting.diner_date).format("YYYY-MM-DD")} ${data.setting.start_time}`,
"YYYY-MM-DD HH:mm:ss"
);
if (dayjs().isAfter(eventDateTime)) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-sky-50 to-white">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-sm text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4">
</h1>
<p className="text-gray-600 text-lg leading-relaxed">
<br />
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-sky-50 to-white">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-sm w-full text-center">
{/* 完了アイコン */}
<div className="text-5xl mb-4 text-sky-500">🎉</div>
{/* 見出し */}
<h1 className="text-2xl font-bold mb-6 text-sky-600"></h1>
{/* 内容 */}
<div className="text-gray-700 mb-6 space-y-2 text-sm leading-snug text-left">
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span className="pr-4"></span>
<span
className="
font-semibold text-right max-w-[65%]
inline-block truncate
[font-size:clamp(0.875rem,2.2vw,1.5rem)]
text-gray-800
"
title={data.cust_name}
>
{data.cust_name}
</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span></span>
<span className="text-2xl font-semibold break-words text-right max-w-[65%]">
{data.cust_parent}
</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span></span>
<span className="text-2xl font-semibold break-words text-right max-w-[65%]">
{data.cust_child}
</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span></span>
<span className="text-2xl font-semibold break-words text-right max-w-[65%]">
{dayjs(data.reserve_time, "HH:mm:ss").format("H:mm")}
</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-1 items-baseline">
<span className="pr-4"></span>
<span
className="
font-medium text-right max-w-[65%]
inline-block truncate
[font-size:clamp(1rem,2vw,1.125rem)]
text-gray-800
"
title={data.cust_email}
>
{data.cust_email}
</span>
</div>
</div>
{/* メッセージ */}
<p className="text-gray-700 text-md mb-6 leading-relaxed">
LINEよりメッセージをください
</p>
{/* LINE復帰ボタン */}
<button
onClick={() => (window.location.href = "https://line.me/R/")}
className="w-full bg-sky-500 text-white py-3 rounded-xl font-semibold text-lg shadow hover:bg-sky-600 active:translate-y-[1px] transition"
>
LINEに戻る
</button>
</div>
</div>
);
}

12
src/app/thanks/page.tsx Normal file
View File

@ -0,0 +1,12 @@
// src/app/thanks/page.tsx
import { Suspense } from "react";
import ThanksPageClient from "./ThanksPageClient";
export default function ThanksPage() {
return (
<Suspense fallback={<p className="text-center mt-10">...</p>}>
<ThanksPageClient />
</Suspense>
);
}

View File

@ -0,0 +1,74 @@
"use client";
import { Dialog } from "@headlessui/react";
import { motion, AnimatePresence } from "framer-motion";
import ModalBackdrop from "@/components/ui/ModalBackdrop";
import { Button } from "@/components/ui/Button";
interface ConfirmDialogProps {
isOpen: boolean;
title?: string; // ヘッダラベル(任意)
message: string; // 本文
onClose: () => void; // 閉じるとき(キャンセル含む)
onConfirm: () => void; // 「はい」押下時
loading?: boolean; // 通信中は true
}
export default function ConfirmDialog({
isOpen,
title = "確認",
message,
onClose,
onConfirm,
loading = false,
}: ConfirmDialogProps) {
return (
<AnimatePresence>
{isOpen && (
<Dialog
open={isOpen}
onClose={() => !loading && onClose()}
className="fixed inset-0 z-50 flex items-center justify-center"
>
<ModalBackdrop />
<motion.div
key="dialog-body"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-auto p-6 z-10 "
>
<Dialog.Title className="text-xl font-bold text-sky-700 mb-4">
{title}
</Dialog.Title>
<p className="mb-6 text-gray-700 whitespace-pre-line">{message}</p>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="secondary"
type="button"
size="md"
onClick={onClose}
disabled={loading}
>
</Button>
<Button
variant="primary"
type="button"
size="md"
onClick={onConfirm}
isLoading={loading}
>
</Button>
</div>
</motion.div>
</Dialog>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,49 @@
// src/components/ErrorDialog.tsx
"use client";
import { Dialog } from "@headlessui/react";
import { motion, AnimatePresence } from "framer-motion";
import ModalBackdrop from "@/components/ui/ModalBackdrop";
import { Button } from "@/components/ui/Button";
interface ErrorDialogProps {
isOpen: boolean;
message: string;
onClose: () => void;
}
export default function ErrorDialog({ isOpen, message, onClose }: ErrorDialogProps) {
return (
<AnimatePresence>
{isOpen && (
<Dialog
open={isOpen}
onClose={onClose}
className="fixed inset-0 z-50 flex items-center justify-center"
>
<ModalBackdrop />
<motion.div
key="dialog-body"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-auto p-6 z-10"
>
<Dialog.Title className="text-xl font-bold text-red-600 mb-4">
</Dialog.Title>
<p className="mb-6 text-gray-700 whitespace-pre-line">{message}</p>
<div className="mt-6 flex justify-end">
<Button variant="primary" size="md" onClick={onClose}>
</Button>
</div>
</motion.div>
</Dialog>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,491 @@
// src/components/Modals/ReserveSettingModal.tsx
"use client";
import { Dialog } from "@headlessui/react";
import { motion, AnimatePresence } from "framer-motion";
import { useForm, Controller } from "react-hook-form";
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import { defaultReserveSettingForm } from "@/components/formDefaults";
import { ReserveSettingForm } from "@/types/reserve";
import DatePicker from "react-datepicker";
import { ja } from "date-fns/locale";
import ConfirmDialog from "@/components/ConfirmDialog"; // 共通化した確認モーダル
import "react-datepicker/dist/react-datepicker.css";
import toast from "react-hot-toast";
import ModalBackdrop from "@/components/ui/ModalBackdrop";
import { Button } from "@/components/ui/Button";
interface CreateItemModalProps {
isOpen: boolean;
onClose: () => void;
onSaved: () => void;
initialData?: ReserveSettingForm;
}
export default function ReserveSettingModal({
isOpen,
onClose,
onSaved,
initialData,
}: CreateItemModalProps) {
const {
register,
handleSubmit,
reset,
watch,
setValue,
control,
formState: { errors },
} = useForm<ReserveSettingForm>({
defaultValues: initialData || defaultReserveSettingForm,
});
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [pendingData, setPendingData] = useState<ReserveSettingForm | null>(null);
useEffect(() => {
// 1⃣ モーダル初期化(編集 or 新規)
reset({ ...defaultReserveSettingForm, ...initialData });
const dinerDate = watch("dinerDate");
const timeRange = Number(watch("timeRange")) || 30;
const endTime = watch("endTime");
// 2⃣ 編集モード時は endTime を優先セット
if (initialData?.endTime) {
setValue("endTime", initialData.endTime);
return; // 自動補完ロジックは不要
}
// 3⃣ 新規 or 未設定時は自動補完
if (dinerDate && !endTime) {
const start = dayjs(dinerDate);
const firstEnd = start.add(timeRange, "minute");
setValue("endTime", firstEnd.format("HH:mm"));
}
// react-hook-form の setValue / reset は安定参照なのでOK
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialData, isOpen, watch, setValue]);
// Submit時に直接APIを呼ばず、ConfirmDialogを開く
const onSubmit = (data: ReserveSettingForm) => {
if (Number(data.priceAdult) === 0 && Number(data.priceChild) === 0) {
alert("おとな料金とこども料金の両方を0にはできません");
return;
}
const payload = {
...data,
dinerDate: data.dinerDate ? dayjs(data.dinerDate).format("YYYY-MM-DDTHH:mm:ssZ") : "",
reserveStartDate: data.reserveStartDate
? dayjs(data.reserveStartDate).format("YYYY-MM-DDTHH:mm:ssZ")
: "",
reserveLimitDate: data.reserveLimitDate
? dayjs(data.reserveLimitDate).format("YYYY-MM-DDTHH:mm:ssZ")
: "",
};
setPendingData(payload);
setIsConfirmOpen(true);
};
// 実際の保存処理
const handleSave = async () => {
if (!pendingData) return;
setLoading(true);
try {
const res = await fetch(
pendingData.id === 0
? "/api/reserve-setting"
: `/api/reserve-setting/${pendingData.id}`,
{
method: pendingData.id === 0 ? "POST" : "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pendingData),
}
);
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "保存に失敗しました");
return;
}
toast.success("開催情報を保存しました 🎉");
onSaved();
onClose();
reset(defaultReserveSettingForm);
} catch (error) {
console.error("保存エラー:", error);
toast.error("通信エラーが発生しました");
} finally {
setLoading(false);
setIsConfirmOpen(false);
setPendingData(null);
}
};
const dinerDate = watch("dinerDate");
return (
<>
<AnimatePresence mode="wait" initial={false}>
{isOpen && (
<Dialog
open={true}
onClose={onClose}
className="fixed inset-0 z-50 flex items-center justify-center font-rounded"
>
<ModalBackdrop />
<motion.div
key="create-modal"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-3xl mx-auto p-8 z-10 max-h-[90vh] overflow-y-auto"
>
{/* ←ここから元のフォームを戻す */}
<Dialog.Title className="text-2xl font-bold text-sky-700 mb-6">
{initialData?.id && initialData.id > 0
? "編集A"
: "新しい開催を登録A"}
</Dialog.Title>
<form
onSubmit={handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 gap-6"
>
{/* 設定分単位 */}
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">
</label>
<select
{...register("timeRange")}
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-2 focus:ring-sky-300 focus:outline-none"
>
{[5, 10, 15, 20, 30, 60].map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
<div className="hidden md:block"></div>
{/* 開催日時 */}
<div>
<label className="block text-sm font-medium"></label>
<Controller
control={control}
name="dinerDate"
rules={{ required: "開催日時は必須です" }}
render={({ field }) => (
<DatePicker
selected={
field.value ? new Date(field.value) : null
}
onChange={(date) => field.onChange(date)}
showTimeSelect
timeIntervals={30}
dateFormat="yyyy/MM/dd HH:mm"
locale={ja}
wrapperClassName="w-full"
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-sky-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
/>
)}
/>
{errors.dinerDate && (
<p className="text-red-500 text-sm mt-1">
{errors.dinerDate.message}
</p>
)}
</div>
{/* 完了時刻 */}
<div>
<label className="block text-sm font-medium"></label>
<select
{...register("endTime", {
required: "完了時刻は必須です",
validate: (value) => {
const diner = dayjs(watch("dinerDate"));
const end = dayjs(
`${diner.format("YYYY-MM-DD")}T${value}`
);
if (end.isBefore(diner)) {
return "完了時刻は開催日時以降を選択してください";
}
if (end.isAfter(diner.endOf("day"))) {
return "完了時刻は当日内に設定してください";
}
return true;
},
})}
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-sky-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
>
{dinerDate &&
(() => {
const start = dayjs(dinerDate);
const timeRange = Number(watch("timeRange")) || 30;
const endTime = watch("endTime");
const options = [];
for (
let m = timeRange;
m <= 24 * 60;
m += timeRange
) {
const end = start.add(m, "minute");
if (
end.isAfter(start) &&
end.isBefore(start.endOf("day"))
) {
options.push(
<option
key={m}
value={end.format("HH:mm")}
>
{end.format("HH:mm")}
</option>
);
}
}
// ✅ 既存 endTime が options に無い場合は追加して保持
if (
endTime &&
!options.some(
(opt) => opt.props.value === endTime
)
) {
options.unshift(
<option key={endTime} value={endTime}>
{endTime}
</option>
);
}
return options;
})()}
</select>
{errors.endTime && (
<p className="text-red-500 text-sm mt-1">
{errors.endTime.message}
</p>
)}
</div>
{/* 受付開始日時 */}
<div>
<label className="block text-sm font-medium">
</label>
<Controller
control={control}
name="reserveStartDate"
rules={{ required: "受付開始日時は必須です" }}
render={({ field }) => (
<DatePicker
selected={
field.value ? new Date(field.value) : null
}
onChange={(date) => field.onChange(date)}
showTimeSelect
timeIntervals={30}
dateFormat="yyyy/MM/dd HH:mm"
locale={ja}
wrapperClassName="w-full"
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus;ring-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
/>
)}
/>
{errors.reserveStartDate && (
<p className="text-red-500 text-sm mt-1">
{errors.reserveStartDate.message}
</p>
)}
</div>
{/* 受付締切日時 */}
<div>
<label className="block text-sm font-medium">
</label>
<Controller
control={control}
name="reserveLimitDate"
rules={{
required: "受付締切日時は必須です",
validate: (value) => {
const start = dayjs(watch("reserveStartDate"));
const diner = dayjs(watch("dinerDate"));
const limit = dayjs(value);
if (limit.isBefore(start)) {
return "受付締切日時は受付開始日時以降に設定してください";
}
if (limit.isAfter(diner.subtract(1, "hour"))) {
return "受付締切は開催日時の1時間前までに設定してください";
}
return true;
},
}}
render={({ field }) => (
<DatePicker
selected={
field.value ? new Date(field.value) : null
}
onChange={(date) => field.onChange(date)}
showTimeSelect
timeIntervals={30}
dateFormat="yyyy/MM/dd HH:mm"
locale={ja}
wrapperClassName="w-full"
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-sky-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
/>
)}
/>
{errors.reserveLimitDate && (
<p className="text-red-500 text-sm mt-1">
{errors.reserveLimitDate.message}
</p>
)}
</div>
{/* --- 以下: 料金/制限入力項目(省略せずそのまま残す) --- */}
{/* おとな料金 */}
<div>
<label className="block text-sm font-medium"></label>
<input
type="number"
{...register("priceAdult", {
min: { value: 0, message: "0以上を入力してください" },
max: {
value: 9999,
message: "9999以下を入力してください",
},
})}
min={0}
max={9999}
onFocus={(e) => e.target.select()}
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-sky-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
/>
{errors.priceAdult && (
<p className="text-red-500 text-sm mt-1">
{errors.priceAdult.message}
</p>
)}
</div>
{/* こども料金 */}
<div>
<label className="block text-sm font-medium"></label>
<input
type="number"
{...register("priceChild", {
min: { value: 0, message: "0以上を入力してください" },
max: {
value: 9999,
message: "9999以下を入力してください",
},
})}
min={0}
max={9999}
onFocus={(e) => e.target.select()}
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-sky-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
/>
{errors.priceChild && (
<p className="text-red-500 text-sm mt-1">
{errors.priceChild.message}
</p>
)}
</div>
{/* 制限方法 */}
<div>
<label className="block text-sm font-medium"></label>
<select
{...register("limitMethod")}
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-sky-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
>
<option value="block"></option>
<option value="all"></option>
</select>
</div>
{/* 制限単位 */}
<div>
<label className="block text-sm font-medium"></label>
<select
{...register("limitUnit")}
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-sky-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
>
<option value="set"></option>
<option value="person"></option>
</select>
</div>
{/* 制限数値 */}
<div>
<label className="block text-sm font-medium"></label>
<input
type="number"
{...register("limitValue", {
min: { value: 0, message: "0以上を入力してください" },
max: {
value: 9999,
message: "9999以下を入力してください",
},
})}
min={0}
max={9999}
onFocus={(e) => e.target.select()}
className="w-full border border-gray-200 rounded-xl px-3 py-2 focus:ring-sky-300 dark:bg-gray-700 dark:text-gray-100 dark:border-gray-600"
/>
{errors.limitValue && (
<p className="text-red-500 text-sm mt-1">
{errors.limitValue.message}
</p>
)}
</div>
{/* フッターボタン */}
<div className="md:col-span-2 flex justify-end gap-3 mt-6">
<Button
variant="secondary"
size="md"
type="button"
onClick={onClose}
disabled={loading}
>
</Button>
<Button
variant="primary"
size="md"
type="submit"
disabled={loading}
isLoading={loading}
>
</Button>
</div>
</form>
</motion.div>
</Dialog>
)}
</AnimatePresence>
{/* 保存確認モーダル */}
<ConfirmDialog
isOpen={isConfirmOpen}
title="保存の確認"
message="入力内容を保存してよろしいですか?"
onClose={() => {
if (!loading) setIsConfirmOpen(false);
}}
onConfirm={handleSave}
loading={loading}
/>
</>
);
}

View File

@ -0,0 +1,16 @@
import { ReserveSettingForm } from "@/types/reserve";
export const defaultReserveSettingForm: ReserveSettingForm = {
id: 0,
timeRange: 30,
dinerDate: "",
endTime: "",
reserveStartDate: "",
reserveLimitDate: "",
priceAdult: 0,
priceChild: 0,
limitMethod: "all",
limitUnit: "set",
limitValue: 0,
dinerRange: "", // 追加!
};

View File

@ -0,0 +1,60 @@
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
type ButtonProps = {
variant?: "primary" | "secondary" | "success" | "warning" | "danger" | "muted" | "info";
size?: "sm" | "md" | "lg";
asChild?: boolean;
children: ReactNode;
isLoading?: boolean;
icon?: ReactNode; // ← ★ アイコン追加
iconPosition?: "left" | "right"; // ← ★ 位置も制御可能
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({
variant = "primary",
size = "md",
asChild,
children,
isLoading,
icon,
iconPosition = "left",
className,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
"inline-flex items-center justify-center gap-2 font-medium rounded-lg transition whitespace-nowrap disabled:opacity-50 disabled:pointer-events-none",
{
"bg-sky-500 hover:bg-sky-600 text-white shadow": variant === "primary",
"bg-gray-300 hover:bg-gray-400 text-gray-700": variant === "secondary",
"bg-green-500 hover:bg-green-600 text-white shadow": variant === "success",
"bg-orange-500 hover:bg-orange-600 text-white shadow": variant === "warning",
"bg-red-600 hover:bg-red-700 text-white shadow": variant === "danger",
"bg-purple-200 hover:bg-purple-300 text-gray-400": variant === "muted",
"bg-sky-400 hover:bg-sky-500 text-gray-50 shadow": variant === "info",
},
{
"w-[110px] h-[36px] px-3 text-sm": size === "sm",
"w-[140px] h-[42px] px-4 text-base": size === "md",
"w-[180px] h-[48px] px-6 text-base font-semibold": size === "lg",
},
className
)}
{...props}
>
<span className="flex items-center justify-center gap-2">
{isLoading && (
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
{!isLoading && icon && iconPosition === "left" && <span>{icon}</span>}
<span>{children}</span>
{!isLoading && icon && iconPosition === "right" && <span>{icon}</span>}
</span>
</Comp>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import { motion } from "framer-motion";
import React from "react";
interface ModalBackdropProps {
/** 背景フェードの時間(秒) */
duration?: number;
/** デフォルトの背景クラスを上書きしたい場合 */
className?: string;
}
/**
* ModalBackdrop
* 使
*
* :
* <ModalBackdrop /> // デフォルトの黒背景
* <ModalBackdrop className="bg-white/30" /> // 明るい背景など個別調整も可能
*/
export default function ModalBackdrop({
duration = 0.2,
className = "fixed inset-0 bg-black/40 backdrop-blur-sm",
}: ModalBackdropProps) {
return (
<motion.div
key="backdrop"
className={className}
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration }}
/>
);
}

16
src/lib/dayjs.ts Normal file
View File

@ -0,0 +1,16 @@
// src/lib/dayjs.ts
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import customParseFormat from "dayjs/plugin/customParseFormat";
import "dayjs/locale/ja";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
// プロジェクト全体を JST 固定にする
dayjs.tz.setDefault("Asia/Tokyo");
dayjs.locale("ja");
export default dayjs;

67
src/lib/mail.ts Normal file
View File

@ -0,0 +1,67 @@
// src/lib/mail.ts
import nodemailer from "nodemailer";
/**
* Nodemailer Gmail API(Service Account)
*/
const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
type: "OAuth2",
user: process.env.GMAIL_USER,
serviceClient: process.env.GOOGLE_CLIENT_EMAIL,
privateKey: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
},
});
/**
*
* @param from
* @param to
* @param subject
* @param html HTML形式
*/
export async function sendMail({
to,
subject,
html,
text,
from,
cc,
bcc,
priority,
}: {
to: string;
subject: string;
html: string;
text?: string;
from?: string;
cc?: string;
bcc?: string;
priority?: "high" | "normal" | "low";
}) {
try {
// HTMLのみ指定された場合、自動でテキスト版を生成
const plainText = text ?? html.replace(/<[^>]+>/g, "");
await transporter.sendMail({
from,
to,
cc,
bcc,
subject,
html,
text: plainText, // ← HTMLからタグを除去してtext生成
priority,
replyTo: process.env.MAIL_REPLY_TO ?? process.env.GMAIL_USER,
envelope: {
from: process.env.MAIL_RETURN_PATH ?? process.env.GMAIL_USER,
to,
},
});
console.log(`✅ Mail sent to ${to}`);
} catch (err) {
console.error("❌ Mail send error:", err);
throw err;
}
}

14
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,14 @@
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ["query", "error", "warn"], // 開発用ログ
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

62
src/lib/utils.ts Normal file
View File

@ -0,0 +1,62 @@
// src/lib/utils.ts
// -------------------------------------------------------
// 共通ユーティリティ関数群
// Tailwindクラス結合、フォーマット変換、日時処理 など
// -------------------------------------------------------
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/* -------------------------------------------
* Tailwind
* ----------------------------------------- */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/* -------------------------------------------
*
* ----------------------------------------- */
export function formatNumber(num: number | string | null | undefined): string {
if (num === null || num === undefined || num === "") return "0";
const value = typeof num === "string" ? parseFloat(num) : num;
return new Intl.NumberFormat("ja-JP").format(value);
}
/* -------------------------------------------
*
* ----------------------------------------- */
// ------------------------------------
// dayjs format wrappers
// ------------------------------------
import dayjs from "@/lib/dayjs";
export const formatDate = (dt: string | Date, style: "jp" | "slash" | "dash" = "jp") => {
const f = style === "jp" ? "YYYY年M月D日" : style === "dash" ? "YYYY-MM-DD" : "YYYY/MM/DD";
return dayjs(dt).format(f);
};
export const formatTime = (dt: string | Date, withSeconds = false) =>
dayjs(dt).format(withSeconds ? "HH:mm:ss" : "HH:mm");
export const formatDatetime = (dt: string | Date) => dayjs(dt).format("YYYY-MM-DD HH:mm:ss");
export const formatEventTimeRange = (date: string, start: string, end: string) =>
`${dayjs(date).format("M/DD")} ${start.slice(0, 5)}${end.slice(0, 5)}`;
export const toLocalDatetime = (date: string, time: string) =>
`${dayjs(date).format("YYYY-MM-DD")}T${dayjs(time, "HH:mm:ss").format("HH:mm")}`;
export const fromLocalDatetime = (datetime: string) =>
dayjs(datetime).format("YYYY-MM-DD HH:mm:ss");
export const isPast = (date: string | Date) => dayjs(date).isBefore(dayjs());
export const isFuture = (date: string | Date) => dayjs(date).isAfter(dayjs());
export const isSameDay = (a: string | Date, b: string | Date) => dayjs(a).isSame(dayjs(b), "day");
/* -------------------------------------------
*
* ----------------------------------------- */
/** async/awaitで指定ミリ秒待つ */
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

View File

@ -0,0 +1,40 @@
import { z } from "zod";
// 名前: 全角30文字以内、特殊文字・絵文字禁止文字・数字・スペースのみ許可
const nameRegex = /^[\p{L}\p{N}\p{Zs}]+$/u;
export const reserveSchema = z
.object({
// 開催日程: 選択必須string → number に変換して検証)
reserve_num: z
.string()
.min(1, "開催日程を選択してください")
.transform((val) => Number(val))
.refine((num) => num > 0, {
message: "開催日程を選択してください",
}),
cust_name: z
.string()
.min(1, "おなまえは必須です")
.max(30, "30文字以内で入力してください")
.regex(nameRegex, "特殊文字や絵文字は使用できません"),
cust_parent: z.number().min(0),
cust_child: z.number().min(0),
reserve_time: z.string().refine((val) => val !== "", {
message: "来場時間帯を選択してください",
}),
cust_email: z
.string()
.min(1, "メールアドレスは必須です")
.email("正しいメールアドレスを入力してください"),
})
.refine((data) => data.cust_parent + data.cust_child > 0, {
message: "おとな・こどもいずれか1人以上を選択してください",
path: ["cust_parent"],
});
export type ReserveFormData = z.infer<typeof reserveSchema>;

View File

@ -0,0 +1,118 @@
// src/lib/validators/reserveLimitValidator.ts
import { prisma } from "@/lib/prisma";
type LimitMethod = "block" | "all";
type LimitType = "set" | "person";
interface LimitCheckParams {
reserve_num: number;
// block のときのみ必要("HH:mm:ss"
reserve_time?: string;
/**
*
* limit_type=set 1
* limit_type=person (cust_parent + cust_child)
* OK
*/
requestedCount?: number;
}
/**
* /
* - ok=true:
* - ok=false: message="予約定員に達しています"
* - extra: 現在カウント// UIにも利用可
*/
export async function checkReserveLimit(params: LimitCheckParams) {
const { reserve_num, reserve_time, requestedCount } = params;
const setting = await prisma.reserveSetting.findUnique({
where: { autonum: reserve_num },
select: {
limit_method: true, // "block" | "all"
limit_type: true, // "set" | "person"
reserve_limit: true, // number
},
});
if (!setting) {
return { ok: false, message: "開催設定が存在しません" as const };
}
const method = setting.limit_method as LimitMethod;
const type = setting.limit_type as LimitType;
const limit = setting.reserve_limit;
let currentCount = 0;
// ---- 無制限処理を先に ----
if (limit === 0) {
return {
ok: true,
message: "",
currentCount,
limit,
remain: Infinity,
method,
type,
};
}
if (method === "block") {
if (!reserve_time) {
return { ok: false, message: "来場時間が必要です" as const };
}
if (type === "set") {
currentCount = await prisma.reserveCustomer.count({
where: { reserve_num, reserve_time, cancel_flag: 0 },
});
} else {
const agg = await prisma.reserveCustomer.aggregate({
where: { reserve_num, reserve_time, cancel_flag: 0 },
_sum: { cust_parent: true, cust_child: true },
});
currentCount = (agg._sum.cust_parent ?? 0) + (agg._sum.cust_child ?? 0);
}
} else {
// method === "all"
if (type === "set") {
currentCount = await prisma.reserveCustomer.count({
where: { reserve_num, cancel_flag: 0 },
});
} else {
const agg = await prisma.reserveCustomer.aggregate({
where: { reserve_num, cancel_flag: 0 },
_sum: { cust_parent: true, cust_child: true },
});
currentCount = (agg._sum.cust_parent ?? 0) + (agg._sum.cust_child ?? 0);
}
}
// 事前確認requestedCount 未指定)のときは「現在満席かどうか」だけ返す
if (requestedCount == null) {
const ok = currentCount < limit;
return {
ok,
message: ok ? "" : ("予約定員に達しています" as const),
currentCount,
limit,
remain: Math.max(0, limit - currentCount),
method,
type,
};
}
// 予約実行時は今回の予約分も加味して判定
const willBe = currentCount + requestedCount;
const ok = willBe <= limit;
return {
ok,
message: ok ? "" : ("予約定員に達しています" as const),
currentCount,
limit,
remain: Math.max(0, limit - currentCount),
method,
type,
};
}

View File

@ -0,0 +1,36 @@
import { z } from "zod";
// 名前バリデーション用(クライアントと同じ)
const nameRegex = /^[\p{L}\p{N}\p{Zs}]+$/u;
export const reserveServerSchema = z
.object({
reserve_num: z.number().int().positive(),
cust_name: z
.string()
.min(1, "おなまえは必須です")
.max(30, "30文字以内で入力してください")
.regex(nameRegex, "特殊文字や絵文字は使用できません"),
cust_parent: z.number().min(0),
cust_child: z.number().min(0),
reserve_time: z.string().refine((val) => val !== "", {
message: "来場時間帯を選択してください",
}),
cust_email: z
.string()
.min(1, "メールアドレスは必須です")
.email("正しいメールアドレスを入力してください"),
// tanks_token
thanks_token: z.string().optional(),
})
// 人数合計チェック
.refine((data) => data.cust_parent + data.cust_child > 0, {
message: "おとな・こどもいずれか1人以上を選択してください",
path: ["cust_parent"],
});
export type ReserveServerData = z.infer<typeof reserveServerSchema>;

View File

@ -0,0 +1,56 @@
// src/lib/validators/reserveSettingValidator.ts
import dayjs from "@/lib/dayjs";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import { ReserveSettingForm } from "@/types/reserve";
dayjs.extend(isSameOrBefore);
export function validateReserveSetting(data: ReserveSettingForm): string | null {
const diner = dayjs(data.dinerDate);
const start = dayjs(data.reserveStartDate);
const limit = dayjs(data.reserveLimitDate);
const end = dayjs(`${data.dinerDate.split("T")[0]}T${data.endTime}`);
if (!diner.isValid() || !start.isValid() || !limit.isValid() || !end.isValid()) {
return "日時の形式が不正です";
}
// --- 数値チェック ---
if (Number(data.priceAdult) < 0 || Number(data.priceAdult) > 9999) {
return "おとな料金は0〜9999の範囲で入力してください";
}
if (Number(data.priceChild) < 0 || Number(data.priceChild) > 9999) {
return "こども料金は0〜9999の範囲で入力してください";
}
if (Number(data.priceAdult) === 0 && Number(data.priceChild) === 0) {
return "おとな料金とこども料金の両方を0円での登録はできません";
}
if (Number(data.limitValue) < 0 || Number(data.limitValue) > 9999) {
return "制限数値は0〜9999の範囲で入力してください";
}
// --- 制限方法/単位チェック ---
if (!["block", "all"].includes(data.limitMethod)) {
return "制限方法が不正です";
}
if (!["set", "person"].includes(data.limitUnit)) {
return "制限単位が不正です";
}
// --- 日時の関係性チェック ---
if (end.isBefore(diner)) {
return "完了時刻は開催日時以降にしてください";
}
if (end.isAfter(diner.endOf("day"))) {
return "完了時刻は当日内にしてください";
}
if (limit.isBefore(start)) {
return "受付締切日時は受付開始日時以降にしてください";
}
if (!limit.isSameOrBefore(diner.subtract(1, "hour"))) {
return "受付締切は開催日時の1時間前までにしてください";
}
return null;
}

26
src/middleware.ts Normal file
View File

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const basicAuth = req.headers.get("authorization");
if (basicAuth) {
const authValue = basicAuth.split(" ")[1];
const [user, pwd] = atob(authValue).split(":");
if (user === process.env.BASIC_AUTH_USER && pwd === process.env.BASIC_AUTH_PASSWORD) {
return NextResponse.next();
}
}
return new NextResponse("Auth required", {
status: 401,
headers: {
"WWW-Authenticate": 'Basic realm="Secure Area"',
},
});
}
export const config = {
matcher: ["/manage/:path*"],
};

18
src/types/reserve.ts Normal file
View File

@ -0,0 +1,18 @@
// src/types/reserve.ts
// 管理画面・予約設定フォーム用
export type ReserveSettingForm = {
id: number;
timeRange: number;
timeRangeDisplay?: string; // ← 表示用optionalにして安全
dinerDate: string; // YYYY-MM-DDTHH:mm
dinerRange: string; // 表示用
endTime: string; // HH:mm
reserveStartDate: string; // YYYY-MM-DDTHH:mm
reserveLimitDate: string; // YYYY-MM-DDTHH:mm
priceAdult: number;
priceChild: number;
limitMethod: "block" | "all";
limitUnit: "set" | "person";
limitValue: number;
};

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

4191
yarn.lock Normal file

File diff suppressed because it is too large Load Diff