Files
keeppay/docs/superpowers/plans/2026-05-07-waiter-reservation-system.md
T
root 841faca34a feat: 添加项目规范和配置文件
- 新增项目规范文档,包含语言设置和 Git 提交规范
- 更新 .gitignore 文件,添加环境文件和数据目录
- 新增 Prettier 配置文件和忽略文件
- 更新 package.json,添加 prettier 和 prisma 相关依赖
- 新增 API 路由处理佣金和消费数据

这些更改为项目提供了更好的结构和代码风格规范。
2026-05-12 21:43:25 +08:00

61 KiB

服务员预约系统 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a PC-end waiter reservation management system with a Google Calendar-style monthly view.

Architecture: Single-page Next.js App Router app. Mock data layer with module-level variables (no backend). shadcn/ui for all UI components. Tailwind CSS v4 for styling.

Tech Stack: Next.js 16.2.5, React 19, TypeScript 5, Tailwind CSS v4, shadcn/ui, lucide-react


Task 1: Initialize shadcn/ui and install dependencies

Files:

  • Create: components.json

  • Create: lib/utils.ts

  • Modify: app/globals.css

  • Step 1: Install shadcn/ui dependencies

Run:

pnpm add class-variance-authority clsx tailwind-merge lucide-react
pnpm add @radix-ui/react-dialog @radix-ui/react-select @radix-ui/react-label @radix-ui/react-dropdown-menu @radix-ui/react-popover @radix-ui/react-slot @radix-ui/react-avatar @radix-ui/react-scroll-area
pnpm add -D @types/node
  • Step 2: Create lib/utils.ts
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
    return twMerge(clsx(inputs))
}
  • Step 3: Update app/globals.css — add CSS variables for shadcn/ui

app/globals.css:

@import 'tailwindcss';

@theme inline {
    --color-background: hsl(0 0% 100%);
    --color-foreground: hsl(222.2 84% 4.9%);
    --color-card: hsl(0 0% 100%);
    --color-card-foreground: hsl(222.2 84% 4.9%);
    --color-popover: hsl(0 0% 100%);
    --color-popover-foreground: hsl(222.2 84% 4.9%);
    --color-primary: hsl(222.2 47.4% 11.2%);
    --color-primary-foreground: hsl(210 40% 98%);
    --color-secondary: hsl(210 40% 96.1%);
    --color-secondary-foreground: hsl(222.2 47.4% 11.2%);
    --color-muted: hsl(210 40% 96.1%);
    --color-muted-foreground: hsl(215.4 16.3% 46.9%);
    --color-accent: hsl(210 40% 96.1%);
    --color-accent-foreground: hsl(222.2 47.4% 11.2%);
    --color-destructive: hsl(0 84.2% 60.2%);
    --color-destructive-foreground: hsl(210 40% 98%);
    --color-border: hsl(214.3 31.8% 91.4%);
    --color-input: hsl(214.3 31.8% 91.4%);
    --color-ring: hsl(222.2 84% 4.9%);
    --radius: 0.5rem;
    --font-sans: var(--font-geist-sans);
    --font-mono: var(--font-geist-mono);
}

body {
    background: var(--color-background);
    color: var(--color-foreground);
    font-family: Arial, Helvetica, sans-serif;
}
  • Step 4: Create components.json
{
    "$schema": "https://ui.shadcn.com/schema.json",
    "style": "default",
    "rsc": true,
    "tsx": true,
    "tailwind": {
        "config": "",
        "css": "app/globals.css",
        "baseColor": "slate",
        "cssVariables": true
    },
    "aliases": {
        "components": "@/components",
        "utils": "@/lib/utils"
    }
}
  • Step 5: Commit
git add components.json lib/utils.ts app/globals.css package.json pnpm-lock.yaml
git commit -m "feat: initialize shadcn/ui and install dependencies"

Task 2: Create shadcn/ui base components

Files:

  • Create: components/ui/button.tsx

  • Create: components/ui/dialog.tsx

  • Create: components/ui/input.tsx

  • Create: components/ui/label.tsx

  • Create: components/ui/select.tsx

  • Create: components/ui/dropdown-menu.tsx

  • Create: components/ui/table.tsx

  • Create: components/ui/card.tsx

  • Create: components/ui/badge.tsx

  • Create: components/ui/avatar.tsx

  • Create: components/ui/scroll-area.tsx

  • Step 1: Create all shadcn/ui component files

Create each file with standard shadcn/ui component code. These are headless UI primitives with no business logic.

components/ui/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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    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 }

components/ui/dialog.tsx:

"use client"

import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close

const DialogOverlay = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
      className
    )}
    {...props}
  />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName

const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"

const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"

const DialogTitle = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Title
    ref={ref}
    className={cn("text-lg font-semibold leading-none tracking-tight", className)}
    {...props}
  />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName

const DialogDescription = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Description
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName

export {
  Dialog,
  DialogPortal,
  DialogOverlay,
  DialogTrigger,
  DialogClose,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
}

components/ui/input.tsx:

import * as React from "react"
import { cn } from "@/lib/utils"

const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
  ({ className, type, ...props }, ref) => {
    return (
      <input
        type={type}
        className={cn(
          "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        ref={ref}
        {...props}
      />
    )
  }
)
Input.displayName = "Input"

export { Input }

components/ui/label.tsx:

"use client"

import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const labelVariants = cva(
  "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)

const Label = React.forwardRef<
  React.ElementRef<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
    VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
  <LabelPrimitive.Root
    ref={ref}
    className={cn(labelVariants(), className)}
    {...props}
  />
))
Label.displayName = LabelPrimitive.Root.displayName

export { Label }

components/ui/select.tsx:

"use client"

import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"

const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value

const SelectTrigger = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Trigger>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
  <SelectPrimitive.Trigger
    ref={ref}
    className={cn(
      "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
      className
    )}
    {...props}
  >
    {children}
    <SelectPrimitive.Icon asChild>
      <ChevronDown className="h-4 w-4 opacity-50" />
    </SelectPrimitive.Icon>
  </SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName

const SelectContent = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
  <SelectPrimitive.Portal>
    <SelectPrimitive.Content
      ref={ref}
      className={cn(
        "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        position === "popper" &&
          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
        className
      )}
      position={position}
      {...props}
    >
      <SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
        <ChevronUp className="h-4 w-4" />
      </SelectPrimitive.ScrollUpButton>
      <SelectPrimitive.Viewport
        className={cn(
          "p-1",
          position === "popper" &&
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
        )}
      >
        {children}
      </SelectPrimitive.Viewport>
      <SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
        <ChevronDown className="h-4 w-4" />
      </SelectPrimitive.ScrollDownButton>
    </SelectPrimitive.Content>
  </SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName

const SelectItem = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
  <SelectPrimitive.Item
    ref={ref}
    className={cn(
      "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
      className
    )}
    {...props}
  >
    <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
      <SelectPrimitive.ItemIndicator>
        <Check className="h-4 w-4" />
      </SelectPrimitive.ItemIndicator>
    </span>
    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
  </SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName

export {
  Select,
  SelectGroup,
  SelectValue,
  SelectTrigger,
  SelectContent,
  SelectItem,
}

components/ui/dropdown-menu.tsx:

"use client"

import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"

const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group

const DropdownMenuContent = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
  <DropdownMenuPrimitive.Portal>
    <DropdownMenuPrimitive.Content
      ref={ref}
      sideOffset={sideOffset}
      className={cn(
        "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        className
      )}
      {...props}
    />
  </DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName

const DropdownMenuItem = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
  <DropdownMenuPrimitive.Item
    ref={ref}
    className={cn(
      "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
      inset && "pl-8",
      className
    )}
    {...props}
  />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName

export {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuGroup,
}

components/ui/table.tsx:

import * as React from "react"
import { cn } from "@/lib/utils"

const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
  ({ className, ...props }, ref) => (
    <div className="relative w-full overflow-auto">
      <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
    </div>
  )
)
Table.displayName = "Table"

const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
  ({ className, ...props }, ref) => (
    <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
  )
)
TableHeader.displayName = "TableHeader"

const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
  ({ className, ...props }, ref) => (
    <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
  )
)
TableBody.displayName = "TableBody"

const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
  ({ className, ...props }, ref) => (
    <tr
      ref={ref}
      className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
      {...props}
    />
  )
)
TableRow.displayName = "TableRow"

const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
  ({ className, ...props }, ref) => (
    <th
      ref={ref}
      className={cn(
        "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
        className
      )}
      {...props}
    />
  )
)
TableHead.displayName = "TableHead"

const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
  ({ className, ...props }, ref) => (
    <td
      ref={ref}
      className={cn(
        "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
        className
      )}
      {...props}
    />
  )
)
TableCell.displayName = "TableCell"

export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }

components/ui/card.tsx:

import * as React from "react"
import { cn } from "@/lib/utils"

const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
  )
)
Card.displayName = "Card"

const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
  )
)
CardHeader.displayName = "CardHeader"

const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
  ({ className, ...props }, ref) => (
    <h3 ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
  )
)
CardTitle.displayName = "CardTitle"

const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
  )
)
CardContent.displayName = "CardContent"

export { Card, CardHeader, CardTitle, CardContent }

components/ui/badge.tsx:

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground shadow",
        secondary: "border-transparent bg-secondary text-secondary-foreground",
        destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
        outline: "text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

export interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
  return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

export { Badge, badgeVariants }

components/ui/avatar.tsx:

"use client"

import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"

const Avatar = React.forwardRef<
  React.ElementRef<typeof AvatarPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
  <AvatarPrimitive.Root
    ref={ref}
    className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
    {...props}
  />
))
Avatar.displayName = AvatarPrimitive.Root.displayName

const AvatarImage = React.forwardRef<
  React.ElementRef<typeof AvatarPrimitive.Image>,
  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
  <AvatarPrimitive.Image
    ref={ref}
    className={cn("aspect-square h-full w-full", className)}
    {...props}
  />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName

const AvatarFallback = React.forwardRef<
  React.ElementRef<typeof AvatarPrimitive.Fallback>,
  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
  <AvatarPrimitive.Fallback
    ref={ref}
    className={cn(
      "flex h-full w-full items-center justify-center rounded-full bg-muted",
      className
    )}
    {...props}
  />
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName

export { Avatar, AvatarImage, AvatarFallback }

components/ui/scroll-area.tsx:

"use client"

import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"

const ScrollArea = React.forwardRef<
  React.ElementRef<typeof ScrollAreaPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
  <ScrollAreaPrimitive.Root
    ref={ref}
    className={cn("relative overflow-hidden", className)}
    {...props}
  >
    <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
      {children}
    </ScrollAreaPrimitive.Viewport>
    <ScrollBar />
    <ScrollAreaPrimitive.Corner />
  </ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName

const ScrollBar = React.forwardRef<
  React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
  <ScrollAreaPrimitive.ScrollAreaScrollbar
    ref={ref}
    orientation={orientation}
    className={cn(
      "flex touch-none select-none transition-colors",
      orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
      orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
      className
    )}
    {...props}
  >
    <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
  </ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName

export { ScrollArea, ScrollBar }
  • Step 2: Build check

Run: pnpm build Expected: Build succeeds with no errors

  • Step 3: Commit
git add components/ui/
git commit -m "feat: add shadcn/ui base components"

Task 3: Core types and mock data

Files:

  • Create: lib/types.ts

  • Create: lib/date-utils.ts

  • Create: lib/mock-data.ts

  • Step 1: Create lib/types.ts

export type MediaType = 'image' | 'video'

export interface Media {
    id: string
    type: MediaType
    url: string
}

export interface Waiter {
    id: string
    name: string
    gallery: Media[]
    specialty: string
    commissionRate: number
}

export interface Reservation {
    id: string
    customerName: string
    date: string
    time: string
    waiterId: string
    package?: string
    note?: string
}

export interface CreateReservationInput {
    customerName: string
    date: string
    time: string
    waiterId: string
    package?: string
    note?: string
}

export interface UpdateReservationInput extends Partial<CreateReservationInput> {}

export interface CreateWaiterInput {
    name: string
    gallery: Media[]
    specialty: string
    commissionRate: number
}

export interface UpdateWaiterInput extends Partial<CreateWaiterInput> {}
  • Step 2: Create lib/date-utils.ts
export function getMonthGrid(year: number, month: number): Date[][] {
    const firstDay = new Date(year, month, 1)
    const startDay = firstDay.getDay()
    const startOffset = startDay === 0 ? -6 : 1 - startDay
    const startDate = new Date(year, month, 1 + startOffset)

    const weeks: Date[][] = []
    for (let w = 0; w < 6; w++) {
        const week: Date[] = []
        for (let d = 0; d < 7; d++) {
            const date = new Date(startDate)
            date.setDate(startDate.getDate() + w * 7 + d)
            week.push(date)
        }
        weeks.push(week)
    }
    return weeks
}

export function formatDate(date: Date): string {
    const y = date.getFullYear()
    const m = String(date.getMonth() + 1).padStart(2, '0')
    const d = String(date.getDate()).padStart(2, '0')
    return `${y}-${m}-${d}`
}

export function isSameDay(a: Date, b: Date): boolean {
    return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}

export function isCurrentMonth(date: Date, year: number, month: number): boolean {
    return date.getFullYear() === year && date.getMonth() === month
}

export function isToday(date: Date): boolean {
    return isSameDay(date, new Date())
}

export function formatTimeDisplay(date: Date): string {
    return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}

export function getMonthName(month: number): string {
    const names = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
    return names[month]
}
  • Step 3: Create lib/mock-data.ts
import type {
    Reservation,
    Waiter,
    CreateReservationInput,
    UpdateReservationInput,
    CreateWaiterInput,
    UpdateWaiterInput,
} from './types'

let waiters: Waiter[] = [
    {
        id: '1',
        name: '王小明',
        gallery: [
            { id: 'm1', type: 'image', url: '/placeholder.svg' },
            { id: 'm2', type: 'image', url: '/placeholder.svg' },
        ],
        specialty: '宴会/商务',
        commissionRate: 30,
    },
    {
        id: '2',
        name: '李小红',
        gallery: [{ id: 'm3', type: 'image', url: '/placeholder.svg' }],
        specialty: '婚宴/生日',
        commissionRate: 25,
    },
    {
        id: '3',
        name: '张三',
        gallery: [],
        specialty: '日常接待',
        commissionRate: 20,
    },
]

let reservations: Reservation[] = [
    {
        id: 'r1',
        customerName: '张伟',
        date: '2026-05-16',
        time: '11:00',
        waiterId: '1',
        package: '生日宴',
        note: '需要蛋糕',
    },
    { id: 'r2', customerName: '李芳', date: '2026-05-16', time: '12:30', waiterId: '2', package: '商务宴请' },
    { id: 'r3', customerName: '赵强', date: '2026-05-16', time: '18:00', waiterId: '1' },
    { id: 'r4', customerName: '刘梅', date: '2026-05-20', time: '11:30', waiterId: '2', package: '婚宴' },
    { id: 'r5', customerName: '陈先生', date: '2026-05-12', time: '10:00', waiterId: '3' },
    { id: 'r6', customerName: '王太太', date: '2026-05-12', time: '14:00', waiterId: '3', package: '下午茶' },
    { id: 'r7', customerName: '林总', date: '2026-05-14', time: '19:00', waiterId: '1', package: '商务宴' },
]

let waiterNextId = 4
let reservationNextId = 8

export function getReservationsByDate(date: string): Reservation[] {
    return reservations.filter((r) => r.date === date)
}

export function getReservationsByMonth(year: number, month: number): Reservation[] {
    const prefix = `${year}-${String(month + 1).padStart(2, '0')}`
    return reservations.filter((r) => r.date.startsWith(prefix))
}

export function createReservation(input: CreateReservationInput): Reservation {
    const reservation: Reservation = {
        id: `r${reservationNextId++}`,
        ...input,
    }
    reservations.push(reservation)
    return reservation
}

export function updateReservation(id: string, input: UpdateReservationInput): Reservation | null {
    const index = reservations.findIndex((r) => r.id === id)
    if (index === -1) return null
    reservations[index] = { ...reservations[index], ...input }
    return reservations[index]
}

export function deleteReservation(id: string): boolean {
    const index = reservations.findIndex((r) => r.id === id)
    if (index === -1) return false
    reservations.splice(index, 1)
    return true
}

export function getWaiters(): Waiter[] {
    return [...waiters]
}

export function getWaiterById(id: string): Waiter | undefined {
    return waiters.find((w) => w.id === id)
}

export function createWaiter(input: CreateWaiterInput): Waiter {
    const waiter: Waiter = {
        id: String(waiterNextId++),
        ...input,
    }
    waiters.push(waiter)
    return waiter
}

export function updateWaiter(id: string, input: UpdateWaiterInput): Waiter | null {
    const index = waiters.findIndex((w) => w.id === id)
    if (index === -1) return null
    waiters[index] = { ...waiters[index], ...input }
    return waiters[index]
}

export function deleteWaiter(id: string): boolean {
    const index = waiters.findIndex((w) => w.id === id)
    if (index === -1) return false
    waiters.splice(index, 1)
    reservations = reservations.filter((r) => r.waiterId !== id)
    return true
}
  • Step 4: Commit
git add lib/types.ts lib/date-utils.ts lib/mock-data.ts
git commit -m "feat: add core types and mock data layer"

Task 4: Create custom hooks

Files:

  • Create: hooks/use-reservations.ts

  • Step 1: Create hooks/use-reservations.ts

'use client'

import { useState, useCallback, useMemo } from 'react'
import {
    getReservationsByDate,
    getReservationsByMonth,
    createReservation,
    updateReservation,
    deleteReservation,
    getWaiters,
    getWaiterById,
} from '@/lib/mock-data'
import type { Reservation, Waiter, CreateReservationInput, UpdateReservationInput } from '@/lib/types'
import { createWaiter, updateWaiter, deleteWaiter } from '@/lib/mock-data'

export function useReservations(year: number, month: number) {
    const [, forceUpdate] = useState(0)
    const rerender = useCallback(() => forceUpdate((n) => n + 1), [])

    const monthReservations = useMemo(() => getReservationsByMonth(year, month), [year, month])

    const getReservationsForDate = useCallback(
        (date: string): Reservation[] => {
            return monthReservations.filter((r) => r.date === date)
        },
        [monthReservations],
    )

    const addReservation = useCallback(
        (input: CreateReservationInput): Reservation => {
            const r = createReservation(input)
            rerender()
            return r
        },
        [rerender],
    )

    const editReservation = useCallback(
        (id: string, input: UpdateReservationInput): Reservation | null => {
            const r = updateReservation(id, input)
            rerender()
            return r
        },
        [rerender],
    )

    const removeReservation = useCallback(
        (id: string): boolean => {
            const result = deleteReservation(id)
            rerender()
            return result
        },
        [rerender],
    )

    return {
        getReservationsForDate,
        addReservation,
        editReservation,
        removeReservation,
    }
}

export function useWaiters() {
    const [, forceUpdate] = useState(0)
    const rerender = useCallback(() => forceUpdate((n) => n + 1), [])

    const waiters = useMemo(() => getWaiters(), [])
    const waiterList = useCallback(() => getWaiters(), [])
    const getWaiter = useCallback((id: string) => getWaiterById(id), [])

    const addWaiter = useCallback(
        (input: Parameters<typeof createWaiter>[0]) => {
            const result = createWaiter(input)
            rerender()
            return result
        },
        [rerender],
    )

    const editWaiter = useCallback(
        (id: string, input: UpdateReservationInput) => {
            const result = updateWaiter(id, input)
            rerender()
            return result
        },
        [rerender],
    )

    const removeWaiter = useCallback(
        (id: string) => {
            const result = deleteWaiter(id)
            rerender()
            return result
        },
        [rerender],
    )

    return { waiterList, getWaiter, addWaiter, editWaiter, removeWaiter, waiters }
}
  • Step 2: Commit
git add hooks/use-reservations.ts
git commit -m "feat: add use-reservations hook"

Task 5: Create calendar components

Files:

  • Create: components/reservation-card.tsx

  • Create: components/calendar-cell.tsx

  • Create: components/calendar-grid.tsx

  • Create: components/calendar-header.tsx

  • Step 1: Create components/reservation-card.tsx

'use client'

import type { Reservation } from '@/lib/types'
import { getWaiterById } from '@/lib/mock-data'

interface ReservationCardProps {
  reservation: Reservation
  onClick: (reservation: Reservation) => void
}

export function ReservationCard({ reservation, onClick }: ReservationCardProps) {
  const waiter = getWaiterById(reservation.waiterId)

  return (
    <button
      onClick={(e) => {
        e.stopPropagation()
        onClick(reservation)
      }}
      className="w-full text-left text-[11px] leading-tight px-1 py-0.5 rounded-sm bg-blue-100 text-blue-800 hover:bg-blue-200 truncate border-l-2 border-blue-500 mb-0.5 cursor-pointer"
      title={`${reservation.customerName} - ${reservation.time}${waiter ? ` (${waiter.name})` : ''}${reservation.package ? ` [${reservation.package}]` : ''}`}
    >
      {reservation.time} {reservation.customerName}
    </button>
  )
}
  • Step 2: Create components/calendar-cell.tsx
'use client'

import type { Reservation } from '@/lib/types'
import { isCurrentMonth, isToday } from '@/lib/date-utils'
import { ReservationCard } from './reservation-card'

interface CalendarCellProps {
  date: Date
  year: number
  month: number
  reservations: Reservation[]
  selectedDate: string | null
  onSelectDate: (dateStr: string) => void
  onNewReservation: (dateStr: string) => void
  onEditReservation: (reservation: Reservation) => void
}

export function CalendarCell({
  date,
  year,
  month,
  reservations,
  selectedDate,
  onSelectDate,
  onNewReservation,
  onEditReservation,
}: CalendarCellProps) {
  const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
  const isOtherMonth = !isCurrentMonth(date, year, month)
  const isSelected = selectedDate === dateStr
  const today = isToday(date)
  const displayMore = reservations.length > 2

  return (
    <div
      onClick={() => onNewReservation(dateStr)}
      className={`min-h-[90px] border-r border-b border-gray-200 p-1 cursor-pointer transition-colors
        ${isOtherMonth ? 'bg-gray-50' : 'bg-white'}
        ${isSelected ? 'ring-2 ring-inset ring-blue-400' : ''}
        hover:bg-gray-50`}
    >
      <div className="flex justify-between items-start mb-0.5">
        <span
          className={`inline-flex items-center justify-center w-6 h-6 text-xs rounded-full
            ${today ? 'bg-blue-600 text-white font-bold' : ''}
            ${isOtherMonth ? 'text-gray-400' : 'text-gray-700'}`}
        >
          {date.getDate()}
        </span>
      </div>
      <div className="space-y-0.5">
        {reservations.slice(0, 2).map((r) => (
          <ReservationCard key={r.id} reservation={r} onClick={onEditReservation} />
        ))}
        {displayMore && (
          <span
            className="text-[11px] text-gray-500 px-1 cursor-pointer hover:text-gray-700"
            onClick={(e) => {
              e.stopPropagation()
              onSelectDate(dateStr)
            }}
          >
            +{reservations.length - 2} 更多
          </span>
        )}
      </div>
    </div>
  )
}
  • Step 3: Create components/calendar-grid.tsx
'use client'

import { useMemo } from 'react'
import type { Reservation } from '@/lib/types'
import { getMonthGrid } from '@/lib/date-utils'
import { CalendarCell } from './calendar-cell'

interface CalendarGridProps {
  year: number
  month: number
  reservationsByDate: Record<string, Reservation[]>
  selectedDate: string | null
  onSelectDate: (dateStr: string) => void
  onNewReservation: (dateStr: string) => void
  onEditReservation: (reservation: Reservation) => void
}

const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']

export function CalendarGrid({
  year,
  month,
  reservationsByDate,
  selectedDate,
  onSelectDate,
  onNewReservation,
  onEditReservation,
}: CalendarGridProps) {
  const weeks = useMemo(() => getMonthGrid(year, month), [year, month])

  return (
    <div className="flex-1 flex flex-col">
      <div className="grid grid-cols-7 border-t border-l border-gray-200">
        {WEEKDAY_NAMES.map((name) => (
          <div
            key={name}
            className="px-2 py-2 text-xs font-medium text-gray-500 border-r border-b border-gray-200 bg-gray-50/50 text-center"
          >
            {name}
          </div>
        ))}
      </div>
      <div className="grid grid-cols-7 border-l border-gray-200 flex-1">
        {weeks.flat().map((date, i) => {
          const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
          return (
            <CalendarCell
              key={i}
              date={date}
              year={year}
              month={month}
              reservations={reservationsByDate[dateStr] || []}
              selectedDate={selectedDate}
              onSelectDate={onSelectDate}
              onNewReservation={onNewReservation}
              onEditReservation={onEditReservation}
            />
          )
        })}
      </div>
    </div>
  )
}
  • Step 4: Create components/calendar-header.tsx
'use client'

import { getMonthName } from '@/lib/date-utils'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-react'

interface CalendarHeaderProps {
  year: number
  month: number
  onPrevMonth: () => void
  onNextMonth: () => void
}

export function CalendarHeader({ year, month, onPrevMonth, onNextMonth }: CalendarHeaderProps) {
  return (
    <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
      <div className="flex items-center gap-3">
        <h1 className="text-lg font-semibold text-gray-900">
          {year} {getMonthName(month)}
        </h1>
        <div className="flex items-center gap-1">
          <Button variant="outline" size="icon" onClick={onPrevMonth}>
            <ChevronLeft className="h-4 w-4" />
          </Button>
          <Button variant="outline" size="icon" onClick={onNextMonth}>
            <ChevronRight className="h-4 w-4" />
          </Button>
        </div>
      </div>
    </div>
  )
}
  • Step 5: Commit
git add components/calendar-header.tsx components/calendar-grid.tsx components/calendar-cell.tsx components/reservation-card.tsx
git commit -m "feat: add calendar components"

Task 6: Create reservation form dialog

Files:

  • Create: components/reservation-dialog.tsx

  • Step 1: Create components/reservation-dialog.tsx

'use client'

import { useState, useEffect } from 'react'
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'
import type { Reservation, CreateReservationInput } from '@/lib/types'
import { getWaiters } from '@/lib/mock-data'

interface ReservationDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  onSave: (input: CreateReservationInput) => void
  onDelete?: () => void
  editReservation?: Reservation | null
  defaultDate?: string
}

export function ReservationDialog({
  open,
  onOpenChange,
  onSave,
  onDelete,
  editReservation,
  defaultDate,
}: ReservationDialogProps) {
  const [customerName, setCustomerName] = useState('')
  const [date, setDate] = useState('')
  const [time, setTime] = useState('')
  const [waiterId, setWaiterId] = useState('')
  const [packageName, setPackageName] = useState('')
  const [note, setNote] = useState('')

  useEffect(() => {
    if (editReservation) {
      setCustomerName(editReservation.customerName)
      setDate(editReservation.date)
      setTime(editReservation.time)
      setWaiterId(editReservation.waiterId)
      setPackageName(editReservation.package || '')
      setNote(editReservation.note || '')
    } else {
      setCustomerName('')
      setDate(defaultDate || '')
      setTime('12:00')
      setWaiterId('')
      setPackageName('')
      setNote('')
    }
  }, [editReservation, defaultDate, open])

  const waiters = getWaiters()

  const handleSave = () => {
    if (!customerName || !date || !time || !waiterId) return
    onSave({
      customerName,
      date,
      time,
      waiterId,
      package: packageName || undefined,
      note: note || undefined,
    })
    onOpenChange(false)
  }

  const isEditing = !!editReservation

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>{isEditing ? '编辑预约' : '新建预约'}</DialogTitle>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label className="text-right">顾客姓名 *</Label>
            <Input
              className="col-span-3"
              value={customerName}
              onChange={(e) => setCustomerName(e.target.value)}
              placeholder="请输入顾客姓名"
            />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label className="text-right">日期 *</Label>
            <Input
              className="col-span-3"
              type="date"
              value={date}
              onChange={(e) => setDate(e.target.value)}
            />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label className="text-right">时间 *</Label>
            <Input
              className="col-span-3"
              type="time"
              value={time}
              onChange={(e) => setTime(e.target.value)}
            />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label className="text-right">服务员 *</Label>
            <Select value={waiterId} onValueChange={setWaiterId}>
              <SelectTrigger className="col-span-3">
                <SelectValue placeholder="请选择服务员" />
              </SelectTrigger>
              <SelectContent>
                {waiters.map((w) => (
                  <SelectItem key={w.id} value={w.id}>
                    {w.name}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label className="text-right">套餐</Label>
            <Input
              className="col-span-3"
              value={packageName}
              onChange={(e) => setPackageName(e.target.value)}
              placeholder="可选"
            />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label className="text-right">备注</Label>
            <Input
              className="col-span-3"
              value={note}
              onChange={(e) => setNote(e.target.value)}
              placeholder="可选"
            />
          </div>
        </div>
        <DialogFooter className="gap-2">
          {isEditing && onDelete && (
            <Button variant="destructive" onClick={onDelete}>
              删除
            </Button>
          )}
          <Button variant="outline" onClick={() => onOpenChange(false)}>
            取消
          </Button>
          <Button onClick={handleSave} disabled={!customerName || !date || !time || !waiterId}>
            保存
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
  • Step 2: Commit
git add components/reservation-dialog.tsx
git commit -m "feat: add reservation form dialog"

Task 7: Create waiter management dialog

Files:

  • Create: components/gallery-uploader.tsx

  • Create: components/waiter-form.tsx

  • Create: components/waiter-dialog.tsx

  • Step 1: Create components/gallery-uploader.tsx

'use client'

import type { Media } from '@/lib/types'
import { Button } from '@/components/ui/button'
import { X, Plus, Image, Video } from 'lucide-react'
import { Input } from '@/components/ui/input'

interface GalleryUploaderProps {
  media: Media[]
  onChange: (media: Media[]) => void
}

let mediaIdCounter = 100

export function GalleryUploader({ media, onChange }: GalleryUploaderProps) {
  const addImage = () => {
    const url = prompt('请输入图片 URL:')
    if (!url) return
    onChange([
      ...media,
      { id: `m${mediaIdCounter++}`, type: 'image', url },
    ])
  }

  const addVideo = () => {
    const url = prompt('请输入视频 URL:')
    if (!url) return
    onChange([
      ...media,
      { id: `m${mediaIdCounter++}`, type: 'video', url },
    ])
  }

  const removeMedia = (id: string) => {
    onChange(media.filter((m) => m.id !== id))
  }

  return (
    <div className="space-y-2">
      <div className="flex flex-wrap gap-2">
        {media.map((m) => (
          <div key={m.id} className="relative group">
            <div className="w-20 h-20 rounded-md border border-gray-200 flex items-center justify-center bg-gray-50 overflow-hidden">
              {m.type === 'image' ? (
                <img src={m.url} alt="" className="w-full h-full object-cover" />
              ) : (
                <Video className="h-8 w-8 text-gray-400" />
              )}
            </div>
            <button
              onClick={() => removeMedia(m.id)}
              className="absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
            >
              <X className="h-3 w-3" />
            </button>
          </div>
        ))}
        <Button variant="outline" size="icon" className="w-20 h-20" onClick={addImage}>
          <Image className="h-5 w-5" />
        </Button>
        <Button variant="outline" size="icon" className="w-20 h-20" onClick={addVideo}>
          <Video className="h-5 w-5" />
        </Button>
      </div>
    </div>
  )
}
  • Step 2: Create components/waiter-form.tsx
'use client'

import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { GalleryUploader } from './gallery-uploader'
import type { Waiter, CreateWaiterInput, Media } from '@/lib/types'

interface WaiterFormProps {
  waiter?: Waiter | null
  onSave: (input: CreateWaiterInput) => void
}

export function WaiterForm({ waiter, onSave }: WaiterFormProps) {
  const [name, setName] = useState('')
  const [specialty, setSpecialty] = useState('')
  const [commissionRate, setCommissionRate] = useState(20)
  const [gallery, setGallery] = useState<Media[]>([])

  useEffect(() => {
    if (waiter) {
      setName(waiter.name)
      setSpecialty(waiter.specialty)
      setCommissionRate(waiter.commissionRate)
      setGallery([...waiter.gallery])
    } else {
      setName('')
      setSpecialty('')
      setCommissionRate(20)
      setGallery([])
    }
  }, [waiter])

  const canSubmit = name && specialty

  const handleSubmit = (e?: React.FormEvent) => {
    e?.preventDefault()
    if (!canSubmit) return
    onSave({ name, specialty, commissionRate, gallery })
  }

  return (
    <div className="grid gap-4 py-4">
      <div className="grid grid-cols-4 items-center gap-4">
        <Label className="text-right">姓名 *</Label>
        <Input
          className="col-span-3"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="请输入姓名"
        />
      </div>
      <div className="grid grid-cols-4 items-center gap-4">
        <Label className="text-right">擅长领域 *</Label>
        <Input
          className="col-span-3"
          value={specialty}
          onChange={(e) => setSpecialty(e.target.value)}
          placeholder="如:宴会/商务"
        />
      </div>
      <div className="grid grid-cols-4 items-center gap-4">
        <Label className="text-right">分成比例 *</Label>
        <div className="col-span-3 flex items-center gap-2">
          <Input
            type="number"
            min={0}
            max={100}
            value={commissionRate}
            onChange={(e) => setCommissionRate(Number(e.target.value))}
          />
          <span className="text-sm text-gray-500">%</span>
        </div>
      </div>
      <div className="grid grid-cols-4 items-start gap-4">
        <Label className="text-right pt-2">照片/视频</Label>
        <div className="col-span-3">
          <GalleryUploader media={gallery} onChange={setGallery} />
        </div>
      </div>
      <div className="flex justify-end gap-2">
        <Button type="submit" onClick={() => handleSubmit()} disabled={!canSubmit}>
          保存
        </Button>
      </div>
    </div>
  )
}
  • Step 3: Create components/waiter-dialog.tsx
'use client'

import { useState } from 'react'
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'
import { Edit2, Trash2, Plus } from 'lucide-react'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { WaiterForm } from './waiter-form'
import type { Waiter, CreateWaiterInput, UpdateWaiterInput } from '@/lib/types'
import { getWaiters, createWaiter, updateWaiter, deleteWaiter } from '@/lib/mock-data'

export function WaiterDialog() {
  const [open, setOpen] = useState(false)
  const [waiters, setWaiters] = useState<Waiter[]>([])
  const [editingWaiter, setEditingWaiter] = useState<Waiter | null>(null)
  const [showForm, setShowForm] = useState(false)

  const refresh = () => {
    setWaiters(getWaiters())
  }

  const handleOpen = () => {
    setOpen(true)
    refresh()
    setShowForm(false)
    setEditingWaiter(null)
  }

  const handleSave = (input: CreateWaiterInput) => {
    if (editingWaiter) {
      updateWaiter(editingWaiter.id, input as UpdateWaiterInput)
    } else {
      createWaiter(input)
    }
    refresh()
    setShowForm(false)
    setEditingWaiter(null)
  }

  const handleDelete = (id: string) => {
    deleteWaiter(id)
    refresh()
  }

  const handleEdit = (waiter: Waiter) => {
    setEditingWaiter(waiter)
    setShowForm(true)
  }

  return (
    <Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) setShowForm(false) }}>
      <DialogTrigger asChild>
        <Button variant="ghost" onClick={handleOpen}>
          服务员管理
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[600px]">
        <DialogHeader>
          <DialogTitle className="flex items-center justify-between">
            <span>服务员管理</span>
            {!showForm && (
              <Button
                variant="outline"
                size="sm"
                onClick={() => {
                  setEditingWaiter(null)
                  setShowForm(true)
                }}
              >
                <Plus className="h-4 w-4 mr-1" /> 新增
              </Button>
            )}
          </DialogTitle>
        </DialogHeader>

        {showForm ? (
          <div>
            <WaiterForm waiter={editingWaiter} onSave={(input) => { handleSave(input); setShowForm(false) }} />
            <div className="flex justify-end gap-2 mt-4">
              <Button variant="outline" onClick={() => { setShowForm(false); setEditingWaiter(null) }}>
                返回
              </Button>
            </div>
          </div>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>姓名</TableHead>
                <TableHead>擅长领域</TableHead>
                <TableHead>分成比例</TableHead>
                <TableHead className="w-[80px]">操作</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {waiters.map((w) => (
                <TableRow key={w.id}>
                  <TableCell className="font-medium">{w.name}</TableCell>
                  <TableCell>{w.specialty}</TableCell>
                  <TableCell>{w.commissionRate}%</TableCell>
                  <TableCell>
                    <div className="flex items-center gap-1">
                      <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(w)}>
                        <Edit2 className="h-4 w-4" />
                      </Button>
                      <Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => handleDelete(w.id)}>
                        <Trash2 className="h-4 w-4" />
                      </Button>
                    </div>
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </DialogContent>
    </Dialog>
  )
}
  • Step 2: Commit
git add components/gallery-uploader.tsx components/waiter-form.tsx components/waiter-dialog.tsx
git commit -m "feat: add waiter management dialog"

Task 8: Assemble main page

Files:

  • Modify: app/layout.tsx

  • Modify: app/page.tsx

  • Step 1: Update app/layout.tsx

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Keeppay - 服务员预约系统",
  description: "服务员预约管理系统",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="zh-CN"
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    >
      <body className="h-full">{children}</body>
    </html>
  );
}
  • Step 2: Update app/page.tsx
'use client'

import { useState, useMemo } from 'react'
import { CalendarHeader } from '@/components/calendar-header'
import { CalendarGrid } from '@/components/calendar-grid'
import { ReservationDialog } from '@/components/reservation-dialog'
import { WaiterDialog } from '@/components/waiter-dialog'
import type { Reservation, CreateReservationInput } from '@/lib/types'
import { getReservationsByMonth, createReservation, updateReservation, deleteReservation } from '@/lib/mock-data'
import { Button } from '@/components/ui/button'

const today = new Date()

export default function Home() {
  const [year, setYear] = useState(today.getFullYear())
  const [month, setMonth] = useState(today.getMonth())
  const [reservationState, setReservationState] = useState(0)
  const [selectedDate, setSelectedDate] = useState<string | null>(null)
  const [dialogOpen, setDialogOpen] = useState(false)
  const [dialogDefaultDate, setDialogDefaultDate] = useState('')
  const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)

  const refresh = () => setReservationState((n) => n + 1)
  const monthReservations = useMemo(() => getReservationsByMonth(year, month), [year, month, reservationState])

  const reservationsByDate = useMemo(() => {
    const map: Record<string, Reservation[]> = {}
    for (const r of monthReservations) {
      if (!map[r.date]) map[r.date] = []
      map[r.date].push(r)
    }
    return map
  }, [monthReservations])

  const handleNewReservation = (dateStr: string) => {
    setEditingReservation(null)
    setDialogDefaultDate(dateStr)
    setDialogOpen(true)
  }

  const handleEditReservation = (reservation: Reservation) => {
    setEditingReservation(reservation)
    setDialogDefaultDate(reservation.date)
    setDialogOpen(true)
  }

  const handleSave = (input: CreateReservationInput) => {
    if (editingReservation) {
      updateReservation(editingReservation.id, input)
    } else {
      createReservation(input)
    }
    refresh()
  }

  const handleDelete = () => {
    if (editingReservation) {
      deleteReservation(editingReservation.id)
      refresh()
      setDialogOpen(false)
    }
  }

  return (
    <div className="h-full flex flex-col">
      {/* Top navigation */}
      <header className="flex items-center justify-between px-6 py-3 border-b border-gray-200 bg-white">
        <div className="flex items-center gap-8">
          <h1 className="text-base font-bold text-gray-900">Keeppay</h1>
          <nav className="flex items-center gap-1">
            <Button variant="ghost" size="sm" className="text-sm">
              预约管理
            </Button>
            <WaiterDialog />
          </nav>
        </div>
      </header>

      {/* Calendar */}
      <div className="flex-1 flex flex-col overflow-hidden">
        <CalendarHeader
          year={year}
          month={month}
          onPrevMonth={() => {
            if (month === 0) {
              setYear(year - 1)
              setMonth(11)
            } else {
              setMonth(month - 1)
            }
          }}
          onNextMonth={() => {
            if (month === 11) {
              setYear(year + 1)
              setMonth(0)
            } else {
              setMonth(month + 1)
            }
          }}
        />
        <CalendarGrid
          year={year}
          month={month}
          reservationsByDate={reservationsByDate}
          selectedDate={selectedDate}
          onSelectDate={setSelectedDate}
          onNewReservation={handleNewReservation}
          onEditReservation={handleEditReservation}
        />
      </div>

      {/* Reservation Dialog */}
      <ReservationDialog
        open={dialogOpen}
        onOpenChange={setDialogOpen}
        onSave={handleSave}
        onDelete={handleDelete}
        editReservation={editingReservation}
        defaultDate={dialogDefaultDate}
      />
    </div>
  )
}
  • Step 3: Commit
git add app/layout.tsx app/page.tsx
git commit -m "feat: assemble main page with calendar and dialogs"

Task 9: Build verification

  • Step 1: Run build

Run: pnpm build Expected: Build succeeds with no errors

  • Step 2: Fix any build errors (if any)

If errors occur, fix them and re-run build until it passes.

  • Step 3: Final commit
git add -A
git commit -m "fix: resolve build issues"