🕰️

第九話:時短予約システム vol.1

2025/01/02に公開

はじめに

相談内容と回答

オンライン英会話スクールにおける複数講師の相談予約時に、LINEチャットボットを検討中です。正確に応答することは可能でしょうか?

複数講師の情報をデータベースに保存し、それを参照して回答すればAI未使用でもシンプルに実現可能です。それから、リコメンドもAIで類似性検索を行うことで対応が可能です。類似性検索のスコアリングについては第五話にて説明しましたので、今回は予約機能に特化したいと思います。

  • 複数講師の予約をTypeScriptのコードで記載する。
  • とにかく最小限の工数で実装する。

技術スタック

Typescript

メリット

  • 型安全性:コンパイル時に型エラーを検出できるため、ランタイムエラーを減らし、バグの早期発見につながります

Prisma

メリット

  • シンプルなAPI
  • スキーマ駆動の設計:Prismaはデータベーススキーマ(schema.prismaファイル)をコードベースで管理します。このスキーマを変更するだけで、データベースのマイグレーションを簡単に行えます。
  • 自動マイグレーション

Next.js

メリット

  • フルスタック開発が可能
  • 簡易なデプロイ

Google API

メリット

  • Googleログインとカレンダーを使って、複数講師のカレンダーを一つに集約することが可能です。また、開発工数を最小限にできるだけでなく、自前で車輪の再開発をしたが為に発生するバグに悩まされずに済みます。

Material UI

メリット

  • モダンな美しいUI
  • テンプレートが充実していて、スマホ対応が容易

成果物

スキーマ

  • 予約した時のデータ保存用にテーブルを追加
  • コマンド:npx prisma migrate dev --name add_reservation_model
model Reservation {
  id        Int      @id @default(autoincrement()) // 自動採番されるID
  start     DateTime // 予約開始時間
  teacher   String // 先生の名前
  createdAt DateTime @default(now()) // 作成日時
  updatedAt DateTime @updatedAt // 更新日時
}

バックエンド

  • 予約データ保存用のAPIを開発
import { NextResponse } from 'next/server';
import prisma from '@/db'; // Prismaインスタンス

interface ReservationEvent {
    start: string;
    end: string;
    teacher: string;
}

export async function POST(request: Request) {
    try {
        const body = await request.json();
        console.log('予約リクエスト:', body);

        // 必要なパラメータが全て揃っているかを事前にチェック
        if (!body || !body.date || !body.hour || !body.filteredEvents || !body.teacher) {
            return NextResponse.json(
                { success: false, message: '入力内容が不完全です。date, hour, filteredEvents, teacherが必要です。' },
                { status: 400 }
            );
        }

        const { date, hour, filteredEvents, teacher } = body;
        console.log(teacher);

        // 日付解析(UTC時間として受け取る)
        const reservationTime = new Date(date); // dateはすでにUTC時間

        // 日本時間(JST)への変換(UTC + 9時間)
        const offset = 9 * 60; // 日本時間(JST)のオフセット(UTC+9時間)
        reservationTime.setMinutes(reservationTime.getMinutes() + reservationTime.getTimezoneOffset() + offset);

        // 日本時間でhourを設定(設定した時間を基に)
        reservationTime.setHours(hour, 0, 0, 0);  // 時間を設定(分、秒、ミリ秒は0にリセット)

        console.log('Parsed Reservation Time:', reservationTime);

        // 日付が不正な場合はエラーを返す
        if (isNaN(reservationTime.getTime())) {
            return NextResponse.json(
                { success: false, message: '予約時間の形式が正しくありません。' },
                { status: 400 }
            );
        }

        // 講師の予約可能時間をチェック
        const teacherEvent = filteredEvents.find((event: ReservationEvent) => {
            const start = new Date(event.start);
            const end = new Date(event.end);
            return event.teacher === teacher &&
                reservationTime >= start &&
                reservationTime <= end;
        });

        if (!teacherEvent) {
            return NextResponse.json(
                { success: false, message: `${teacher}先生はこの時間は予約できません。` },
                { status: 400 }
            );
        }

        // 予約データをデータベースに保存
        const newReservation = await prisma.reservation.create({
            data: {
                start: reservationTime,
                teacher: teacher,
            },
        });

        return NextResponse.json({
            success: true,
            message: `${teacher}先生との予約が完了しました`,
            reservation: newReservation, // 保存した予約データも返す
        });
    } catch (error) {
        console.error('予約処理エラー:', error);
        return NextResponse.json(
            {
                success: false,
                message: 'サーバーエラーが発生しました',
                error: error instanceof Error ? error.message : '不明なエラー',
            },
            { status: 500 }
        );
    }
}

  • import { getGoogleCalendar } from '@/lib/google';用に作成
import { google } from 'googleapis';

export const getGoogleCalendar = (accessToken: string) => {
    const auth = new google.auth.OAuth2();
    auth.setCredentials({
        access_token: accessToken
    });

    return google.calendar({ version: 'v3', auth });
};
  • もう一つのgetEventsのAPIでGoogleカレンダーのデータを引っ張る
  • ポイントは、作成した予約データをGoogleカレンダーのデータにぶつけて分割するところ
import { NextRequest, NextResponse } from 'next/server';
import { getGoogleCalendar } from '@/lib/google';
import prisma from '@/db';

export async function GET(req: NextRequest) {
    try {
        const accessToken = req.headers.get('authorization')?.replace('Bearer ', '');

        if (!accessToken) {
            return NextResponse.json(
                { error: 'Missing access token' },
                { status: 400 }
            );
        }

        const calendar = getGoogleCalendar(accessToken);

        const { data } = await calendar.events.list({
            calendarId: 'primary',
            singleEvents: true,
            orderBy: 'startTime',
            timeMin: new Date().toISOString(),
            maxResults: 10
        });

        const events = data.items || [];

        const reservations = await prisma.reservation.findMany({
            where: {
                start: {
                    gte: new Date()
                }
            }
        });

        const filteredEvents = events.flatMap(event => {
            if (!event.start || !event.end) {
                return [event];
            }

            const eventStart = event.start.dateTime ? new Date(event.start.dateTime) :
                event.start.date ? new Date(event.start.date) : null;
            const eventEnd = event.end.dateTime ? new Date(event.end.dateTime) :
                event.end.date ? new Date(event.end.date) : null;

            if (!eventStart || !eventEnd) {
                return [event];
            }

            // Reservation.teacherとevent.summaryが一致するものを絞り込む
            const overlappingReservations = reservations.filter(reservation => {
                const reservationStart = reservation.start;
                const reservationEnd = new Date(reservationStart.getTime() + 60 * 60 * 1000);

                // teacherとsummaryが一致するものだけを対象にする
                return (eventStart < reservationEnd && eventEnd > reservationStart) &&
                    reservation.teacher === event.summary;
            });

            if (overlappingReservations.length > 0) {
                const availableEvents: any[] = [];
                const sortedReservations = overlappingReservations.sort((a, b) => a.start.getTime() - b.start.getTime());

                let currentStart = eventStart;

                sortedReservations.forEach(reservation => {
                    const reservationStart = reservation.start;
                    const reservationEnd = new Date(reservationStart.getTime() + 60 * 60 * 1000);

                    if (currentStart < reservationStart) {
                        availableEvents.push({
                            ...event,
                            start: { dateTime: currentStart.toISOString() },
                            end: { dateTime: reservationStart.toISOString() },
                        });
                    }
                    currentStart = reservationEnd;
                });

                if (currentStart < eventEnd) {
                    availableEvents.push({
                        ...event,
                        start: { dateTime: currentStart.toISOString() },
                        end: { dateTime: eventEnd.toISOString() },
                    });
                }

                return availableEvents;
            }

            return [event];
        });

        return NextResponse.json(filteredEvents);

    } catch (error: any) {
        console.error('Error fetching events:', error);
        const errorMessage = error.response?.data?.error?.message || 'Failed to fetch events';
        return NextResponse.json(
            { error: errorMessage },
            { status: error.response?.status || 500 }
        );
    }
}

Googleログイン

  • Googleカレンダーのタイトルを講師名としてデータ作成
  • next-authを活用
  • マスタユーザーでログインし、データを表示

'use client';

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import {
  Card,
  CardContent,
  CardHeader,
  Typography,
  Button,
  Alert,
  AlertTitle,
} from '@mui/material';

const SignInSchema = z.object({
  email: z
    .string()
    .min(1, { message: 'Email is required!' })
    .email({ message: 'Email must be a valid email address!' }),
  password: z
    .string()
    .min(1, { message: 'Password is required!' })
    .min(6, { message: 'Password must be at least 6 characters!' }),
});

type SignInSchemaType = z.infer<typeof SignInSchema>;

export default function SignInPage() {
  const router = useRouter();
  const [showPassword, setShowPassword] = useState(false);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const defaultValues: SignInSchemaType = {
    email: 'demo@example.com',
    password: 'demo123',
  };

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignInSchemaType>({
    resolver: zodResolver(SignInSchema),
    defaultValues,
  });

  const onSubmit = async (data: SignInSchemaType) => {
    try {
      const result = await signIn('credentials', {
        email: data.email,
        password: data.password,
        redirect: false,
      });

      if (result?.error) {
        setErrorMessage(result.error);
        return;
      }

      router.push('/dashboard');
      router.refresh();
    } catch (error) {
      console.error(error);
      setErrorMessage('An error occurred during sign in');
    }
  };

  return (
    <div className="flex min-h-screen items-center justify-center p-4">
      <Card sx={{ maxWidth: 400, width: '100%' }}>
        <CardHeader>
          <Typography variant="h5" align="center">
            Sign in to your account
          </Typography>
          <Typography variant="body2" align="center" color="textSecondary" mt={2}>
            Don't have an account?{' '}
            <a href="/auth/signup" style={{ color: '#1976d2', textDecoration: 'underline' }}>
              Get started
            </a>
          </Typography>
        </CardHeader>

        <CardContent>
          {/* Error message */}
          {errorMessage && (
            <Alert severity="error" sx={{ mb: 4 }}>
              <AlertTitle>Error</AlertTitle>
              {errorMessage}
            </Alert>
          )}

          <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
            <Button
              type="button"
              variant="outlined"
              fullWidth
              color="primary"
              onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
              sx={{ mt: 2 }}
            >
              <svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
                <path
                  d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
                  fill="#4285F4"
                />
                <path
                  d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
                  fill="#34A853"
                />
                <path
                  d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
                  fill="#FBBC05"
                />
                <path
                  d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
                  fill="#EA4335"
                />
              </svg>
              Sign in with Google
            </Button>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}

next-authの設定も必要です。

import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";

// next-authの型拡張
declare module "next-auth" {
    interface Session {
        accessToken: string; // accessToken は必須で string 型
    }
}

const authHandler = NextAuth({
    providers: [
        GoogleProvider({
            clientId: process.env.GOOGLE_CLIENT_ID || "",
            clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
            authorization: {
                params: {
                    scope: "https://www.googleapis.com/auth/calendar openid",
                },
            },
        }),
    ],
    callbacks: {
        async jwt({ token, account, session }) {
            console.log("JWT Callback: account", account); // account の内容を確認

            // account に access_token が含まれているか確認
            if (account?.access_token) {
                token.accessToken = account.access_token; // access_token を token に保存
            } else {
                console.log("No access_token in account");
                // すでに token.accessToken が存在する場合、上書きしない
                token.accessToken = token.accessToken || ""; // 既存の accessToken を維持
            }

            return token;
        },

        async session({ session, token }) {
            console.log("Session Callback: token", token); // token の内容を確認

            // token に accessToken が含まれているか確認
            if (token.accessToken) {
                session.accessToken = String(token.accessToken); // token の accessToken を session に保存
            } else {
                console.log("No accessToken found in token");
                session.accessToken = ""; // 型エラーを回避するため空文字を設定
            }

            return session;
        },
    },
    secret: process.env.NEXTAUTH_SECRET as string, // 型アサーション
});

// GET メソッドに対応するエクスポートを追加
export { authHandler as GET, authHandler as POST };

講師自身のカレンダーでもデータを登録します。その時に、マスタカレンダーのメールアドレスを招待してもらうようにします。この場合のマスタカレンダーのメールアドレスは「takaya@bletainasus.com」です。

これで白い背景の他の講師の待機可能データが、マスタカレンダーに反映されます。

フロントエンド

  • 登録されたJsonデータを待機スケジュール表として表示
  • ポイントは、ChatBotReserveのコンポーネントにselectedSummaryの引数を持たせてuseEffectのトリガーにすることによって、リアルタイムでデータ反映させること
import { useState, useMemo, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import SignInPage from '@/auth/view/jwt/jwt-sign-in-view';
import ChatBotReserve from './chatbotReserve';

interface CalendarEvent {
  id: string;
  summary: string;
  start: {
    dateTime: string;
  };
  end: {
    dateTime: string;
  };
}

export interface SimplifiedEvent {
  start: string;
  end: string;
  teacher: string;
}

const generateColorFromId = () => {
  return '#22c55e';
};

export default function ScheduleTable() {
  const { data: session, status } = useSession();
  const [events, setEvents] = useState<CalendarEvent[]>([]);
  const [error, setError] = useState<string>('');
  const [date, setDate] = useState<string>(new Date().toISOString().split('T')[0]);
  const [filteredEvents, setFilteredEvents] = useState<CalendarEvent[]>([]);
  const [selectedTimeSlot, setSelectedTimeSlot] = useState<string | null>(null);
  const [selectedSummary, setSummary] = useState<string>('');

  // イベントを変換して講師名を含める
  const simplifiedEvents = useMemo<SimplifiedEvent[]>(() => {
    return filteredEvents.map((event) => ({
      start: event.start.dateTime,
      end: event.end.dateTime,
      teacher: event.summary || '未設定', // summaryを講師名として使用
    }));
  }, [filteredEvents]);

  const eventColors = useMemo(() => {
    const colors: Record<string, string> = {};
    filteredEvents.forEach((event) => {
      if (!colors[event.id]) {
        colors[event.id] = generateColorFromId();
      }
    });
    return colors;
  }, [filteredEvents]);

  useEffect(() => {
    const fetchEvents = async () => {
      try {
        if (!session?.accessToken) {
          setError('No access token available');
          return;
        }

        const response = await fetch('/api/getEvents', {
          headers: {
            Authorization: `Bearer ${session.accessToken}`,
          },
        });

        if (!response.ok) {
          const errorData = await response.json();
          throw new Error(errorData.error || 'Failed to fetch events');
        }

        const data = await response.json();
        setEvents(data);
      } catch (error) {
        setError(error instanceof Error ? error.message : 'Failed to fetch events');
        console.error('Error fetching events:', error);
      }
    };

    if (session?.accessToken) {
      fetchEvents();
    }
  }, [session, selectedSummary]);

  useEffect(() => {
    if (date) {
      const filtered = events.filter((event) => {
        const eventDate = new Date(event.start.dateTime).toLocaleDateString('ja-JP', {
          year: 'numeric',
          month: '2-digit',
          day: '2-digit',
        });
        const selectedDate = new Date(date).toLocaleDateString('ja-JP', {
          year: 'numeric',
          month: '2-digit',
          day: '2-digit',
        });
        return eventDate === selectedDate;
      });
      setFilteredEvents(filtered);
    } else {
      setFilteredEvents(events);
    }
  }, [date, events]);

  const timeSlots = Array.from({ length: 24 }, (_, i) => `${i}`);

  const eventsBySummary: Record<string, CalendarEvent[]> = {};
  filteredEvents.forEach((event) => {
    const summary = event.summary || 'No Title';
    if (!eventsBySummary[summary]) {
      eventsBySummary[summary] = [];
    }
    eventsBySummary[summary].push(event);
  });

  // 時間帯がクリックされたときの処理
  const handleTimeSlotClick = (time: string, summary: string) => {
    const dateObj = new Date(date);
    const formattedDate = `${dateObj.getMonth() + 1}月${dateObj.getDate()}日`;

    // 現在の時間を基に1時間後の時間を計算
    const startTime = new Date(dateObj);
    startTime.setHours(parseInt(time), 0, 0, 0); // 時間をセット

    const endTime = new Date(startTime);
    endTime.setHours(startTime.getHours() + 1); // 1時間後に設定

    const formattedStartTime = `${startTime.getHours()}時`;
    const formattedEndTime = `${endTime.getHours()}時`;

    // 時間帯を設定
    setSelectedTimeSlot(
      `${formattedDate} ${formattedStartTime} ~ ${formattedEndTime} ${summary}先生の予約`
    );
    setSummary(summary);
  };

  if (status === 'loading') {
    return <div>Loading...</div>;
  }

  if (status === 'unauthenticated') {
    return (
      <>
        <SignInPage />
        <div>Googleカレンダー共有許可の為にログインが必要です。</div>
      </>
    );
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <>
      <div>
        <h2>複数イベントのカレンダー</h2>
        <div>
          <input
            type="date"
            value={date}
            onChange={(e) => setDate(e.target.value)}
            placeholder="日付を選択"
          />
        </div>

        <table
          style={{
            tableLayout: 'fixed',
            width: '100%',
            borderCollapse: 'collapse',
          }}
        >
          <thead>
            <tr>
              <th style={{ width: '200px', textAlign: 'left' }}>講師名</th>
              {timeSlots.map((time) => (
                <th key={time} style={{ width: '25px', textAlign: 'center' }}>
                  {time}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {Object.keys(eventsBySummary).map((summary) => (
              <tr key={summary}>
                <td
                  style={{
                    width: '200px',
                    textAlign: 'left',
                    whiteSpace: 'nowrap',
                    overflow: 'hidden',
                    textOverflow: 'ellipsis',
                  }}
                >
                  {summary}
                </td>
                {timeSlots.map((time) => {
                  const timeInt = parseInt(time);
                  const eventsForSlot =
                    eventsBySummary[summary]?.filter((event) => {
                      const eventStartTime = new Date(event.start.dateTime).getHours();
                      const eventEndTime = new Date(event.end.dateTime).getHours();
                      return eventStartTime <= timeInt && eventEndTime > timeInt;
                    }) || [];

                  return (
                    <td
                      key={time}
                      style={{ width: '50px', textAlign: 'center', cursor: 'pointer' }}
                      onClick={() => handleTimeSlotClick(time, summary)}
                    >
                      {eventsForSlot.map((event) => (
                        <div
                          key={event.id}
                          style={{
                            backgroundColor: eventColors[event.id],
                            width: '100%',
                            height: '20px',
                            marginBottom: '5px',
                            borderRadius: '4px',
                          }}
                        />
                      ))}
                    </td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {selectedTimeSlot && (
        <ChatBotReserve
          setSummary={setSummary}
          selectedSummary={selectedSummary}
          selectedTimeSlot={selectedTimeSlot}
          filteredEvents={simplifiedEvents}
        />
      )}
    </>
  );
}

予約日時をクリックすることによって、チャットボットを表示して、その固定値を取るようにしています。これは講師紹介のAIに繋ぐための布石であり、モーダルで処理されたい方はもちろんそれでも構いません。基本的に最初の相談内容に沿って開発します。

予約用チャットボット

  • 「はい」か「いいえ」か確認する
  • 予約データを作成後、getEventsのAPIをselectedSummary経由でuseEffect
import { useState, useEffect } from 'react';
import {
  Box,
  Button,
  TextField,
  Typography,
  Paper,
  List,
  ListItem,
  ListItemText,
  IconButton,
  CircularProgress,
} from '@mui/material';
import { Iconify } from 'src/components/iconify';

// Type definitions
interface ChatBotReserveProps {
  filteredEvents: Array<{
    start: string;
    end: string;
    teacher: string;
  }>;
  selectedTimeSlot: string;
  selectedSummary: string;
  setSummary: React.Dispatch<React.SetStateAction<string>>; // Add this line
}

interface TimeInfo {
  date: Date;
  hour: number;
  teacher?: string; // 講師名を追加(オプショナル)
}

interface PendingReservation {
  date: Date;
  hour: number;
  teacher?: string;
  needsConfirmation: boolean;
}

interface ChatMessage {
  content: string;
  type: 'sent' | 'received';
}

const ChatBotReserve: React.FC<ChatBotReserveProps> = ({
  selectedSummary,
  selectedTimeSlot,
  filteredEvents,
  setSummary,
}) => {
  const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
  const [inputValue, setInputValue] = useState<string>('');
  const [pendingReservation, setPendingReservation] = useState<PendingReservation | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setInputValue(selectedTimeSlot);
    console.log(selectedSummary);
  }, [selectedTimeSlot]);

  const parseTimeFromInput = (input: string): TimeInfo | null => {
    const normalizedInput = input.replace(/[0-9]/g, (s) =>
      String.fromCharCode(s.charCodeAt(0) - 0xfee0)
    );

    const fullMatch = normalizedInput.match(/(\d+)月(\d+)日の(\d+)時/);
    const timeOnlyMatch = normalizedInput.match(/(\d+)時/);

    if (timeOnlyMatch) {
      const hour = parseInt(timeOnlyMatch[1]);
      if (hour >= 0 && hour <= 23) {
        if (filteredEvents.length > 0) {
          const firstEvent = new Date(filteredEvents[0].start);
          const date = new Date(
            firstEvent.getFullYear(),
            firstEvent.getMonth(),
            firstEvent.getDate()
          );
          date.setHours(hour, 0, 0, 0);
          return { date, hour };
        } else {
          setChatMessages((prev) => [
            ...prev,
            {
              content: '申し訳ありませんが、予約可能な時間が見つかりませんでした。',
              type: 'received',
            },
          ]);
          return null;
        }
      }
    }

    if (fullMatch) {
      const month = parseInt(fullMatch[1]) - 1;
      const day = parseInt(fullMatch[2]);
      const hour = parseInt(fullMatch[3]);

      if (hour >= 0 && hour <= 23) {
        const date = new Date();
        date.setMonth(month);
        date.setDate(day);
        date.setHours(hour, 0, 0, 0);

        return { date, hour };
      }
    }

    return null;
  };

  const handleUserInput = (input: string) => {
    if (isLoading) return;

    setChatMessages((prev) => [...prev, { content: input, type: 'sent' }]);

    const timeInfo = parseTimeFromInput(input);

    if (timeInfo) {
      const matchingEvents = filteredEvents.filter((event) => {
        const eventStart = new Date(event.start);
        const eventEnd = new Date(event.end);

        return (
          timeInfo.hour >= eventStart.getHours() &&
          timeInfo.hour < eventEnd.getHours() &&
          timeInfo.date.getDate() === eventStart.getDate() &&
          timeInfo.date.getMonth() === eventStart.getMonth()
        );
      });

      if (matchingEvents.length === 0) {
        setChatMessages((prev) => [
          ...prev,
          {
            content: `申し訳ありません。${timeInfo.hour}時は予約可能な時間がありません。`,
            type: 'received',
          },
        ]);
        return;
      }

      if (matchingEvents.length >= 1) {
        const [event] = matchingEvents;
        const eventStart = new Date(event.start);
        const eventEnd = new Date(event.end);

        // 時間帯を「何時〜何時」に変更
        const startTime = eventStart.getHours();
        const endTime = startTime + 1; // 1時間後

        const timeRange = `${startTime}時〜${endTime}時`;

        const newReservation: PendingReservation = {
          date: timeInfo.date,
          hour: timeInfo.hour,
          teacher: event.teacher,
          needsConfirmation: true,
        };

        setPendingReservation(newReservation);
        setChatMessages((prev) => [
          ...prev,
          {
            content: `${inputValue}でよろしいですか?(はい/いいえ)`,
            type: 'received',
          },
        ]);
        return;
      }
    }

    setChatMessages((prev) => [
      ...prev,
      {
        content:
          '時間の形式が正しくありません。もう一度「○月○日の○時」または「○時」で入力してください。',
        type: 'received',
      },
    ]);
  };

  const handleReservationConfirmation = (confirmation: string) => {
    if (pendingReservation) {
      if (confirmation === 'はい') {
        // 予約をAPIに送信
        setIsLoading(true);
        fetch('/api/chatbotreserved', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            date: pendingReservation.date.toISOString(),
            hour: pendingReservation.hour,
            teacher: selectedSummary,
            filteredEvents: filteredEvents,
          }),
        })
          .then((response) => response.json())
          .then((data) => {
            setIsLoading(false);
            setChatMessages((prev) => [
              ...prev,
              {
                content: '予約が完了しました!',
                type: 'received',
              },
            ]);
            setPendingReservation(null);
            setSummary('');
          })
          .catch((error) => {
            setIsLoading(false);
            setChatMessages((prev) => [
              ...prev,
              {
                content: '予約の送信中にエラーが発生しました。もう一度お試しください。',
                type: 'received',
              },
            ]);
          });
      } else {
        setChatMessages((prev) => [
          ...prev,
          {
            content: '予約をキャンセルしました。',
            type: 'received',
          },
        ]);
        setPendingReservation(null);
      }
    }
  };

  return (
    <Box>
      <IconButton
        style={{
          position: 'fixed',
          bottom: 16,
          right: 16,
          backgroundColor: '#1976d2',
          color: 'white',
          zIndex: 1000,
        }}
      >
        <Iconify icon="mdi:chat" fontSize={24} />
      </IconButton>

      <Paper
        elevation={3}
        style={{
          position: 'fixed',
          bottom: 80,
          right: 16,
          width: 420,
          maxWidth: '100%',
          padding: 16,
          zIndex: 1000,
        }}
      >
        <Box display="flex" justifyContent="space-between" alignItems="center">
          <Typography variant="h6">予約システム</Typography>
        </Box>

        <Box style={{ maxHeight: 300, overflowY: 'auto', marginTop: 16 }} component={List}>
          {chatMessages.map((msg, index) => (
            <ListItem
              key={index}
              style={{
                backgroundColor: msg.type === 'sent' ? '#e3f2fd' : '#f1f8e9',
                margin: '4px 0',
                borderRadius: 8,
                whiteSpace: 'pre-wrap',
              }}
            >
              <ListItemText primary={msg.content} />
            </ListItem>
          ))}
        </Box>

        <Box display="flex" alignItems="center" marginTop={2}>
          <TextField
            variant="outlined"
            placeholder="予約時間を入力(例:1月5日の14時、15時)"
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)}
            fullWidth
            disabled={isLoading}
          />
          <Button
            variant="contained"
            color="primary"
            style={{ marginLeft: 8, minWidth: 64 }}
            onClick={() => {
              if (inputValue.trim()) {
                handleUserInput(inputValue.trim());
                setInputValue('');
              }
            }}
            disabled={isLoading}
          >
            {isLoading ? <CircularProgress size={24} color="inherit" /> : '送信'}
          </Button>
        </Box>

        {/* 予約確認のUI */}
        {pendingReservation?.needsConfirmation && (
          <Box>
            <Button
              variant="contained"
              color="primary"
              onClick={() => handleReservationConfirmation('はい')}
              style={{ marginTop: 8 }}
            >
              はい
            </Button>
            <Button
              variant="outlined"
              color="secondary"
              onClick={() => handleReservationConfirmation('いいえ')}
              style={{ marginTop: 8, marginLeft: 8 }}
            >
              いいえ
            </Button>
          </Box>
        )}
      </Paper>
    </Box>
  );
};

export default ChatBotReserve;

完成しました。

まとめ

1日とは言わないまでも、2〜3日あればある程度の予約システムは作れるかもしれません。後は、どのユーザーでログインしてもこのマスタデータを流せるようにすることや、1時間しか認証が持たないようなので、それを継続させる調整などが必要になってきそうです。

今まで下記の開発をしてきました。

  1. PDFからQ&Aを自動生成するフィットネス会員用チャットボット
  2. PDFからQ&Aを自動生成するフィットネス同業他社教育用チャットボット
  3. 写真を画像認識してスコア取得するシステム
  4. 時短予約システム

AI縛りで繋ぎたかったものの今回のはかなりのボリュームでしたので、次回は英会話講師をリコメンドできるチャットボットをAIで開発したいと思います。

それではまた、ごきげんよう🍀

Discussion