first release commit
This commit is contained in:
commit
2cd12da2f6
69
.gitignore
vendored
Normal file
69
.gitignore
vendored
Normal 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
8
.prettierrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 4,
|
||||
"useTabs": true
|
||||
}
|
||||
36
README.md
Normal file
36
README.md
Normal 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
40
eslint.config.mjs
Normal 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
8
next.config.ts
Normal 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
8693
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal 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
5
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
52
prisma/schema.prisma
Normal file
52
prisma/schema.prisma
Normal 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
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
48
src/app/api/manage/[reserve_num]/customer/[id]/route.ts
Normal file
48
src/app/api/manage/[reserve_num]/customer/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
88
src/app/api/manage/[reserve_num]/customer/route.ts
Normal file
88
src/app/api/manage/[reserve_num]/customer/route.ts
Normal 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 });
|
||||
}
|
||||
66
src/app/api/manage/[reserve_num]/route.ts
Normal file
66
src/app/api/manage/[reserve_num]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
186
src/app/api/reserve-setting/[id]/route.ts
Normal file
186
src/app/api/reserve-setting/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
43
src/app/api/reserve-setting/active/route.ts
Normal file
43
src/app/api/reserve-setting/active/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
175
src/app/api/reserve-setting/route.ts
Normal file
175
src/app/api/reserve-setting/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
20
src/app/api/reserve/[token]/route.ts
Normal file
20
src/app/api/reserve/[token]/route.ts
Normal 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);
|
||||
}
|
||||
93
src/app/api/reserve/mailTemplate.ts
Normal file
93
src/app/api/reserve/mailTemplate.ts
Normal 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 };
|
||||
};
|
||||
108
src/app/api/reserve/route.ts
Normal file
108
src/app/api/reserve/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
src/app/globals.css
Normal file
26
src/app/globals.css
Normal 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
37
src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
270
src/app/manage/[reserve_num]/CommonModal.tsx
Normal file
270
src/app/manage/[reserve_num]/CommonModal.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
433
src/app/manage/[reserve_num]/ManageDayClient.tsx
Normal file
433
src/app/manage/[reserve_num]/ManageDayClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/manage/[reserve_num]/layout.tsx
Normal file
12
src/app/manage/[reserve_num]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/manage/[reserve_num]/page.tsx
Normal file
11
src/app/manage/[reserve_num]/page.tsx
Normal 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} />;
|
||||
}
|
||||
7
src/app/manage/setting/layout.tsx
Normal file
7
src/app/manage/setting/layout.tsx
Normal 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>;
|
||||
}
|
||||
422
src/app/manage/setting/page.tsx
Normal file
422
src/app/manage/setting/page.tsx
Normal 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
83
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
581
src/app/reserve/ReservePageClient.tsx
Normal file
581
src/app/reserve/ReservePageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/app/reserve/layout.tsx
Normal file
7
src/app/reserve/layout.tsx
Normal 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
11
src/app/reserve/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/app/thanks/ThanksPageClient.tsx
Normal file
145
src/app/thanks/ThanksPageClient.tsx
Normal 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
12
src/app/thanks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/ConfirmDialog.tsx
Normal file
74
src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/ErrorDialog.tsx
Normal file
49
src/components/ErrorDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
491
src/components/Modals/ReserveSettingModal.tsx
Normal file
491
src/components/Modals/ReserveSettingModal.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/components/formDefaults.ts
Normal file
16
src/components/formDefaults.ts
Normal 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: "", // 追加!
|
||||
};
|
||||
60
src/components/ui/Button.tsx
Normal file
60
src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/ModalBackdrop.tsx
Normal file
36
src/components/ui/ModalBackdrop.tsx
Normal 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
16
src/lib/dayjs.ts
Normal 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
67
src/lib/mail.ts
Normal 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
14
src/lib/prisma.ts
Normal 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
62
src/lib/utils.ts
Normal 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));
|
||||
40
src/lib/validators/reserveCustomerValidator.ts
Normal file
40
src/lib/validators/reserveCustomerValidator.ts
Normal 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>;
|
||||
118
src/lib/validators/reserveLimitValidator.ts
Normal file
118
src/lib/validators/reserveLimitValidator.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
src/lib/validators/reserveServerValidator.ts
Normal file
36
src/lib/validators/reserveServerValidator.ts
Normal 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>;
|
||||
56
src/lib/validators/reserveSettingValidator.ts
Normal file
56
src/lib/validators/reserveSettingValidator.ts
Normal 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
26
src/middleware.ts
Normal 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
18
src/types/reserve.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user