0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

居酒屋の注文サイトは外注したら30万かかるらしい

Last updated at Posted at 2025-07-05

このガイドでは、居酒屋の注文管理システムをゼロから構築する完全なプロセスを説明します。初心者でも再現可能なように、段階的に進めていきます。

システム概要

・顧客向け機能: QRコードでテーブルから注文、カート機能、支払い
・管理者向け機能: メニュー管理、テーブル管理、リアルタイム注文監視
・リアルタイム通信: Server-Sent Events (SSE) による注文の即座反映

必要な技術スタック

・フロントエンド: Next.js 14, React 18, TypeScript
・スタイリング: Tailwind CSS
・データベース: SQLite (Prisma ORM)
・リアルタイム通信: Server-Sent Events (SSE)
・UI コンポーネント: Radix UI
・状態管理: Zustand

構築手順

ステップ1:初期設定

bush
# 1. Next.jsプロジェクトを作成
npx create-next-app@latest izakaya --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

# 2. プロジェクトディレクトリに移動
cd izakaya

# 3. 必要な依存関係をインストール
npm install @prisma/client @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tabs @radix-ui/react-toast @tanstack/react-query @trpc/client @trpc/react-query @trpc/server class-variance-authority clsx lucide-react qrcode.react socket.io socket.io-client tailwind-merge tailwindcss-animate zod zustand

# 4. 開発依存関係をインストール
npm install -D prisma tsx @types/node

ステップ2:データベース設定

bush
# 1. Prismaを初期化
npx prisma init

# 2. .envファイルを編集
echo "DATABASE_URL=\"file:./dev.db\"" > .env

ステップ3:データベーススキーマの作成
prisma/schema.prisma ファイルを作成

prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Category {
  id          String   @id @default(cuid())
  name        String
  description String?
  items       Item[]
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

model Item {
  id          String   @id @default(cuid())
  name        String
  description String?
  price       Int
  imageUrl    String?
  categoryId  String
  category    Category @relation(fields: [categoryId], references: [id])
  orderItems  OrderItem[]
  isAvailable Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

model Table {
  id        String   @id @default(cuid())
  number    Int      @unique
  status    String   @default("available")
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Order {
  id            String      @id @default(cuid())
  tableId       String
  status        String
  paymentStatus String      @default("pending")
  paymentMethod String?
  paidAt        DateTime?
  orderItems    OrderItem[]
  total         Int
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
}

model OrderItem {
  id        String   @id @default(cuid())
  orderId   String
  order     Order    @relation(fields: [orderId], references: [id])
  itemId    String
  item      Item     @relation(fields: [itemId], references: [id])
  quantity  Int
  price     Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model StaffCall {
  id        String   @id @default(cuid())
  tableId   String
  status    String
  message   String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

ステップ4:データベースの初期化

bush
# 1. マイグレーションを実行
npx prisma migrate dev --name init

# 2. Prismaクライアントを生成
npx prisma generate

ステップ 5: 初期データの作成
prisma/seed.ts ファイルを作成

seed.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // カテゴリを作成
  const beerCategory = await prisma.category.create({
    data: {
      name: 'ビール',
      description: '生ビール、瓶ビールなど',
    },
  });

  const sakeCategory = await prisma.category.create({
    data: {
      name: '日本酒',
      description: '熱燗、冷酒など',
    },
  });

  const foodCategory = await prisma.category.create({
    data: {
      name: '料理',
      description: 'おつまみ、メイン料理など',
    },
  });

  // 商品を作成
  await prisma.item.createMany({
    data: [
      {
        name: '生ビール',
        description: 'キレのある生ビール',
        price: 580,
        categoryId: beerCategory.id,
      },
      {
        name: '日本酒(熱燗)',
        description: '温かい日本酒',
        price: 480,
        categoryId: sakeCategory.id,
      },
      {
        name: '枝豆',
        description: '塩ゆで枝豆',
        price: 380,
        categoryId: foodCategory.id,
      },
      {
        name: '唐揚げ',
        description: 'サクサク唐揚げ',
        price: 580,
        categoryId: foodCategory.id,
      },
    ],
  });

  // テーブルを作成
  await prisma.table.createMany({
    data: [
      { number: 1 },
      { number: 2 },
      { number: 3 },
      { number: 4 },
    ],
  });

  console.log('Seed data created successfully');
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

ステップ 6: ユーティリティ関数の作成
src/lib/utils.ts ファイルを作成

utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export function formatPrice(price: number): string {
  return new Intl.NumberFormat('ja-JP', {
    style: 'currency',
    currency: 'JPY',
  }).format(price);
}

ステップ 7: Prismaクライアントの設定
src/lib/prisma.ts ファイルを作成

prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

ステップ 8: サーバーアクションの作成
src/app/actions.ts ファイルを作成

actions.ts
'use server';

import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

export async function createOrderAction(tableId: string, items: Array<{ itemId: string; quantity: number; price: number }>) {
  try {
    const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);

    const order = await prisma.order.create({
      data: {
        tableId,
        status: 'pending',
        total,
        orderItems: {
          create: items.map(item => ({
            itemId: item.itemId,
            quantity: item.quantity,
            price: item.price,
          })),
        },
      },
      include: {
        orderItems: {
          include: {
            item: true,
          },
        },
      },
    });

    revalidatePath('/admin/realtime-orders');
    return { success: true, order };
  } catch (error) {
    console.error('Error creating order:', error);
    return { success: false, error: '注文の作成に失敗しました' };
  }
}

export async function updateOrderStatusAction(orderId: string, status: string) {
  try {
    await prisma.order.update({
      where: { id: orderId },
      data: { status },
    });

    revalidatePath('/admin/realtime-orders');
    return { success: true };
  } catch (error) {
    console.error('Error updating order status:', error);
    return { success: false, error: 'ステータスの更新に失敗しました' };
  }
}

export async function createStaffCallAction(tableId: string, message?: string) {
  try {
    await prisma.staffCall.create({
      data: {
        tableId,
        status: 'pending',
        message,
      },
    });

    return { success: true };
  } catch (error) {
    console.error('Error creating staff call:', error);
    return { success: false, error: 'スタッフコールの作成に失敗しました' };
  }
}

ステップ 9: SSE APIエンドポイントの作成
src/app/api/orders/stream/route.ts ファイルを作成

routes.ts
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const sendData = (data: any) => {
    const message = `data: ${JSON.stringify(data)}\n\n`;
    return encoder.encode(message);
  };

  const stream = new ReadableStream({
    async start(controller) {
      try {
        controller.enqueue(sendData({
          type: 'connection_established',
          message: 'SSE connection established',
          timestamp: new Date().toISOString(),
        }));

        let lastOrderIds = new Set<string>();

        const initialOrders = await prisma.order.findMany({
          where: {
            status: {
              in: ['pending', 'preparing', 'ready'],
            },
          },
          include: {
            orderItems: {
              include: {
                item: true,
              },
            },
          },
          orderBy: {
            createdAt: 'desc',
          },
        });

        lastOrderIds = new Set(initialOrders.map(order => order.id));

        controller.enqueue(sendData({
          type: 'orders_update',
          orders: initialOrders,
          newOrderCount: 0,
          totalOrderCount: initialOrders.length,
          timestamp: new Date().toISOString(),
        }));

        const pollOrders = async () => {
          try {
            const orders = await prisma.order.findMany({
              where: {
                status: {
                  in: ['pending', 'preparing', 'ready'],
                },
              },
              include: {
                orderItems: {
                  include: {
                    item: true,
                  },
                },
              },
              orderBy: {
                createdAt: 'desc',
              },
            });

            const currentOrderIds = new Set(orders.map(order => order.id));
            const newOrders = orders.filter(order => !lastOrderIds.has(order.id));
            
            if (newOrders.length > 0) {
              controller.enqueue(sendData({
                type: 'new_orders',
                orders: newOrders,
                message: `${newOrders.length}件の新しい注文があります`,
                timestamp: new Date().toISOString(),
              }));
            }

            controller.enqueue(sendData({
              type: 'orders_update',
              orders,
              newOrderCount: newOrders.length,
              totalOrderCount: orders.length,
              timestamp: new Date().toISOString(),
            }));

            lastOrderIds = currentOrderIds;
          } catch (error) {
            console.error('Error polling orders:', error);
            controller.enqueue(sendData({
              type: 'error',
              message: '注文データの取得に失敗しました',
              timestamp: new Date().toISOString(),
            }));
          }
        };

        const interval = setInterval(pollOrders, 5000);

        request.signal.addEventListener('abort', () => {
          console.log('SSE: Client disconnected');
          clearInterval(interval);
          controller.close();
        });

      } catch (error) {
        console.error('SSE stream error:', error);
        controller.enqueue(sendData({
          type: 'error',
          message: 'ストリームの初期化に失敗しました',
          timestamp: new Date().toISOString(),
        }));
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': 'Cache-Control',
    },
  });
}

ステップ 10: UIコンポーネントの作成
Button コンポーネント (src/components/ui/button.tsx)

button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

ステップ 11: カート機能の実装
カートフック (src/hooks/use-cart.ts)

use-cart.ts
import { create } from 'zustand';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: { id: string; name: string; price: number }) => void;
  removeItem: (itemId: string) => void;
  updateQuantity: (itemId: string, quantity: number) => void;
  clearCart: () => void;
  getTotal: () => number;
  getItemCount: () => number;
}

export const useCart = create<CartStore>((set, get) => ({
  items: [],
  
  addItem: (item) => {
    set((state) => {
      const existingItem = state.items.find(i => i.id === item.id);
      if (existingItem) {
        return {
          items: state.items.map(i =>
            i.id === item.id
              ? { ...i, quantity: i.quantity + 1 }
              : i
          ),
        };
      }
      return {
        items: [...state.items, { ...item, quantity: 1 }],
      };
    });
  },
  
  removeItem: (itemId) => {
    set((state) => ({
      items: state.items.filter(i => i.id !== itemId),
    }));
  },
  
  updateQuantity: (itemId, quantity) => {
    set((state) => ({
      items: state.items.map(i =>
        i.id === itemId ? { ...i, quantity } : i
      ),
    }));
  },
  
  clearCart: () => {
    set({ items: [] });
  },
  
  getTotal: () => {
    const { items } = get();
    return items.reduce((total, item) => total + (item.price * item.quantity), 0);
  },
  
  getItemCount: () => {
    const { items } = get();
    return items.reduce((count, item) => count + item.quantity, 0);
  },
}));

ステップ 12: メインコンポーネントの実装
リアルタイム注文コンポーネント (src/components/admin/realtime-orders.tsx)

realtime-orders.tsx
'use client';

import { useEffect, useState, useRef } from 'react';
import { formatPrice } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { updateOrderStatusAction } from '@/app/actions';

interface OrderItem {
  id: string;
  item: {
    name: string;
  };
  quantity: number;
  price: number;
}

interface Order {
  id: string;
  tableId: string;
  status: string;
  total: number;
  createdAt: string;
  orderItems: OrderItem[];
}

interface RealtimeOrdersProps {
  initialOrders: Order[];
  tableMap: Map<string, number>;
}

export function RealtimeOrders({ initialOrders, tableMap }: RealtimeOrdersProps) {
  const [orders, setOrders] = useState<Order[]>(initialOrders);
  const [isConnected, setIsConnected] = useState(false);
  const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
  const [soundEnabled, setSoundEnabled] = useState(true);
  const [isClient, setIsClient] = useState(false);
  const [debugMessages, setDebugMessages] = useState<string[]>([]);
  const [newOrderNotification, setNewOrderNotification] = useState<string | null>(null);
  const [connectionError, setConnectionError] = useState<string | null>(null);
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const eventSourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    setIsClient(true);
    setLastUpdate(new Date());
  }, []);

  useEffect(() => {
    audioRef.current = new Audio('/notification.mp3');
    audioRef.current.volume = 0.5;
  }, []);

  const addDebugMessage = (message: string) => {
    setDebugMessages(prev => [...prev.slice(-9), `${new Date().toLocaleTimeString()}: ${message}`]);
  };

  const connectSSE = () => {
    try {
      addDebugMessage('SSE接続を開始します...');
      setConnectionError(null);
      
      const eventSource = new EventSource('/api/orders/stream');
      eventSourceRef.current = eventSource;

      eventSource.onopen = () => {
        setIsConnected(true);
        addDebugMessage('SSE接続が確立されました');
      };

      eventSource.onmessage = (event) => {
        try {
          const data = JSON.parse(event.data);
          addDebugMessage(`受信: ${data.type}`);
          
          switch (data.type) {
            case 'connection_established':
              addDebugMessage('接続確認メッセージを受信');
              break;
              
            case 'new_orders':
              addDebugMessage(`${data.orders.length}件の新しい注文を検出`);
              if (soundEnabled) {
                audioRef.current?.play().catch(console.error);
              }
              setNewOrderNotification(data.message);
              setTimeout(() => setNewOrderNotification(null), 5000);
              setOrders(data.orders);
              setLastUpdate(new Date(data.timestamp));
              break;
              
            case 'orders_update':
              if (data.newOrderCount > 0) {
                addDebugMessage(`${data.newOrderCount}件の新しい注文があります`);
              }
              setOrders(data.orders);
              setLastUpdate(new Date(data.timestamp));
              break;
              
            case 'error':
              addDebugMessage(`エラー: ${data.message}`);
              setConnectionError(data.message);
              break;
              
            default:
              addDebugMessage(`未知のメッセージタイプ: ${data.type}`);
          }
        } catch (error) {
          console.error('Error parsing SSE data:', error);
          addDebugMessage('SSEデータの解析に失敗しました');
        }
      };

      eventSource.onerror = (error) => {
        console.error('SSE error:', error);
        setIsConnected(false);
        setConnectionError('SSE接続エラーが発生しました');
        addDebugMessage('SSE接続エラーが発生しました');
        
        setTimeout(() => {
          if (eventSourceRef.current) {
            eventSourceRef.current.close();
            connectSSE();
          }
        }, 5000);
      };

    } catch (error) {
      console.error('Error creating SSE connection:', error);
      setConnectionError('SSE接続の作成に失敗しました');
      addDebugMessage('SSE接続の作成に失敗しました');
    }
  };

  useEffect(() => {
    if (isClient) {
      connectSSE();
    }

    return () => {
      if (eventSourceRef.current) {
        eventSourceRef.current.close();
        setIsConnected(false);
        addDebugMessage('SSE接続を閉じました');
      }
    };
  }, [isClient]);

  const handleStatusUpdate = async (orderId: string, newStatus: string) => {
    try {
      addDebugMessage(`注文ステータスを更新: ${orderId} -> ${newStatus}`);
      const result = await updateOrderStatusAction(orderId, newStatus);
      if (result.success) {
        setOrders(prevOrders =>
          prevOrders.map(order =>
            order.id === orderId ? { ...order, status: newStatus } : order
          )
        );
        addDebugMessage('ステータス更新が完了しました');
      }
    } catch (error) {
      console.error('Error updating order status:', error);
      addDebugMessage('ステータス更新に失敗しました');
    }
  };

  const getStatusText = (status: string) => {
    switch (status) {
      case 'pending': return '受付中';
      case 'preparing': return '調理中';
      case 'ready': return '提供準備完了';
      case 'completed': return '完了';
      case 'cancelled': return 'キャンセル';
      default: return status;
    }
  };

  const getStatusColor = (status: string) => {
    switch (status) {
      case 'pending': return 'bg-yellow-100 text-yellow-800';
      case 'preparing': return 'bg-blue-100 text-blue-800';
      case 'ready': return 'bg-green-100 text-green-800';
      case 'completed': return 'bg-gray-100 text-gray-800';
      case 'cancelled': return 'bg-red-100 text-red-800';
      default: return 'bg-gray-100 text-gray-800';
    }
  };

  return (
    <div className="space-y-4">
      {connectionError && (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
          <strong className="font-bold">接続エラー!</strong>
          <span className="block sm:inline"> {connectionError}</span>
          <button
            onClick={() => {
              setConnectionError(null);
              connectSSE();
            }}
            className="ml-2 text-sm underline"
          >
            再接続
          </button>
        </div>
      )}

      {newOrderNotification && (
        <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative animate-pulse">
          <strong className="font-bold">新しい注文!</strong>
          <span className="block sm:inline"> {newOrderNotification}</span>
        </div>
      )}

      <div className="flex justify-between items-center bg-white p-4 rounded-lg shadow-sm">
        <div className="flex items-center gap-4">
          <div className="flex items-center gap-2">
            <div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
            <span className="text-sm text-gray-600">
              {isConnected ? 'リアルタイム接続中' : '接続切れ'}
            </span>
          </div>
          <div className="flex items-center gap-2">
            <input
              type="checkbox"
              id="sound-toggle"
              checked={soundEnabled}
              onChange={(e) => setSoundEnabled(e.target.checked)}
              className="w-4 h-4"
            />
            <label htmlFor="sound-toggle" className="text-sm text-gray-600">
              通知音
            </label>
          </div>
        </div>
        <div className="text-sm text-gray-500">
          最終更新: {isClient && lastUpdate ? lastUpdate.toLocaleTimeString() : '読み込み中...'}
        </div>
      </div>

      <details className="bg-gray-100 p-4 rounded-lg">
        <summary className="cursor-pointer font-medium text-gray-700">デバッグ情報</summary>
        <div className="mt-2 space-y-1">
          {debugMessages.map((message, index) => (
            <div key={index} className="text-xs text-gray-600 font-mono">
              {message}
            </div>
          ))}
        </div>
      </details>

      {orders.length === 0 ? (
        <div className="bg-white rounded-lg shadow-md p-6 text-center">
          <p className="text-gray-600">現在の注文はありません。</p>
        </div>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {orders.map((order) => (
            <div key={order.id} className="bg-white rounded-lg shadow-md p-6 border-l-4 border-primary-500">
              <div className="flex justify-between items-center mb-4">
                <h2 className="text-xl font-semibold">
                  テーブル {tableMap.get(order.tableId) || order.tableId}
                </h2>
                <span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(order.status)}`}>
                  {getStatusText(order.status)}
                </span>
              </div>
              
              <p className="text-gray-600 mb-2">
                注文時刻: {isClient ? new Date(order.createdAt).toLocaleString() : '読み込み中...'}
              </p>
              
              <div className="mb-4">
                <h3 className="font-medium mb-2">注文内容:</h3>
                <ul className="list-disc list-inside text-sm text-gray-700">
                  {order.orderItems.map((orderItem) => (
                    <li key={orderItem.id}>
                      {orderItem.item.name} x {orderItem.quantity} ({formatPrice(orderItem.price)})
                    </li>
                  ))}
                </ul>
              </div>
              
              <div className="flex justify-between items-center mb-4">
                <p className="text-lg font-semibold">合計:</p>
                <p className="text-xl font-bold text-primary-600">
                  {formatPrice(order.total)}
                </p>
              </div>
              
              <div className="flex flex-col gap-2">
                {order.status === 'pending' && (
                  <Button
                    onClick={() => handleStatusUpdate(order.id, 'preparing')}
                    className="w-full"
                  >
                    調理開始
                  </Button>
                )}
                {order.status === 'preparing' && (
                  <Button
                    onClick={() => handleStatusUpdate(order.id, 'ready')}
                    className="w-full"
                  >
                    提供準備完了
                  </Button>
                )}
                {order.status === 'ready' && (
                  <Button
                    onClick={() => handleStatusUpdate(order.id, 'completed')}
                    className="w-full"
                  >
                    提供済み
                  </Button>
                )}
                {order.status !== 'completed' && order.status !== 'cancelled' && (
                  <Button
                    onClick={() => handleStatusUpdate(order.id, 'cancelled')}
                    variant="outline"
                    className="w-full"
                  >
                    キャンセル
                  </Button>
                )}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

ステップ 13: 管理者ページの作成
管理者レイアウト (src/app/admin/layout.tsx)

layout.tsx
import { prisma } from '@/lib/prisma';
import Link from 'next/link';

export default async function AdminLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-gray-50">
      <nav className="bg-white shadow-sm border-b">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
          <div className="flex justify-between h-16">
            <div className="flex">
              <div className="flex-shrink-0 flex items-center">
                <h1 className="text-xl font-semibold text-gray-900">居酒屋管理システム</h1>
              </div>
              <div className="hidden sm:ml-6 sm:flex sm:space-x-8">
                <Link
                  href="/admin"
                  className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
                >
                  ダッシュボード
                </Link>
                <Link
                  href="/admin/realtime-orders"
                  className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
                >
                  リアルタイム注文
                </Link>
                <Link
                  href="/admin/orders"
                  className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
                >
                  注文履歴
                </Link>
                <Link
                  href="/admin/items"
                  className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
                >
                  商品管理
                </Link>
                <Link
                  href="/admin/tables"
                  className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
                >
                  テーブル管理
                </Link>
              </div>
            </div>
          </div>
        </div>
      </nav>
      <main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
        {children}
      </main>
    </div>
  );
}

リアルタイム注文ページ (src/app/admin/realtime-orders/page.tsx)

page.tsx
import { prisma } from '@/lib/prisma';
import { RealtimeOrders } from '@/components/admin/realtime-orders';

export default async function RealtimeOrdersPage() {
  const orders = await prisma.order.findMany({
    where: {
      status: {
        in: ['pending', 'preparing', 'ready'],
      },
    },
    include: {
      orderItems: {
        include: {
          item: true,
        },
      },
    },
    orderBy: {
      createdAt: 'desc',
    },
  });

  const tables = await prisma.table.findMany();
  const tableMap = new Map(tables.map(table => [table.id, table.number]));

  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold text-gray-900">リアルタイム注文</h1>
        <p className="mt-1 text-sm text-gray-500">
          現在の注文状況をリアルタイムで監視できます
        </p>
      </div>
      
      <RealtimeOrders initialOrders={orders} tableMap={tableMap} />
    </div>
  );
}

ステップ 14: 通知音ファイルの追加
public/notification.mp3 ファイルを追加してください。短い通知音のMP3ファイルを用意して、このパスに配置します

bush
# 1. シードデータを実行
npm run prisma:seed

# 2. 開発サーバーを起動
npm run dev

使用方法

・管理者としての使用
・リアルタイム注文監視: /admin/realtime-orders にアクセス
・注文ステータス管理: 各注文カードのボタンでステータスを更新
・通知音: 新しい注文が来ると自動で通知音が鳴る

顧客としての使用

・QRコードスキャン: テーブルに設置されたQRコードをスキャン
・メニュー閲覧: カテゴリ別に商品を閲覧
・注文: カートに商品を追加して注文
・支払い: 注文完了後に支払い処理

カスタマイズポイント

  1. デザインのカスタマイズ
    tailwind.config.js でカラーテーマを変更
    コンポーネントのスタイリングを調整
  2. 機能の拡張
    支払い方法の追加
    在庫管理機能
    売上レポート機能
    スタッフ管理機能
  3. データベースの拡張
    ユーザー認証
    注文履歴の詳細化
    在庫テーブルの追加

注意事項
本番環境: このシステムは開発用です。本番環境では適切なセキュリティ対策が必要です
データベース: SQLiteは開発用です。本番ではPostgreSQLやMySQLを使用してください
通知音: 著作権に注意して通知音ファイルを用意してください

よくある問題
SSE接続エラー: ブラウザの設定でポップアップブロッカーを無効にしてください
通知音が鳴らない: ブラウザの音声設定を確認してください
データベースエラー: npx prisma migrate reset でデータベースをリセットしてください

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?