feat: 添加项目规范和配置文件

- 新增项目规范文档,包含语言设置和 Git 提交规范
- 更新 .gitignore 文件,添加环境文件和数据目录
- 新增 Prettier 配置文件和忽略文件
- 更新 package.json,添加 prettier 和 prisma 相关依赖
- 新增 API 路由处理佣金和消费数据

这些更改为项目提供了更好的结构和代码风格规范。
This commit is contained in:
2026-05-12 21:43:25 +08:00
parent 2944e03451
commit 841faca34a
87 changed files with 11012 additions and 1362 deletions
+36
View File
@@ -0,0 +1,36 @@
# 项目规范
## 语言设置
- 所有回复、注释、文档必须使用中文
## Git Commit 规范
- Commit message 遵循 Conventional Commits 规范
- Git commit message 必须使用中文编写
- 格式:
```
<type>(<scope>): <subject>
<body>
<footer>
```
- 类型:feat | fix | docs | style | refactor | perf | test | chore | ci | types | revert
- 范围(可选):表示改动影响的模块,按项目实际模块划分即可
- 描述:不超过 50 个字符,使用祈使语气,首字母不大写(英文时适用),结尾不加句号
- **Header**(必填):`类型(范围): 简短描述`
- **Body**(选填):详细说明改动动机和前后行为对比
- **Footer**(选填):不兼容变更说明(BREAKING CHANGE
- 示例:
```
feat(cart): 支持批量删除商品
- 添加多选交互
- 优化删除接口性能
- 更新测试用例
BREAKING CHANGE: 移除旧版删除接口
```
## 代码规范
- 变量和函数使用小驼峰命名(camelCase)
- 代码注释使用中文
+11 -2
View File
@@ -30,8 +30,15 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# env files
.env
.env.local
.env.production
.env.*.local
# data
/data/
# vercel
.vercel
@@ -39,3 +46,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/lib/generated/prisma
+13
View File
@@ -0,0 +1,13 @@
node_modules
.next
dist
build
.git
.superpowers
pnpm-lock.yaml
*.env
*.env.local
*.md
CHANGELOG*
LICENSE*
docs
+6
View File
@@ -0,0 +1,6 @@
{
"tabWidth": 4,
"printWidth": 120,
"singleQuote": true,
"semi": false
}
@@ -0,0 +1,235 @@
<h2>日历仪表盘布局 — 整体页面结构</h2>
<p class="subtitle">方案三:顶部导航 + 月历日历 + 右侧预约详情</p>
<div class="mockup" style="max-width: 960px">
<div
class="mockup-header"
style="
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #1a1a2e;
color: white;
border-radius: 8px 8px 0 0;
"
>
<span style="font-weight: 600">Keeppay · 服务员预约系统</span>
<div style="display: flex; gap: 16px; font-size: 13px">
<span style="opacity: 0.8">📅 预约管理</span>
<span style="opacity: 0.6">👤 服务员管理</span>
<span style="opacity: 0.6">⚙️ 设置</span>
</div>
</div>
<div style="display: flex; height: 420px">
<!-- Left: Calendar -->
<div style="flex: 1; padding: 20px; border-right: 1px solid #e5e7eb">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
<span style="font-size: 14px; cursor: pointer"></span>
<span style="font-weight: 600; font-size: 15px">2026年5月</span>
<span style="font-size: 14px; cursor: pointer"></span>
</div>
<div
style="
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
font-size: 12px;
color: #6b7280;
margin-bottom: 8px;
"
>
<span></span><span></span><span></span><span></span><span></span><span></span
><span></span>
</div>
<div
style="
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
font-size: 13px;
"
>
<!-- Days with booking indicators -->
<div style="padding: 8px 0; color: #9ca3af">28</div>
<div style="padding: 8px 0; color: #9ca3af">29</div>
<div style="padding: 8px 0; color: #9ca3af">30</div>
<div style="padding: 8px 0">1</div>
<div style="padding: 8px 0">2</div>
<div style="padding: 8px 0">3</div>
<div style="padding: 8px 0">4</div>
<div style="padding: 8px 0">5</div>
<div style="padding: 8px 0">6</div>
<div style="padding: 8px 0">7</div>
<div style="padding: 8px 0">8</div>
<div style="padding: 8px 0">9</div>
<div style="padding: 8px 0">10</div>
<div style="padding: 8px 0">11</div>
<div style="padding: 8px 0; position: relative">
12
<span
style="
position: absolute;
top: 2px;
right: 6px;
width: 6px;
height: 6px;
background: #ef4444;
border-radius: 50%;
"
></span>
</div>
<div style="padding: 8px 0">13</div>
<div style="padding: 8px 0; position: relative">
14
<span
style="
position: absolute;
top: 2px;
right: 6px;
width: 6px;
height: 6px;
background: #f59e0b;
border-radius: 50%;
"
></span>
</div>
<div style="padding: 8px 0">15</div>
<div style="padding: 8px 0; background: #3b82f6; color: white; border-radius: 6px; font-weight: 600">
16
</div>
<div style="padding: 8px 0; position: relative">
17
<span
style="
position: absolute;
top: 2px;
right: 6px;
width: 6px;
height: 6px;
background: #ef4444;
border-radius: 50%;
"
></span>
</div>
<div style="padding: 8px 0">18</div>
<div style="padding: 8px 0">19</div>
<div style="padding: 8px 0; position: relative">
20
<span
style="
position: absolute;
top: 2px;
right: 6px;
width: 6px;
height: 6px;
background: #22c55e;
border-radius: 50%;
"
></span>
</div>
<div style="padding: 8px 0">21</div>
<div style="padding: 8px 0">22</div>
<div style="padding: 8px 0">23</div>
<div style="padding: 8px 0">24</div>
<div style="padding: 8px 0">25</div>
<div style="padding: 8px 0">26</div>
<div style="padding: 8px 0">27</div>
<div style="padding: 8px 0">28</div>
<div style="padding: 8px 0">29</div>
<div style="padding: 8px 0">30</div>
<div style="padding: 8px 0">31</div>
<div style="padding: 8px 0; color: #9ca3af">1</div>
</div>
</div>
<!-- Right: Daily Bookings -->
<div style="width: 340px; padding: 16px 20px">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px">
<div>
<span style="font-weight: 600; font-size: 15px">5月16日 预约</span>
<span style="font-size: 12px; color: #6b7280; margin-left: 8px">共3条</span>
</div>
<button class="mock-button" style="padding: 4px 12px; font-size: 12px">+ 新建</button>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; background: #f9fafb">
<div style="display: flex; justify-content: space-between">
<span style="font-weight: 600; font-size: 14px">张伟</span>
<span
style="
font-size: 12px;
background: #dbeafe;
color: #2563eb;
padding: 2px 8px;
border-radius: 4px;
"
>11:00</span
>
</div>
<div style="font-size: 12px; color: #6b7280; margin-top: 6px">👤 服务员:王小明 · 📝 生日宴</div>
</div>
<div style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px">
<div style="display: flex; justify-content: space-between">
<span style="font-weight: 600; font-size: 14px">李芳</span>
<span
style="
font-size: 12px;
background: #fef3c7;
color: #d97706;
padding: 2px 8px;
border-radius: 4px;
"
>12:30</span
>
</div>
<div style="font-size: 12px; color: #6b7280; margin-top: 6px">👤 服务员:李小红 · 📝 商务宴请</div>
</div>
<div style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px">
<div style="display: flex; justify-content: space-between">
<span style="font-weight: 600; font-size: 14px">赵强</span>
<span
style="
font-size: 12px;
background: #dcfce7;
color: #16a34a;
padding: 2px 8px;
border-radius: 4px;
"
>18:00</span
>
</div>
<div style="font-size: 12px; color: #6b7280; margin-top: 6px">👤 服务员:王小明</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top: 24px">
<h3>交互说明</h3>
<ul style="font-size: 13px; color: #4b5563; line-height: 1.8">
<li><strong>日历</strong> — 有预约的日期显示彩色圆点标记,点击日期切换右侧预约列表</li>
<li><strong>右侧面板</strong> — 显示选中日期所有预约,每条可点击编辑或删除</li>
<li><strong>新建预约</strong> — 点击"新建"按钮弹出 Dialog 表单</li>
<li><strong>顶部导航</strong> — "服务员管理" 打开侧面板/弹窗进行 CRUD</li>
</ul>
</div>
<div class="options" style="margin-top: 16px">
<div class="option" data-choice="approve" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>布局看起来不错,继续详细设计</h3>
</div>
</div>
<div class="option" data-choice="revise" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>需要调整,我再补充意见</h3>
</div>
</div>
</div>
@@ -0,0 +1 @@
{"type":"server-started","port":61551,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:61551","screen_dir":"/Users/luoyangwei/Documents/workspace/keeppay/.superpowers/brainstorm/27538-1778155393/content","state_dir":"/Users/luoyangwei/Documents/workspace/keeppay/.superpowers/brainstorm/27538-1778155393/state"}
@@ -0,0 +1 @@
27538
+3
View File
@@ -0,0 +1,3 @@
{
"css.lint.unknownAtRules": "ignore"
}
+2
View File
@@ -1,5 +1,7 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { updateCommissionItem, deleteCommissionItem } from '@/lib/db'
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await request.json()
const { name, rate } = body
const item = await updateCommissionItem(id, {
...(name !== undefined ? { name } : {}),
...(rate !== undefined ? { rate: Math.min(100, Math.max(0, rate)) } : {}),
})
if (!item) {
return NextResponse.json({ error: 'Commission item not found' }, { status: 404 })
}
return NextResponse.json(item)
} catch (error) {
console.error('Failed to update commission item:', error)
return NextResponse.json({ error: 'Failed to update commission item' }, { status: 400 })
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const ok = await deleteCommissionItem(id)
if (!ok) {
return NextResponse.json({ error: 'Commission item not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete commission item:', error)
return NextResponse.json({ error: 'Failed to delete commission item' }, { status: 500 })
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { listCommissionItems, createCommissionItem, seedCommissionItems } from '@/lib/db'
export async function GET() {
try {
await seedCommissionItems()
const items = await listCommissionItems()
return NextResponse.json(items)
} catch (error) {
console.error('Failed to list commission items:', error)
return NextResponse.json({ error: 'Failed to list commission items' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { name, rate } = body
if (!name || typeof rate !== 'number') {
return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
}
const item = await createCommissionItem({ name, rate: Math.min(100, Math.max(0, rate)) })
return NextResponse.json(item, { status: 201 })
} catch (error) {
console.error('Failed to create commission item:', error)
return NextResponse.json({ error: 'Failed to create commission item' }, { status: 400 })
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import { updateConsumption, deleteConsumption } from '@/lib/db'
import type { ConsumptionState } from '@/lib/types'
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await request.json()
const item = await updateConsumption(id, body)
if (!item) {
return NextResponse.json({ error: 'Consumption not found' }, { status: 404 })
}
return NextResponse.json(item)
} catch (error) {
console.error('Failed to update consumption:', error)
return NextResponse.json({ error: 'Failed to update consumption' }, { status: 400 })
}
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await request.json()
const item = await updateConsumption(id, { state: body.state as ConsumptionState })
if (!item) {
return NextResponse.json({ error: 'Consumption not found' }, { status: 404 })
}
return NextResponse.json(item)
} catch (error) {
console.error('Failed to patch consumption:', error)
return NextResponse.json({ error: 'Failed to patch consumption' }, { status: 400 })
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const ok = await deleteConsumption(id)
if (!ok) {
return NextResponse.json({ error: 'Consumption not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete consumption:', error)
return NextResponse.json({ error: 'Failed to delete consumption' }, { status: 500 })
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { listConsumptions, createConsumption, seedConsumptions } from '@/lib/db'
export async function GET() {
try {
await seedConsumptions()
const items = await listConsumptions()
return NextResponse.json(items)
} catch (error) {
console.error('Failed to list consumptions:', error)
return NextResponse.json({ error: 'Failed to list consumptions' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const item = await createConsumption(body)
return NextResponse.json(item, { status: 201 })
} catch (error) {
console.error('Failed to create consumption:', error)
return NextResponse.json({ error: 'Failed to create consumption' }, { status: 400 })
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server'
import { updateReservation, deleteReservation } from '@/lib/db'
import { CreateReservationSchema } from '@/lib/schemas'
import type { ConsumptionState } from '@/lib/types'
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await request.json()
const parsed = CreateReservationSchema.partial().parse(body)
const reservation = await updateReservation(id, parsed)
if (!reservation) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 })
}
return NextResponse.json(reservation)
} catch (error) {
console.error('Failed to update reservation:', error)
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 400 })
}
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await request.json()
const reservation = await updateReservation(id, { state: body.state as ConsumptionState } as any)
if (!reservation) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 })
}
return NextResponse.json(reservation)
} catch (error) {
console.error('Failed to patch reservation:', error)
return NextResponse.json({ error: 'Failed to patch reservation' }, { status: 400 })
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const ok = await deleteReservation(id)
if (!ok) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete reservation:', error)
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 })
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { listReservations, createReservation, seedReservations } from '@/lib/db'
import { CreateReservationSchema } from '@/lib/schemas'
export async function GET(request: Request) {
try {
await seedReservations()
const { searchParams } = new URL(request.url)
const year = searchParams.get('year')
const month = searchParams.get('month')
const reservations = await listReservations(
year && month ? { year: Number(year), month: Number(month) } : undefined,
)
return NextResponse.json(reservations)
} catch (error) {
console.error('Failed to list reservations:', error)
return NextResponse.json({ error: 'Failed to list reservations' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const parsed = CreateReservationSchema.parse(body)
const reservation = await createReservation(parsed)
return NextResponse.json(reservation, { status: 201 })
} catch (error) {
console.error('Failed to create reservation:', error)
return NextResponse.json({ error: 'Failed to create reservation' }, { status: 400 })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server'
import { uploadFile } from '@/lib/oss'
export async function POST(request: Request) {
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
const folder = (formData.get('folder') as string) || 'uploads'
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const result = await uploadFile(file, folder)
return NextResponse.json(result)
} catch (error) {
console.error('Upload failed:', error)
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { getWaiter, updateWaiter, deleteWaiter } from '@/lib/db'
import { UpdateWaiterSchema } from '@/lib/schemas'
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const body = await request.json()
const result = UpdateWaiterSchema.safeParse(body)
if (!result.success) {
return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
}
const waiter = await updateWaiter(id, result.data)
if (!waiter) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(waiter)
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const deleted = await deleteWaiter(id)
if (!deleted) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server'
import { listWaiters, createWaiter, seedWaiters } from '@/lib/db'
import { CreateWaiterSchema } from '@/lib/schemas'
export async function GET() {
seedWaiters()
const waiters = await listWaiters()
return NextResponse.json(waiters)
}
export async function POST(request: Request) {
const body = await request.json()
const result = CreateWaiterSchema.safeParse(body)
if (!result.success) {
return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
}
const waiter = await createWaiter(result.data)
return NextResponse.json(waiter, { status: 201 })
}
+172 -27
View File
@@ -1,32 +1,177 @@
@import "tailwindcss";
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.81 0.1 252);
--chart-2: oklch(0.62 0.19 260);
--chart-3: oklch(0.55 0.22 263);
--chart-4: oklch(0.49 0.22 264);
--chart-5: oklch(0.42 0.18 266);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--radius: 0.6rem;
--shadow-x: 0;
--shadow-y: 1px;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-opacity: 0.1;
--shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.275 0 0);
--input: oklch(0.325 0 0);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.81 0.1 252);
--chart-2: oklch(0.62 0.19 260);
--chart-3: oklch(0.55 0.22 263);
--chart-4: oklch(0.49 0.22 264);
--chart-5: oklch(0.42 0.18 266);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.275 0 0);
--sidebar-ring: oklch(0.439 0 0);
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--radius: 0.6rem;
--shadow-x: 0;
--shadow-y: 1px;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-opacity: 0.1;
--shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
@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);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: Arial, Helvetica, sans-serif;
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+12 -26
View File
@@ -1,33 +1,19 @@
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"],
});
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: "Keeppay - 服务员预约系统",
description: "服务员预约管理系统",
};
title: 'Keeppay - 服务员预约系统',
description: '服务员预约管理系统',
}
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode
}>) {
return (
<html
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="h-full">{children}</body>
</html>
);
return (
<html lang="zh-CN" className="h-full antialiased">
<body className="h-full">{children}</body>
</html>
)
}
+40 -38
View File
@@ -4,77 +4,77 @@ 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 { NavHeader } from '@/components/nav-header'
import { useReservations } from '@/hooks/use-reservations'
import type { Reservation, CreateReservationInput } from '@/lib/types'
import { getReservationsByMonth, createReservation, updateReservation, deleteReservation } from '@/lib/mock-data'
import { Button } from '@/components/ui/button'
import { formatDate } from '@/lib/date-utils'
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 { getReservationsForDate, refresh: refreshReservations } = useReservations(year, month)
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 monthReservations = useMemo(() => {
const daysInMonth = new Date(year, month + 1, 0).getDate()
const map: Record<string, Reservation[]> = {}
for (const r of monthReservations) {
if (!map[r.date]) map[r.date] = []
map[r.date].push(r)
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const dayReservations = getReservationsForDate(dateStr)
if (dayReservations.length > 0) {
map[dateStr] = dayReservations
}
}
return map
}, [monthReservations])
}, [year, month, getReservationsForDate])
const handleNewReservation = (dateStr: string) => {
const handleNewReservation = (dateStr?: string) => {
setEditingReservation(null)
setDialogDefaultDate(dateStr)
setDialogDefaultDate(dateStr || formatDate(today))
setDialogOpen(true)
}
const handleToday = () => {
setYear(today.getFullYear())
setMonth(today.getMonth())
}
const handleEditReservation = (reservation: Reservation) => {
setEditingReservation(reservation)
setDialogDefaultDate(reservation.date)
setDialogOpen(true)
}
const handleSave = (input: CreateReservationInput) => {
const handleSave = async (input: CreateReservationInput) => {
if (editingReservation) {
updateReservation(editingReservation.id, input)
await fetch(`/api/reservation/${editingReservation.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
} else {
createReservation(input)
await fetch('/api/reservation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
}
refresh()
refreshReservations()
}
const handleDelete = () => {
if (editingReservation) {
deleteReservation(editingReservation.id)
refresh()
setDialogOpen(false)
}
const handleDelete = async (reservation: Reservation) => {
await fetch(`/api/reservation/${reservation.id}`, { method: 'DELETE' })
refreshReservations()
}
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>
<NavHeader />
{/* Calendar */}
<div className="flex-1 flex flex-col overflow-hidden">
@@ -97,15 +97,18 @@ export default function Home() {
setMonth(month + 1)
}
}}
onToday={handleToday}
onNewReservation={() => handleNewReservation()}
/>
<CalendarGrid
year={year}
month={month}
reservationsByDate={reservationsByDate}
reservationsByDate={monthReservations}
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
onNewReservation={handleNewReservation}
onEditReservation={handleEditReservation}
onDeleteReservation={handleDelete}
/>
</div>
@@ -114,7 +117,6 @@ export default function Home() {
open={dialogOpen}
onOpenChange={setDialogOpen}
onSave={handleSave}
onDelete={handleDelete}
editReservation={editingReservation}
defaultDate={dialogDefaultDate}
/>
+504
View File
@@ -0,0 +1,504 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { NavHeader } from '@/components/nav-header'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { DataTable } from '@/components/data-table'
import { DatePickerWithRange } from '@/components/date-range-picker'
import { CommissionSettings } from '@/components/commission-settings'
import { ConsumptionDialog } from '@/components/consumption-dialog'
import { ReservationDialog } from '@/components/reservation-dialog'
import { DeletePopover } from '@/components/delete-popover'
import type { ConsumptionRecord, Reservation, CommissionItem, CreateReservationInput } from '@/lib/types'
import { buildProfitData, calcTotals } from '@/lib/profit-utils'
import {
getWeekStart,
getWeekEnd,
getMonthStart,
getMonthEnd,
getYearStart,
getYearEnd,
isDateInRange,
} from '@/lib/date-utils'
import type { DateRange } from 'react-day-picker'
import { Plus, Edit2 } from 'lucide-react'
type DateFilter = 'today' | 'week' | 'month' | 'year' | 'all' | 'custom'
const FILTER_OPTIONS: { value: DateFilter; label: string }[] = [
{ value: 'today', label: '今日' },
{ value: 'week', label: '本周' },
{ value: 'month', label: '本月' },
{ value: 'year', label: '本年' },
{ value: 'all', label: '全部' },
]
const today = new Date()
function getFilterRange(filter: DateFilter): { start: Date; end: Date } | null {
switch (filter) {
case 'today':
return {
start: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
end: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999),
}
case 'week':
return { start: getWeekStart(today), end: getWeekEnd(today) }
case 'month':
return { start: getMonthStart(today), end: getMonthEnd(today) }
case 'year':
return { start: getYearStart(today), end: getYearEnd(today) }
case 'all':
return null
default:
return null
}
}
const STATE_COLORS: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
'待分账': 'outline',
'已分账': 'secondary',
}
export default function ProfitPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [filter, setFilter] = useState<DateFilter>('month')
const monthStart = getMonthStart(today)
const monthEnd = getMonthEnd(today)
const [dateRange, setDateRange] = useState<DateRange | undefined>({ from: monthStart, to: monthEnd })
const [consumptionOpen, setConsumptionOpen] = useState(false)
const [editingConsumption, setEditingConsumption] = useState<ConsumptionRecord | null>(null)
const [reservationOpen, setReservationOpen] = useState(false)
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [commissions, setCommissions] = useState<CommissionItem[]>([])
useEffect(() => {
fetch('/api/commission')
.then((r) => r.json())
.then(setCommissions)
.catch(() => {})
}, [refreshKey])
const [allReservations, setAllReservations] = useState<Reservation[]>([])
const [waiterMap, setWaiterMap] = useState<Record<string, { name: string; commissionRate: number }>>({})
const [allConsumptions, setAllConsumptions] = useState<ConsumptionRecord[]>([])
useEffect(() => {
fetch('/api/reservation')
.then((r) => r.json())
.then(setAllReservations)
.catch(() => {})
}, [refreshKey])
useEffect(() => {
fetch('/api/waiter')
.then((r) => r.json())
.then((list: any[]) => {
const map: Record<string, { name: string; commissionRate: number }> = {}
for (const w of list) map[w.id] = { name: w.name, commissionRate: w.commissionRate }
setWaiterMap(map)
})
.catch(() => {})
}, [refreshKey])
useEffect(() => {
fetch('/api/consumption')
.then((r) => r.json())
.then(setAllConsumptions)
.catch(() => {})
}, [refreshKey])
const handleDateRange = (v: DateFilter) => {
setFilter(v)
const range = getFilterRange(v)
if (range) {
setDateRange({ from: range.start, to: range.end })
}
}
const filteredReservations = useMemo(() => {
if (!dateRange?.from || !dateRange?.to) return allReservations
return allReservations.filter((r) => isDateInRange(r.date, dateRange.from!, dateRange.to!))
}, [allReservations, dateRange])
const filteredConsumptions = useMemo(() => {
if (!dateRange?.from || !dateRange?.to) return allConsumptions
return allConsumptions.filter((r) => {
const d = new Date(r.date + 'T00:00:00')
return d >= dateRange.from! && d <= dateRange.to!
})
}, [allConsumptions, dateRange])
const reservationItems = useMemo(
() => buildProfitData(filteredReservations, commissions, waiterMap),
[filteredReservations, commissions, waiterMap],
)
const consumptionMapped = useMemo(
() =>
filteredConsumptions.map((c) => ({
type: 'consumption' as const,
id: c.id,
customerName: c.customerName,
date: c.date,
time: c.time,
waiterName: c.waiterName,
package: '-',
state: c.state,
serviceFee: c.amount,
waiterEarnings: c.waiterEarnings,
waiterCommission: c.commissionRate,
commissionBreakdown: commissions.map((ci) => ({
id: ci.id,
name: ci.name,
rate: ci.rate,
earnings: Math.round((c.amount * ci.rate) / 100),
})),
_consumption: c,
})),
[filteredConsumptions, commissions],
)
const allItems = useMemo(() => {
const items = [
...reservationItems.map((i) => ({
type: 'reservation' as const,
id: i.reservation.id,
customerName: i.reservation.customerName,
date: i.reservation.date,
time: i.reservation.time,
waiterName: i.waiterName,
package: i.reservation.package || '-',
state: i.reservation.state || '待分账',
serviceFee: i.serviceFee,
waiterEarnings: i.waiterEarnings,
waiterCommission: i.waiterCommission,
commissionBreakdown: i.commissionBreakdown,
})),
...consumptionMapped,
]
items.sort((a, b) => b.date.localeCompare(a.date) || b.time.localeCompare(a.time))
return items
}, [reservationItems, consumptionMapped])
const totals = useMemo(() => {
const totalServiceFee = allItems.reduce((s, i) => s + i.serviceFee, 0)
const totalWaiterEarnings = allItems.reduce((s, i) => s + i.waiterEarnings, 0)
const totalCommissionEarnings: Record<string, number> = {}
for (const ci of commissions) {
totalCommissionEarnings[ci.id] = 0
}
for (const item of allItems) {
for (const cb of item.commissionBreakdown) {
totalCommissionEarnings[cb.id] = (totalCommissionEarnings[cb.id] || 0) + cb.earnings
}
}
return { totalServiceFee, totalWaiterEarnings, totalCommissionEarnings }
}, [allItems, commissions])
// --- Consumption handlers ---
const handleConsumptionSave = async (data: Omit<ConsumptionRecord, 'id'>) => {
if (editingConsumption) {
await fetch(`/api/consumption/${editingConsumption.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
} else {
await fetch('/api/consumption', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
setEditingConsumption(null)
setRefreshKey((n) => n + 1)
}
const handleEditConsumption = (record: ConsumptionRecord) => {
setEditingConsumption(record)
setConsumptionOpen(true)
}
const handleDeleteConsumption = async (id: string) => {
await fetch(`/api/consumption/${id}`, { method: 'DELETE' })
setRefreshKey((n) => n + 1)
}
const handleSettleConsumption = async (id: string) => {
await fetch(`/api/consumption/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: '已分账' }),
})
setRefreshKey((n) => n + 1)
}
// --- Reservation handlers ---
const handleEditReservation = (record: Reservation) => {
setEditingReservation(record)
setReservationOpen(true)
}
const handleReservationSave = async (input: CreateReservationInput) => {
if (editingReservation) {
await fetch(`/api/reservation/${editingReservation.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
}
setEditingReservation(null)
setRefreshKey((n) => n + 1)
}
const handleDeleteReservation = async (id: string) => {
await fetch(`/api/reservation/${id}`, { method: 'DELETE' })
setRefreshKey((n) => n + 1)
}
const handleSettleReservation = async (id: string) => {
await fetch(`/api/reservation/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: '已分账' }),
})
setRefreshKey((n) => n + 1)
}
const columns: ColumnDef<(typeof allItems)[number]>[] = [
{ id: 'customerName', header: '顾客', enableSorting: true, accessorKey: 'customerName' },
{ id: 'date', header: '日期', enableSorting: true, accessorKey: 'date' },
{ id: 'time', header: '时间', enableSorting: true, accessorKey: 'time' },
{ id: 'waiterName', header: '服务员', enableSorting: true, accessorKey: 'waiterName' },
{ id: 'package', header: '类型', enableSorting: true, accessorKey: 'package' },
{
id: 'state',
header: '状态',
enableSorting: true,
cell: ({ row }) => (
<Badge variant={STATE_COLORS[row.original.state] || 'outline'}>{row.original.state}</Badge>
),
},
{
id: 'serviceFee',
header: '金额',
enableSorting: true,
accessorKey: 'serviceFee',
cell: ({ row }) => `¥${row.original.serviceFee.toLocaleString()}`,
},
{
id: 'waiterEarnings',
header: '服务员分成',
enableSorting: true,
accessorKey: 'waiterEarnings',
cell: ({ row }) => (
<span>
¥{row.original.waiterEarnings.toLocaleString()}
<span className="text-xs text-muted-foreground ml-1">({row.original.waiterCommission}%)</span>
</span>
),
},
{
id: 'totalCommission',
header: '分红合计',
enableSorting: true,
cell: ({ row }) => {
const total = row.original.commissionBreakdown.reduce((s, c) => s + c.earnings, 0)
return `¥${total.toLocaleString()}`
},
},
{
id: 'actions',
header: '操作',
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-1">
{row.original.type === 'reservation' ? (
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => handleEditReservation(allReservations.find((r) => r.id === row.original.id)!)}
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => handleEditConsumption((row.original as any)._consumption)}
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
</Button>
)}
{row.original.state === '待分账' && (
<Button
variant="outline"
size="sm"
className="h-7"
onClick={() =>
row.original.type === 'reservation'
? handleSettleReservation(row.original.id)
: handleSettleConsumption(row.original.id)
}
>
</Button>
)}
<DeletePopover
onConfirm={() =>
row.original.type === 'reservation'
? handleDeleteReservation(row.original.id)
: handleDeleteConsumption(row.original.id)
}
/>
</div>
),
},
]
return (
<div className="h-full flex flex-col">
<NavHeader />
<div className="flex-1 flex flex-col p-6 gap-4 overflow-hidden">
{/* Filter */}
<div className="flex items-center gap-2 shrink-0 flex-wrap">
{FILTER_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant={filter === opt.value ? 'default' : 'outline'}
size="sm"
onClick={() => handleDateRange(opt.value)}
>
{opt.label}
</Button>
))}
<DatePickerWithRange
date={dateRange}
onDateChange={(d) => {
setDateRange(d)
setFilter('custom')
}}
/>
<div className="flex-1" />
<Button size="sm" onClick={() => setConsumptionOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{/* Stats cards: fixed + dynamic commission cards */}
<div className="flex gap-4 shrink-0 flex-wrap">
<Card className="flex-1 min-w-[160px]">
<CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="text-xl font-bold text-foreground mt-1">
¥{totals.totalServiceFee.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{allItems.length} </div>
</CardContent>
</Card>
<Card className="flex-1 min-w-[160px]">
<CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="text-xl font-bold text-foreground mt-1">
¥{totals.totalWaiterEarnings.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{' '}
{totals.totalServiceFee
? ((totals.totalWaiterEarnings / totals.totalServiceFee) * 100).toFixed(1)
: 0}
%
</div>
</CardContent>
</Card>
{commissions.map((c) => (
<Card key={c.id} className="flex-1 min-w-[160px]">
<CardContent className="p-4">
<div className="text-xs text-muted-foreground">{c.name}</div>
<div className="text-xl font-bold text-foreground mt-1">
¥{(totals.totalCommissionEarnings[c.id] || 0).toLocaleString()}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{c.rate}% · {' '}
{totals.totalServiceFee
? (
((totals.totalCommissionEarnings[c.id] || 0) / totals.totalServiceFee) *
100
).toFixed(1)
: 0}
%
</div>
</CardContent>
</Card>
))}
<CommissionSettings
items={commissions}
onAdd={async (name, rate) => {
await fetch('/api/commission', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, rate }),
})
setRefreshKey((n) => n + 1)
}}
onUpdate={async (id, name, rate) => {
await fetch(`/api/commission/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, rate }),
})
setRefreshKey((n) => n + 1)
}}
onDelete={async (id) => {
await fetch(`/api/commission/${id}`, { method: 'DELETE' })
setRefreshKey((n) => n + 1)
}}
/>
</div>
{/* Table */}
<div className="flex-1 flex flex-col min-h-0">
<DataTable
columns={columns}
data={allItems}
pageSize={100}
searchKey="customerName"
searchPlaceholder="搜索顾客姓名..."
tableMinWidth="1300px"
/>
</div>
</div>
<ConsumptionDialog
open={consumptionOpen}
onOpenChange={(v) => {
setConsumptionOpen(v)
if (!v) setEditingConsumption(null)
}}
onSave={handleConsumptionSave}
editRecord={editingConsumption}
/>
<ReservationDialog
open={reservationOpen}
onOpenChange={(v) => {
setReservationOpen(v)
if (!v) setEditingReservation(null)
}}
onSave={handleReservationSave}
editReservation={editingReservation}
defaultDate=""
/>
</div>
)
}
+231
View File
@@ -0,0 +1,231 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { NavHeader } from '@/components/nav-header'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DataTable } from '@/components/data-table'
import { WaiterForm } from '@/components/waiter-form'
import { Edit2, Trash2, Plus, Minus } from 'lucide-react'
import type { Waiter, CreateWaiterInput, UpdateWaiterInput } from '@/lib/types'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
: 'default',
: 'secondary',
: 'destructive',
: 'outline',
}
async function fetchWaiters(): Promise<Waiter[]> {
const res = await fetch('/api/waiter')
return res.json()
}
async function createWaiterApi(data: CreateWaiterInput): Promise<Waiter> {
const res = await fetch('/api/waiter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
return res.json()
}
async function updateWaiterApi(id: string, data: UpdateWaiterInput): Promise<Waiter> {
const res = await fetch(`/api/waiter/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
return res.json()
}
async function deleteWaiterApi(id: string): Promise<void> {
await fetch(`/api/waiter/${id}`, { method: 'DELETE' })
}
export default function WaiterPage() {
const [waiters, setWaiters] = useState<Waiter[]>([])
const [loading, setLoading] = useState(true)
const [editingWaiter, setEditingWaiter] = useState<Waiter | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await fetchWaiters()
setWaiters(data)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const handleSave = async (input: CreateWaiterInput) => {
if (editingWaiter) {
await updateWaiterApi(editingWaiter.id, input as UpdateWaiterInput)
} else {
await createWaiterApi(input)
}
setDialogOpen(false)
setEditingWaiter(null)
load()
}
const handleQuickRate = async (id: string, delta: number) => {
const waiter = waiters.find((w) => w.id === id)
if (!waiter) return
const newRate = Math.min(100, Math.max(0, waiter.commissionRate + delta))
await updateWaiterApi(id, { commissionRate: newRate } as UpdateWaiterInput)
load()
}
const handleDelete = async (id: string) => {
if (!confirm('确定要删除该服务员吗?')) return
await deleteWaiterApi(id)
load()
}
const columns: ColumnDef<Waiter>[] = [
{
id: 'name',
header: '姓名',
enableSorting: true,
cell: ({ row }) => (
<div className="flex items-center gap-2">
{row.original.avatar ? (
<img
src={row.original.avatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = 'none'
}}
/>
) : (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs text-muted-foreground">
{row.original.name[0]}
</div>
)}
<span>{row.original.name}</span>
</div>
),
},
{
id: 'specialty',
header: '擅长领域',
enableSorting: true,
accessorKey: 'specialty',
cell: ({ row }) => (
<div className="line-clamp-2 whitespace-pre-wrap max-w-[200px] text-sm" title={row.original.specialty}>
{row.original.specialty}
</div>
),
},
{
id: 'commissionRate',
header: '分成比例',
enableSorting: true,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleQuickRate(row.original.id, -1)}
disabled={row.original.commissionRate <= 0}
>
<Minus className="h-3 w-3" />
</Button>
<span className="w-10 text-center text-sm tabular-nums">{row.original.commissionRate}%</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleQuickRate(row.original.id, 1)}
disabled={row.original.commissionRate >= 100}
>
<Plus className="h-3 w-3" />
</Button>
</div>
),
},
{
accessorKey: 'status',
header: '状态',
enableSorting: true,
cell: ({ row }) => (
<Badge variant={STATUS_COLORS[row.original.status] || 'outline'}>{row.original.status}</Badge>
),
},
{
id: 'actions',
header: '操作',
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => { setEditingWaiter(row.original); setDialogOpen(true) }}
>
<Edit2 className="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() => handleDelete(row.original.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
),
},
]
return (
<div className="h-full flex flex-col">
<NavHeader />
<div className="flex-1 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground"></h2>
<Button
size="sm"
onClick={() => {
setEditingWaiter(null)
setDialogOpen(true)
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{loading ? (
<div className="text-center text-muted-foreground py-12">...</div>
) : (
<DataTable
columns={columns}
data={waiters}
pageSize={10}
searchKey="name"
searchPlaceholder="搜索服务员姓名..."
/>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingWaiter ? '编辑服务员' : '新增服务员'}</DialogTitle>
</DialogHeader>
<WaiterForm waiter={editingWaiter} onSave={handleSave} />
</DialogContent>
</Dialog>
</div>
)
}
+14 -14
View File
@@ -1,16 +1,16 @@
{
"$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"
}
"$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"
}
}
+93 -56
View File
@@ -1,69 +1,106 @@
'use client'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Plus } from 'lucide-react'
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
date: Date
year: number
month: number
reservations: Reservation[]
selectedDate: string | null
onSelectDate: (dateStr: string) => void
onNewReservation: (dateStr: string) => void
onEditReservation: (reservation: Reservation) => void
onDeleteReservation?: (reservation: Reservation) => void
}
export function CalendarCell({
date,
year,
month,
reservations,
selectedDate,
onSelectDate,
onNewReservation,
onEditReservation,
date,
year,
month,
reservations,
selectedDate,
onSelectDate,
onNewReservation,
onEditReservation,
onDeleteReservation,
}: 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
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>
)
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
onClick={() => onNewReservation(dateStr)}
className={`min-h-[90px] border-r border-b p-1 cursor-pointer transition-colors
${isOtherMonth ? 'bg-muted/30' : 'bg-background'}
${isSelected ? 'ring-2 ring-inset ring-ring' : ''}
hover:bg-muted/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-primary text-primary-foreground font-bold' : ''}
${isOtherMonth ? 'text-muted-foreground/50' : 'text-foreground/70'}`}
>
{date.getDate()}
</span>
</div>
<div className="space-y-0.5">
{reservations.slice(0, 2).map((r) => (
<ReservationCard
key={r.id}
reservation={r}
onClick={onEditReservation}
onEdit={onEditReservation}
onDelete={onDeleteReservation}
/>
))}
{displayMore && (
<span
className="text-[11px] text-muted-foreground px-1 cursor-pointer hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onSelectDate(dateStr)
}}
>
+{reservations.length - 2}
</span>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuItem onClick={() => onNewReservation(dateStr)}>
<Plus className="mr-2 h-4 w-4" />
</ContextMenuItem>
{reservations.length > 0 && (
<>
<ContextMenuSeparator />
{reservations.map((r) => (
<ContextMenuItem key={r.id} onClick={() => onEditReservation(r)}>
<span className="truncate">{r.customerName}</span>
<span className="ml-auto text-xs text-muted-foreground">{r.time}</span>
</ContextMenuItem>
))}
</>
)}
</ContextMenuContent>
</ContextMenu>
)
}
+50 -47
View File
@@ -6,58 +6,61 @@ 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
year: number
month: number
reservationsByDate: Record<string, Reservation[]>
selectedDate: string | null
onSelectDate: (dateStr: string) => void
onNewReservation: (dateStr: string) => void
onEditReservation: (reservation: Reservation) => void
onDeleteReservation?: (reservation: Reservation) => void
}
const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
export function CalendarGrid({
year,
month,
reservationsByDate,
selectedDate,
onSelectDate,
onNewReservation,
onEditReservation,
year,
month,
reservationsByDate,
selectedDate,
onSelectDate,
onNewReservation,
onEditReservation,
onDeleteReservation,
}: CalendarGridProps) {
const weeks = useMemo(() => getMonthGrid(year, month), [year, month])
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>
)
return (
<div className="flex-1 flex flex-col">
<div className="grid grid-cols-7 border-t border-l">
{WEEKDAY_NAMES.map((name) => (
<div
key={name}
className="px-2 py-2 text-xs font-medium text-muted-foreground border-r border-b bg-muted/30 text-center"
>
{name}
</div>
))}
</div>
<div className="grid grid-cols-7 border-l 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}
onDeleteReservation={onDeleteReservation}
/>
)
})}
</div>
</div>
)
}
+44 -22
View File
@@ -2,31 +2,53 @@
import { getMonthName } from '@/lib/date-utils'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
interface CalendarHeaderProps {
year: number
month: number
onPrevMonth: () => void
onNextMonth: () => void
year: number
month: number
onPrevMonth: () => void
onNextMonth: () => void
onToday: () => void
onNewReservation: () => 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>
export function CalendarHeader({
year,
month,
onPrevMonth,
onNextMonth,
onToday,
onNewReservation,
}: CalendarHeaderProps) {
const isCurrentMonthView = (() => {
const now = new Date()
return year === now.getFullYear() && month === now.getMonth()
})()
return (
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-foreground">
{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>
{!isCurrentMonthView && (
<Button variant="outline" size="sm" onClick={onToday}>
</Button>
)}
</div>
<Button size="sm" onClick={onNewReservation}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</div>
)
)
}
+77
View File
@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
interface ComboboxProps {
options: { value: string; label: string }[]
value: string
onChange: (value: string) => void
placeholder?: string
emptyText?: string
className?: string
}
export function Combobox({
options,
value,
onChange,
placeholder = '选择...',
emptyText = '无匹配项',
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false)
const selected = options.find((o) => o.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'w-full justify-between h-9 text-sm font-normal',
!value && 'text-muted-foreground',
className,
)}
>
{selected ? selected.label : <span>{placeholder}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput placeholder={`搜索${placeholder}...`} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={opt.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? '' : currentValue)
setOpen(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === opt.value ? 'opacity-100' : 'opacity-0',
)}
/>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+143
View File
@@ -0,0 +1,143 @@
'use client'
import { useState } 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 { Card, CardContent } from '@/components/ui/card'
import { DeletePopover } from '@/components/delete-popover'
import { Plus, Edit2, Settings2 } from 'lucide-react'
import type { CommissionItem } from '@/lib/types'
interface CommissionSettingsProps {
items: CommissionItem[]
onAdd: (name: string, rate: number) => void
onUpdate: (id: string, name: string, rate: number) => void
onDelete: (id: string) => void
}
export function CommissionSettings({ items, onAdd, onUpdate, onDelete }: CommissionSettingsProps) {
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<{ id?: string; name: string; rate: number } | null>(null)
const handleSave = () => {
if (!editing || !editing.name.trim()) return
const rate = Math.min(100, Math.max(0, editing.rate))
if (editing.id) {
onUpdate(editing.id, editing.name, rate)
} else {
onAdd(editing.name, rate)
}
setEditing(null)
}
return (
<>
<Card className="min-w-[180px]">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground"></span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setOpen(true)}>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{items.length} </div>
<div className="text-xs text-muted-foreground">
{items.map((c) => `${c.name} ${c.rate}%`).join(' · ')}
</div>
</div>
</CardContent>
</Card>
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v)
if (!v) setEditing(null)
}}
>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="flex items-center gap-2 p-2 rounded-md border">
<div className="flex-1 text-sm">{item.name}</div>
<div className="text-sm text-muted-foreground w-12 text-right">{item.rate}%</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setEditing({ id: item.id, name: item.name, rate: item.rate })}
>
<Edit2 className="h-3.5 w-3.5" />
</Button>
<DeletePopover onConfirm={() => onDelete(item.id)} />
</div>
))}
{items.length === 0 && (
<div className="text-sm text-muted-foreground text-center py-4"></div>
)}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setEditing({ name: '', rate: 10 })}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
{editing && (
<div className="border rounded-md p-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
className="h-8 text-sm"
placeholder="如: 张三分红"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> (%)</Label>
<Input
type="number"
min={0}
max={100}
value={editing.rate}
onChange={(e) => setEditing({ ...editing, rate: Number(e.target.value) })}
className="h-8 text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSave}>
{editing.id ? '保存修改' : '添加'}
</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(null)}>
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
onClick={() => {
setOpen(false)
setEditing(null)
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+251
View File
@@ -0,0 +1,251 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
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 { Combobox } from '@/components/combobox'
import { DatePicker } from '@/components/date-picker'
import { format } from 'date-fns'
import type { ConsumptionRecord, Waiter, Reservation, CommissionItem, ConsumptionState } from '@/lib/types'
interface ConsumptionDialogProps {
open: boolean
onOpenChange: (v: boolean) => void
onSave: (record: Omit<ConsumptionRecord, 'id'>) => void
editRecord?: ConsumptionRecord | null
}
const today = new Date()
const todayStr = today.toISOString().slice(0, 10)
const nowStr = `${String(today.getHours()).padStart(2, '0')}:${String(today.getMinutes()).padStart(2, '0')}`
export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: ConsumptionDialogProps) {
const [customerName, setCustomerName] = useState('')
const [date, setDate] = useState(todayStr)
const [time, setTime] = useState(nowStr)
const [waiterId, setWaiterId] = useState('')
const [amount, setAmount] = useState('')
const [state, setState] = useState<ConsumptionState>('待分账')
useEffect(() => {
if (editRecord) {
setCustomerName(editRecord.customerName)
setDate(editRecord.date)
setTime(editRecord.time)
setWaiterId(editRecord.waiterId)
setAmount(String(editRecord.amount))
setState(editRecord.state || '待分账')
} else {
setCustomerName('')
setDate(todayStr)
setTime(nowStr)
setWaiterId('')
setAmount('')
setState('待分账')
}
}, [editRecord, open])
const [waiters, setWaiters] = useState<Waiter[]>([])
const [reservations, setReservations] = useState<Reservation[]>([])
const [commissions, setCommissions] = useState<CommissionItem[]>([])
useEffect(() => {
Promise.all([
fetch('/api/waiter').then((r) => r.json()),
fetch('/api/reservation').then((r) => r.json()),
fetch('/api/commission').then((r) => r.json()),
])
.then(([w, r, c]) => {
setWaiters(w)
setReservations(r)
setCommissions(c)
})
.catch(() => {})
}, [open])
const customerOptions = useMemo(() => {
const names = new Set(reservations.map((r) => r.customerName))
return Array.from(names).map((n) => ({ value: n, label: n }))
}, [reservations])
const selectedNameData = useMemo(() => {
if (!customerName) return null
return reservations.find((r) => r.customerName === customerName)
}, [customerName, reservations])
const selectedWaiter = useMemo(() => waiters.find((w) => w.id === waiterId), [waiterId, waiters])
const numAmount = Number(amount) || 0
const handleNameChange = (name: string) => {
setCustomerName(name)
const record = reservations.find((r) => r.customerName === name)
if (record) {
setDate(record.date)
setTime(record.time)
}
}
const handleSave = () => {
if (!customerName || !waiterId || !amount) return
const waiter = waiters.find((w) => w.id === waiterId)
if (!waiter) return
const amt = Number(amount)
if (isNaN(amt) || amt <= 0) return
onSave({
customerName,
date,
time,
waiterId: waiter.id,
waiterName: waiter.name,
commissionRate: waiter.commissionRate,
amount: amt,
waiterEarnings: Math.round((amt * waiter.commissionRate) / 100),
adminEarnings: commissions.reduce((s, c) => s + Math.round((amt * c.rate) / 100), 0),
state,
})
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{editRecord ? '编辑消费记录' : '新增消费记录'}</DialogTitle>
</DialogHeader>
{/* Commission cards grid at top */}
<div className="grid grid-cols-2 gap-3">
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">
{selectedWaiter ? selectedWaiter.name : '服务员'}
</div>
<div className="text-lg font-semibold mt-0.5">
¥
{selectedWaiter && numAmount > 0
? Math.round((numAmount * selectedWaiter.commissionRate) / 100).toLocaleString()
: '0'}
</div>
<div className="text-xs text-muted-foreground">
{selectedWaiter ? `比例 ${selectedWaiter.commissionRate}%` : '未选择'}
</div>
</CardContent>
</Card>
{commissions.map((c) => {
const earn = numAmount > 0 ? Math.round((numAmount * c.rate) / 100) : 0
return (
<Card key={c.id}>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">{c.name}</div>
<div className="text-lg font-semibold mt-0.5">¥{earn.toLocaleString()}</div>
<div className="text-xs text-muted-foreground"> {c.rate}%</div>
</CardContent>
</Card>
)
})}
</div>
{/* Form */}
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"> *</Label>
<div className="col-span-3">
<Combobox
options={customerOptions}
value={customerName}
onChange={handleNameChange}
placeholder="选择或输入顾客"
emptyText="未找到匹配顾客"
/>
</div>
</div>
{selectedNameData && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="col-start-2 col-span-3 text-xs text-muted-foreground bg-muted rounded px-3 py-1.5">
: {selectedNameData.date} {selectedNameData.time}
{selectedNameData.package ? ` · ${selectedNameData.package}` : ''}
</div>
</div>
)}
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"> *</Label>
<div className="col-span-3">
<DatePicker
value={date ? new Date(date + 'T00:00:00') : undefined}
onChange={(d) => {
if (d) setDate(format(d, 'yyyy-MM-dd'))
}}
/>
</div>
</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>
<div className="col-span-3">
<Select value={waiterId} onValueChange={setWaiterId}>
<SelectTrigger>
<SelectValue placeholder="请选择服务员" />
</SelectTrigger>
<SelectContent>
{waiters.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select value={state} onValueChange={(v) => setState(v as ConsumptionState)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="待分账"></SelectItem>
<SelectItem value="已分账"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} disabled={!customerName || !waiterId || !amount}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+197
View File
@@ -0,0 +1,197 @@
'use client'
import { useState } from 'react'
import {
type ColumnDef,
type SortingState,
type ColumnFiltersState,
type VisibilityState,
flexRender,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
interface DataTableProps<TData> {
columns: ColumnDef<TData>[]
data: TData[]
pageSize?: number
searchKey?: string
searchPlaceholder?: string
tableMinWidth?: string
}
export function DataTable<TData>({
columns,
data,
pageSize = 10,
searchKey,
searchPlaceholder = '搜索...',
tableMinWidth = '100%',
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onGlobalFilterChange: setGlobalFilter,
state: { sorting, columnFilters, columnVisibility, globalFilter },
initialState: { pagination: { pageSize } },
})
const SortIcon = ({ column }: { column: any }) => {
const sorted = column.getIsSorted()
if (sorted === 'asc') return <ArrowUp className="ml-1 h-3 w-3 inline" />
if (sorted === 'desc') return <ArrowDown className="ml-1 h-3 w-3 inline" />
return <ArrowUpDown className="ml-1 h-3 w-3 inline opacity-30" />
}
return (
<div className="space-y-3 flex flex-col min-h-0">
{/* Toolbar */}
<div className="flex items-center gap-2 shrink-0">
{searchKey && (
<Input
placeholder={searchPlaceholder}
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''}
onChange={(e) => table.getColumn(searchKey)?.setFilterValue(e.target.value)}
className="max-w-[250px] h-8 text-sm"
/>
)}
<div className="flex-1" />
<span className="text-xs text-muted-foreground"> {data.length} </span>
</div>
{/* Scrollable table */}
<div className="rounded-md border overflow-auto flex-1 min-h-0">
<div style={{ minWidth: tableMinWidth }}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead
key={header.id}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
className={`sticky top-0 z-10 bg-background ${header.column.getCanSort() ? 'cursor-pointer select-none' : ''}`}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && <SortIcon column={header.column} />}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Select
value={String(table.getState().pagination.pageSize)}
onValueChange={(v) => table.setPageSize(Number(v))}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground px-2">
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
'use client'
import { format } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { Calendar as CalendarIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
interface DatePickerProps {
value: Date | undefined
onChange: (date: Date | undefined) => void
className?: string
placeholder?: string
}
export function DatePicker({ value, onChange, className, placeholder = '选择日期' }: DatePickerProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-[140px] justify-start text-left font-normal h-8',
!value && 'text-muted-foreground',
className,
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? format(value, 'yyyy-MM-dd') : <span>{placeholder}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar mode="single" selected={value} onSelect={onChange} locale={zhCN} />
</PopoverContent>
</Popover>
)
}
interface MonthPickerProps {
value: Date
onChange: (date: Date) => void
className?: string
}
export function MonthPicker({ value, onChange, className }: MonthPickerProps) {
const years = Array.from({ length: 10 }, (_, i) => value.getFullYear() - 5 + i)
const months = Array.from({ length: 12 }, (_, i) => i + 1)
return (
<div className={cn('flex items-center gap-1', className)}>
<Select
value={String(value.getFullYear())}
onValueChange={(y) => onChange(new Date(Number(y), value.getMonth(), 1))}
>
<SelectTrigger className="h-8 w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={String(value.getMonth() + 1)}
onValueChange={(m) => onChange(new Date(value.getFullYear(), Number(m) - 1, 1))}
>
<SelectTrigger className="h-8 w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{months.map((m) => (
<SelectItem key={m} value={String(m)}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { format } from 'date-fns'
import { CalendarIcon } from 'lucide-react'
import { type DateRange } from 'react-day-picker'
import { cn } from '@/lib/utils'
interface DatePickerWithRangeProps {
date: DateRange | undefined
onDateChange: (date: DateRange | undefined) => void
className?: string
}
export function DatePickerWithRange({ date, onDateChange, className }: DatePickerWithRangeProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'justify-start text-left font-normal h-8',
!date?.from && 'text-muted-foreground',
className,
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date?.from ? (
date.to ? (
<>
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
</>
) : (
format(date.from, 'LLL dd, y')
)
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={onDateChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
)
}
+36
View File
@@ -0,0 +1,36 @@
'use client'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Trash2 } from 'lucide-react'
interface DeletePopoverProps {
onConfirm: () => void
}
export function DeletePopover({ onConfirm }: DeletePopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" side="bottom" align="end">
<div className="text-sm mb-2"></div>
<div className="flex gap-2">
<PopoverTrigger asChild>
<Button size="sm" variant="destructive" onClick={onConfirm}>
</Button>
</PopoverTrigger>
<PopoverTrigger asChild>
<Button size="sm" variant="outline">
</Button>
</PopoverTrigger>
</div>
</PopoverContent>
</Popover>
)
}
+64 -50
View File
@@ -1,66 +1,80 @@
'use client'
import { useRef } from 'react'
import type { Media } from '@/lib/types'
import { Button } from '@/components/ui/button'
import { X, Plus, Image, Video } from 'lucide-react'
import { X, Image, Video, Loader2 } from 'lucide-react'
interface GalleryUploaderProps {
media: Media[]
onChange: (media: Media[]) => void
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 imageRef = useRef<HTMLInputElement>(null)
const videoRef = useRef<HTMLInputElement>(null)
const addVideo = () => {
const url = prompt('请输入视频 URL:')
if (!url) return
onChange([
...media,
{ id: `m${mediaIdCounter++}`, type: 'video', url },
])
}
const uploadFile = async (file: File, type: 'image' | 'video') => {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', type === 'image' ? 'images' : 'videos')
const removeMedia = (id: string) => {
onChange(media.filter((m) => m.id !== id))
}
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) return
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" />
)}
const { url } = await res.json()
onChange([...media, { id: `m${mediaIdCounter++}`, type, url }])
}
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
uploadFile(file, 'image')
e.target.value = ''
}
const handleVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
uploadFile(file, 'video')
e.target.value = ''
}
const removeMedia = (id: string) => {
onChange(media.filter((m) => m.id !== id))
}
return (
<div className="space-y-2">
<input ref={imageRef} type="file" accept="image/*" className="hidden" onChange={handleImageChange} />
<input ref={videoRef} type="file" accept="video/*" className="hidden" onChange={handleVideoChange} />
<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-destructive text-destructive-foreground 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={() => imageRef.current?.click()}>
<Image className="h-5 w-5" />
</Button>
<Button variant="outline" size="icon" className="w-20 h-20" onClick={() => videoRef.current?.click()}>
<Video className="h-5 w-5" />
</Button>
</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>
)
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
import { CalendarDays, Users, TrendingUp } from 'lucide-react'
const NAV_ITEMS = [
{ href: '/', label: '预约管理', icon: CalendarDays },
{ href: '/waiter', label: '服务员管理', icon: Users },
{ href: '/profit', label: '利润分红', icon: TrendingUp },
]
export function NavHeader() {
const pathname = usePathname()
return (
<header className="flex items-center px-6 py-3 border-b bg-background">
<div className="w-[200px]">
<Link href="/" className="flex items-center gap-2 shrink-0">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-sm">K</span>
</div>
<span className="text-base font-bold text-foreground hidden sm:inline">Keeppay</span>
</Link>
</div>
<nav className="flex-1 flex items-center justify-center gap-1">
{NAV_ITEMS.map((item) => (
<Link key={item.href} href={item.href}>
<Button
variant={pathname === item.href ? 'secondary' : 'ghost'}
size="sm"
className="text-sm gap-1.5"
>
<item.icon className="h-4 w-4" />
{item.label}
</Button>
</Link>
))}
</nav>
<div className="w-[200px] flex justify-end">
<ThemeToggle />
</div>
</header>
)
}
+47 -14
View File
@@ -1,26 +1,59 @@
'use client'
import { useEffect, useState } from 'react'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import type { Reservation } from '@/lib/types'
import { getWaiterById } from '@/lib/mock-data'
interface ReservationCardProps {
reservation: Reservation
onClick: (reservation: Reservation) => void
reservation: Reservation
onClick: (reservation: Reservation) => void
onEdit?: (reservation: Reservation) => void
onDelete?: (reservation: Reservation) => void
}
export function ReservationCard({ reservation, onClick }: ReservationCardProps) {
const waiter = getWaiterById(reservation.waiterId)
export function ReservationCard({ reservation, onClick, onEdit, onDelete }: ReservationCardProps) {
const [waiterName, setWaiterName] = useState('')
return (
useEffect(() => {
fetch(`/api/waiter/${reservation.waiterId}`)
.then((r) => r.json())
.then((w) => setWaiterName(w.name || ''))
.catch(() => {})
}, [reservation.waiterId])
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<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}]` : ''}`}
onClick={(e) => {
e.stopPropagation()
onClick(reservation)
}}
className="w-full text-left text-[11px] leading-tight px-1 py-0.5 rounded-sm bg-primary/10 text-primary hover:bg-primary/20 truncate border-l-2 border-primary mb-0.5 cursor-pointer"
title={`${reservation.customerName} - ${reservation.time}${waiterName ? ` (${waiterName})` : ''}${reservation.package ? ` [${reservation.package}]` : ''}`}
>
{reservation.time} {reservation.customerName}
{reservation.time} {reservation.customerName}
</button>
)
</ContextMenuTrigger>
<ContextMenuContent className="w-32">
<ContextMenuItem
onClick={(e) => {
e.stopPropagation()
onEdit?.(reservation)
}}
>
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation()
onDelete?.(reservation)
}}
className="text-destructive focus:text-destructive"
>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
+141 -150
View File
@@ -1,167 +1,158 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
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'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DatePicker } from '@/components/date-picker'
import { format } from 'date-fns'
import type { Reservation, CreateReservationInput, Waiter } from '@/lib/types'
interface ReservationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (input: CreateReservationInput) => void
onDelete?: () => void
editReservation?: Reservation | null
defaultDate?: string
open: boolean
onOpenChange: (open: boolean) => void
onSave: (input: CreateReservationInput) => void
editReservation?: Reservation | null
defaultDate?: string
}
export function ReservationDialog({
open,
onOpenChange,
onSave,
onDelete,
editReservation,
defaultDate,
open,
onOpenChange,
onSave,
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('')
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('')
const [waiters, setWaiters] = useState<Waiter[]>([])
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])
useEffect(() => {
fetch('/api/waiter')
.then((r) => r.json())
.then(setWaiters)
.catch(() => {})
}, [open])
const handleSave = () => {
if (!customerName || !date || !time || !waiterId) return
onSave({
customerName,
date,
time,
waiterId,
package: packageName || undefined,
note: note || undefined,
})
onOpenChange(false)
}
}, [editReservation, defaultDate, open])
const waiters = getWaiters()
const isEditing = !!editReservation
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>
)
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>
<div className="col-span-3">
<DatePicker
value={date ? new Date(date + 'T00:00:00') : undefined}
onChange={(d) => {
if (d) setDate(format(d, 'yyyy-MM-dd'))
}}
/>
</div>
</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">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} disabled={!customerName || !date || !time || !waiterId}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+77
View File
@@ -0,0 +1,77 @@
'use client'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { Button } from '@/components/ui/button'
import { Bold, Italic, List, Heading2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface RichEditorProps {
content: string
onChange: (html: string) => void
placeholder?: string
}
export function RichEditor({ content, onChange, placeholder }: RichEditorProps) {
const editor = useEditor({
extensions: [StarterKit, Placeholder.configure({ placeholder: placeholder || '输入内容...' })],
content,
onUpdate: ({ editor }) => onChange(editor.getHTML()),
editorProps: {
attributes: { class: 'prose prose-sm max-w-none focus:outline-none min-h-[80px] px-3 py-2' },
},
})
if (!editor) return null
const ToolBtn = ({
action,
active,
children,
}: {
action: () => void
active: boolean
children: React.ReactNode
}) => (
<button
type="button"
onClick={(e) => {
e.preventDefault()
action()
}}
className={cn(
'h-7 w-7 flex items-center justify-center rounded text-muted-foreground hover:bg-muted',
active && 'bg-muted text-foreground',
)}
>
{children}
</button>
)
return (
<div className="rounded-md border bg-background">
<div className="flex items-center gap-0.5 px-2 py-1 border-b">
<ToolBtn action={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')}>
<Bold className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn action={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')}>
<Italic className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
action={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
>
<Heading2 className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
action={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
>
<List className="h-3.5 w-3.5" />
</ToolBtn>
</div>
<EditorContent editor={editor} />
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
'use client'
import { useEffect, useState } from 'react'
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function ThemeToggle() {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
const stored = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const dark = stored ? stored === 'dark' : prefersDark
setIsDark(dark)
document.documentElement.classList.toggle('dark', dark)
}, [])
const toggle = () => {
const next = !isDark
setIsDark(next)
document.documentElement.classList.toggle('dark', next)
localStorage.setItem('theme', next ? 'dark' : 'light')
}
return (
<Button
variant="ghost"
size="icon"
onClick={toggle}
className="h-8 w-8"
title={isDark ? '切换到亮色模式' : '切换到暗色模式'}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
)
}
+22 -28
View File
@@ -1,45 +1,39 @@
"use client"
'use client'
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
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>
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}
/>
<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>
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}
/>
<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>
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}
/>
<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
+19 -20
View File
@@ -1,30 +1,29 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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",
},
'inline-flex items-center rounded-full 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 hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+34 -40
View File
@@ -1,52 +1,46 @@
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"
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",
},
'inline-flex items-center justify-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
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',
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
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}
/>
)
}
({ 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"
Button.displayName = 'Button'
export { Button, buttonVariants }
+155
View File
@@ -0,0 +1,155 @@
'use client'
import * as React from 'react'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn(
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_next,
),
month_caption: cn(
'flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]',
defaultClassNames.month_caption,
),
dropdowns: cn(
'flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border',
defaultClassNames.dropdown_root,
),
dropdown: cn('bg-popover absolute inset-0 opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
day: cn(
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
defaultClassNames.day,
),
range_start: cn('bg-accent rounded-l-md', defaultClassNames.range_start),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }: any) => {
return <div ref={rootRef} data-slot="calendar" className={cn(className)} {...props} />
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />
}
if (orientation === 'right') {
return <ChevronRightIcon className={cn('size-4', className)} {...props} />
}
return <ChevronDownIcon className={cn('size-4', className)} {...props} />
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton(props: any) {
const defaultClassNames = getDefaultClassNames()
const { className, day, modifiers } = props
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers?.focused) ref.current?.focus()
}, [modifiers?.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day?.date?.toLocaleDateString()}
data-selected-single={
modifiers?.selected && !modifiers?.range_start && !modifiers?.range_end && !modifiers?.range_middle
}
data-range-start={modifiers?.range_start}
data-range-end={modifiers?.range_end}
data-range-middle={modifiers?.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md',
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+38 -46
View File
@@ -1,51 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import * as React from 'react'
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";
import { cn } from '@/lib/utils'
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 Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} />
))
Card.displayName = 'Card'
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 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 CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
export { Card, CardHeader, CardTitle, CardContent };
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
)
CardDescription.displayName = 'CardDescription'
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'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+132
View File
@@ -0,0 +1,132 @@
'use client'
import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from '@/components/ui/dialog'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />)
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+180
View File
@@ -0,0 +1,180 @@
'use client'
import * as React from 'react'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
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 origin-[--radix-context-menu-content-transform-origin]',
className,
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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 origin-[--radix-context-menu-content-transform-origin]',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 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 left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
}
ContextMenuShortcut.displayName = 'ContextMenuShortcut'
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+62 -62
View File
@@ -1,97 +1,97 @@
"use client"
'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"
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>
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}
/>
<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>
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>
<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} />
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
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} />
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
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}
/>
<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>
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}
/>
<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,
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+165 -36
View File
@@ -1,52 +1,181 @@
"use client"
'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"
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}
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-[--radix-dropdown-menu-content-transform-origin]',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
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 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-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 origin-[--radix-dropdown-menu-content-transform-origin]',
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 }
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}
/>
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+18 -17
View File
@@ -1,21 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from 'react'
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}
/>
)
}
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Input.displayName = "Input"
Input.displayName = 'Input'
export { Input }
+10 -16
View File
@@ -1,24 +1,18 @@
"use client"
'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"
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
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>
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}
/>
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
+31
View File
@@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 origin-[--radix-popover-content-transform-origin]',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
+30 -33
View File
@@ -1,44 +1,41 @@
"use client"
'use client'
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
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>
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>
<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>
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
+121 -73
View File
@@ -1,97 +1,145 @@
"use client"
'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"
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>
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>
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 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}
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...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">
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.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-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-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 origin-[--radix-select-content-transform-origin]',
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}
>
<SelectScrollUpButton />
<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>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
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>
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 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 left-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
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+57 -47
View File
@@ -1,66 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
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>
)
({ className, ...props }, ref) => (
<div className="relative w-full">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
)
Table.displayName = "Table"
Table.displayName = 'Table'
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
)
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />,
)
TableHeader.displayName = "TableHeader"
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} />
)
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
),
)
TableBody.displayName = "TableBody"
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
),
)
TableFooter.displayName = 'TableFooter'
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}
/>
)
({ 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"
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}
/>
)
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
),
)
TableHead.displayName = "TableHead"
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}
/>
)
({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
),
)
TableCell.displayName = "TableCell"
TableCell.displayName = 'TableCell'
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
),
)
TableCaption.displayName = 'TableCaption'
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Textarea.displayName = 'Textarea'
export { Textarea }
+125 -114
View File
@@ -1,132 +1,143 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { useState, useEffect } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Edit2, Trash2, Plus } from 'lucide-react'
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 [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())
}
useEffect(() => {
if (open) {
setWaiters(getWaiters())
}
}, [open])
const handleOpen = () => {
setOpen(true)
refresh()
setShowForm(false)
setEditingWaiter(null)
}
const handleSave = (input: CreateWaiterInput) => {
if (editingWaiter) {
updateWaiter(editingWaiter.id, input as UpdateWaiterInput)
} else {
createWaiter(input)
const refresh = () => {
setWaiters(getWaiters())
}
refresh()
setShowForm(false)
setEditingWaiter(null)
}
const handleDelete = (id: string) => {
deleteWaiter(id)
refresh()
}
const handleSave = (input: CreateWaiterInput) => {
if (editingWaiter) {
updateWaiter(editingWaiter.id, input as UpdateWaiterInput)
} else {
createWaiter(input)
}
refresh()
setShowForm(false)
setEditingWaiter(null)
}
const handleEdit = (waiter: Waiter) => {
setEditingWaiter(waiter)
setShowForm(true)
}
const handleDelete = (id: string) => {
deleteWaiter(id)
refresh()
}
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>
const handleEdit = (waiter: Waiter) => {
setEditingWaiter(waiter)
setShowForm(true)
}
{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>
return (
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v)
if (!v) setShowForm(false)
}}
>
<DialogTrigger asChild>
<Button variant="ghost"></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>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
)
) : (
<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>
)
}
+139 -71
View File
@@ -1,89 +1,157 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { GalleryUploader } from './gallery-uploader'
import type { Waiter, CreateWaiterInput, Media } from '@/lib/types'
import { Camera } from 'lucide-react'
import type { Waiter, CreateWaiterInput, Media, WaiterStatus } from '@/lib/types'
interface WaiterFormProps {
waiter?: Waiter | null
onSave: (input: CreateWaiterInput) => void
waiter?: Waiter | null
onSave: (input: CreateWaiterInput) => void
}
const STATUS_OPTIONS: WaiterStatus[] = ['上班中', '休息中', '禁用', '初始化']
export function WaiterForm({ waiter, onSave }: WaiterFormProps) {
const [name, setName] = useState('')
const [specialty, setSpecialty] = useState('')
const [commissionRate, setCommissionRate] = useState(20)
const [gallery, setGallery] = useState<Media[]>([])
const [name, setName] = useState('')
const [avatar, setAvatar] = useState('')
const [specialty, setSpecialty] = useState('')
const [commissionRate, setCommissionRate] = useState(20)
const [status, setStatus] = useState<WaiterStatus>('初始化')
const [gallery, setGallery] = useState<Media[]>([])
const avatarRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (waiter) {
setName(waiter.name)
setSpecialty(waiter.specialty)
setCommissionRate(waiter.commissionRate)
setGallery([...waiter.gallery])
} else {
setName('')
setSpecialty('')
setCommissionRate(20)
setGallery([])
useEffect(() => {
if (waiter) {
setName(waiter.name)
setAvatar(waiter.avatar || '')
setSpecialty(waiter.specialty)
setCommissionRate(waiter.commissionRate)
setStatus(waiter.status)
setGallery([...waiter.gallery])
} else {
setName('')
setAvatar('')
setSpecialty('')
setCommissionRate(20)
setStatus('初始化')
setGallery([])
}
}, [waiter])
const uploadAvatar = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'avatars')
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) return
const { url } = await res.json()
setAvatar(url)
}
}, [waiter])
const canSubmit = name && specialty
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
uploadAvatar(file)
e.target.value = ''
}
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault()
if (!canSubmit) return
onSave({ name, specialty, commissionRate, gallery })
}
const canSubmit = name && specialty
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>
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault()
if (!canSubmit) return
onSave({ name, avatar: avatar || undefined, specialty, commissionRate, status, gallery })
}
return (
<div className="grid gap-4 py-4">
<input ref={avatarRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
<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-start gap-4">
<Label className="text-right pt-2"></Label>
<div className="col-span-3 space-y-2">
{avatar ? (
<div className="relative w-16 h-16 group">
<img src={avatar} alt="avatar" className="w-16 h-16 rounded-full object-cover border" />
<button
type="button"
onClick={() => avatarRef.current?.click()}
className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<Camera className="h-5 w-5 text-white" />
</button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={() => avatarRef.current?.click()}>
<Camera className="h-4 w-4 mr-1" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label className="text-right pt-2"> *</Label>
<Textarea
className="col-span-3 min-h-[100px]"
value={specialty}
onChange={(e) => setSpecialty(e.target.value)}
placeholder="如:宴会/商务&#10;生日策划&#10;日常接待"
/>
</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-muted-foreground">%</span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select value={status} onValueChange={(v) => setStatus(v as WaiterStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</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>
</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>
)
)
}
+2
View File
@@ -0,0 +1,2 @@
AccessKey ID,AccessKey Secret
LTAI5t6T6HFoDP2KKb7waDDv,4TxYYOc89VJZAHOxsTjf8VdmG9qtE8
1 AccessKey ID AccessKey Secret
2 LTAI5t6T6HFoDP2KKb7waDDv 4TxYYOc89VJZAHOxsTjf8VdmG9qtE8
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
# 服务员预约系统 - 设计文档
## 概述
PC 端服务员预约管理后台,帮助餐厅管理员按日期管理服务员预约。使用 Next.js 16 App Router + shadcn/ui + Tailwind CSS v4 构建。
## 目标用户
餐厅管理员,专注"日期+预约"核心场景。
## 页面布局
### 整体结构
```
┌──────────────────────────────────────────────────────────────────┐
│ Keeppay · 服务员预约系统 预约管理 服务员管理 │
├──────────────────────────────────────────────────────────────────┤
│ ◀ 2026年5月 ▶ │
├─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────┤
│ 周一 │ 周二 │ 周三 │ 周四 │ 周五 │ 周六 │ 周日 │
├─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────┤
│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
│ │ │ │ │ │ │ │
├─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────┤
│ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │
│ │ │ │ │ │ │ │
├─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────┤
│ 15 │ [16]● │ 17 │ 18 │ 19 │ 20 │ 21 │
│ │张伟11:00│ │ │ │ │ │
│ │李芳12:30│ │ │ │ │ │
├─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────┤
│ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │
│ │ │ │ │ │ │ │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────┘
```
- 顶部导航栏:系统名称 + 预约管理(即当前日历视图)/ 服务员管理(弹窗)
- 主区域:Google Calendar 风格月视图
- 有预约的日期格内直接展示预约卡片(顾客姓名+时间)
- 超出 2 条显示 "+N 更多"
### 交互方式
- **点击日期格空白区域** → 弹出新建预约 Dialog(自动填充该日期)
- **点击预约卡片** → 弹出操作菜单(编辑 / 删除)
- **点击顶部月份箭头** → 切换月份
- **点击"服务员管理"** → 弹出服务员管理 Dialog
## 数据模型
### 预约 (Reservation)
```typescript
interface Reservation {
id: string
customerName: string
date: string // "2026-05-16"
time: string // "11:00"
waiterId: string
package?: string // 可选
note?: string // 可选
}
```
### 服务员 (Waiter)
```typescript
interface Waiter {
id: string
name: string
gallery: Media[] // 照片/视频画廊
specialty: string // 擅长领域
commissionRate: number // 分成比例,如 30 表示 30%
}
interface Media {
id: string
type: 'image' | 'video'
url: string
}
```
## 组件树
```
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx # 主页面:日历视图
│ └── globals.css
├── components/
│ ├── ui/ # shadcn/ui 组件
│ ├── calendar-header.tsx # 顶部月份导航
│ ├── calendar-grid.tsx # 月历网格
│ ├── calendar-cell.tsx # 单个日期格子
│ ├── reservation-card.tsx # 日期格内预约卡片
│ ├── reservation-form.tsx # 预约表单(新建/编辑复用)
│ ├── waiter-dialog.tsx # 服务员管理弹窗
│ ├── waiter-form.tsx # 服务员编辑表单
│ └── gallery-uploader.tsx # 画廊上传组件
├── lib/
│ ├── types.ts
│ ├── mock-data.ts # mock 数据 + CRUD
│ ├── utils.ts # cn() 工具
│ └── date-utils.ts # 日历日期计算
└── hooks/
└── use-reservations.ts # 预约数据 Hook
```
## 数据流
### Mock 数据层 (lib/mock-data.ts)
模块级变量模拟数据库,CRUD 函数直接操作内存数组。
```
页面组件
↓ useState/useEffect
useReservations hook
↓ 调用
mock-data.ts CRUD 函数
↓ 操作
内存数组(模块级变量)
```
### 核心函数
- `getReservationsByDate(date: string) → Reservation[]`
- `createReservation(data) → Reservation`
- `updateReservation(id, data) → Reservation`
- `deleteReservation(id) → void`
- `getWaiters() → Waiter[]`
- `createWaiter(data) → Waiter`
- `updateWaiter(id, data) → Waiter`
- `deleteWaiter(id) → void`
### 日历计算 (lib/date-utils.ts)
- `getMonthGrid(year, month) → Date[][]` — 返回 6 周 × 7 天的日期矩阵
- `formatDate(date) → "YYYY-MM-DD"`
- `isSameDay(a, b) → boolean`
- `isCurrentMonth(date, year, month) → boolean`
## shadcn/ui 初始化
需要安装并初始化的组件:
- `Button` — 操作按钮
- `Dialog` — 弹窗(预约表单、服务员管理)
- `Input` — 文本输入
- `Select` — 服务员下拉选择
- `Label` — 表单标签
- `Table` — 服务员列表表格
- `Calendar` — 参考(我们自建月历,但可以参考组件 API)
- `Card` — 预约卡片
- `DropdownMenu` — 预约操作菜单(编辑/删除)
- `Popover` — 日期选择器
## Implementation Plan (后续步骤)
1. 初始化 shadcn/ui
2. 安装必要依赖(lucide-react 等)
3. 创建 lib/types.ts、lib/utils.ts、lib/date-utils.ts、lib/mock-data.ts
4. 创建 shadcn/ui 组件
5. 创建自定义业务组件(日历网格、预约卡片、表单等)
6. 组装主页面 page.tsx
7. 验证构建
+15 -15
View File
@@ -1,18 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import { defineConfig, globalIgnores } from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
import nextTs from 'eslint-config-next/typescript'
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
])
export default eslintConfig;
export default eslintConfig
+21 -91
View File
@@ -1,101 +1,31 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import {
getReservationsByDate,
getReservationsByMonth,
createReservation,
updateReservation,
deleteReservation,
getWaiters,
getWaiterById,
createWaiter,
updateWaiter,
deleteWaiter,
} from '@/lib/mock-data'
import type { Reservation, CreateReservationInput, UpdateReservationInput } from '@/lib/types'
import { useState, useEffect, useCallback } from 'react'
import type { Reservation } from '@/lib/types'
export function useReservations(year: number, month: number) {
const [, forceUpdate] = useState(0)
const rerender = useCallback(() => forceUpdate((n) => n + 1), [])
const [reservations, setReservations] = useState<Reservation[]>([])
const [loading, setLoading] = useState(true)
const monthReservations = useMemo(() => getReservationsByMonth(year, month), [year, month])
const load = useCallback(async () => {
setLoading(true)
try {
const res = await fetch(`/api/reservation?year=${year}&month=${month + 1}`)
if (res.ok) {
const data = await res.json()
setReservations(data)
}
} finally {
setLoading(false)
}
}, [year, month])
useEffect(() => { load() }, [load])
const getReservationsForDate = useCallback(
(date: string): Reservation[] => {
return monthReservations.filter((r) => r.date === date)
},
[monthReservations]
(date: string): Reservation[] => reservations.filter((r) => r.date === date),
[reservations],
)
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 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: Parameters<typeof updateWaiter>[1]) => {
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 }
return { getReservationsForDate, loading, refresh: load }
}
+60 -30
View File
@@ -1,49 +1,79 @@
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 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)
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)
}
weeks.push(week)
}
return weeks
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}`
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()
)
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
return date.getFullYear() === year && date.getMonth() === month
}
export function isToday(date: Date): boolean {
return isSameDay(date, new Date())
return isSameDay(date, new Date())
}
export function getMonthName(month: number): string {
const names = [
'一月', '二月', '三月', '四月', '五月', '六月',
'七月', '八月', '九月', '十月', '十一月', '十二月',
]
return names[month]
const names = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
return names[month]
}
export function getWeekStart(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
const diff = day === 0 ? -6 : 1 - day
d.setDate(d.getDate() + diff)
d.setHours(0, 0, 0, 0)
return d
}
export function getWeekEnd(date: Date): Date {
const d = getWeekStart(date)
d.setDate(d.getDate() + 6)
d.setHours(23, 59, 59, 999)
return d
}
export function getMonthStart(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1)
}
export function getMonthEnd(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999)
}
export function getYearStart(date: Date): Date {
return new Date(date.getFullYear(), 0, 1)
}
export function getYearEnd(date: Date): Date {
return new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999)
}
export function isDateInRange(dateStr: string, start: Date, end: Date): boolean {
const d = new Date(dateStr)
return d >= start && d <= end
}
+422
View File
@@ -0,0 +1,422 @@
import { PrismaClient } from '@/lib/generated/prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { nextId } from './snowflake'
import type { Waiter, Media, Reservation, CreateReservationInput, CommissionItem, ConsumptionRecord, ConsumptionState } from './types'
const adapter = new PrismaBetterSqlite3({ url: './data/keeppay.db' })
const prisma = new PrismaClient({ adapter })
function rowToWaiter(row: any): Waiter {
return {
id: row.id,
name: row.name,
avatar: row.avatar || undefined,
gallery: JSON.parse(row.gallery || '[]') as Media[],
specialty: row.specialty,
commissionRate: row.commissionRate,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}
export async function seedWaiters() {
const count = await prisma.waiter.count()
if (count > 0) return
const seeds = [
{
name: '王小明',
avatar: 'https://api.dicebear.com/9.x/avataaars-neutral/svg?backgroundColor=b6e3f4&seed=wang',
gallery: [],
specialty: '宴会/商务',
commissionRate: 30,
status: '上班中',
},
{
name: '李小红',
avatar: 'https://api.dicebear.com/9.x/avataaars-neutral/svg?backgroundColor=b6e3f4&seed=li',
gallery: [],
specialty: '婚宴/生日',
commissionRate: 25,
status: '上班中',
},
{
name: '张三',
avatar: 'https://api.dicebear.com/9.x/avataaars-neutral/svg?backgroundColor=b6e3f4&seed=zhang',
gallery: [],
specialty: '日常接待',
commissionRate: 20,
status: '休息中',
},
]
await prisma.waiter.createMany({
data: seeds.map((s) => ({ ...s, id: nextId(), gallery: JSON.stringify(s.gallery) })),
})
}
export async function listWaiters(): Promise<Waiter[]> {
const rows = await prisma.waiter.findMany({ orderBy: { createdAt: 'desc' } })
return rows.map(rowToWaiter)
}
export async function getWaiter(id: string): Promise<Waiter | null> {
const row = await prisma.waiter.findUnique({ where: { id } })
return row ? rowToWaiter(row) : null
}
export async function createWaiter(data: {
name: string
avatar?: string
gallery?: Media[]
specialty: string
commissionRate: number
status?: string
}): Promise<Waiter> {
const row = await prisma.waiter.create({
data: {
id: nextId(),
name: data.name,
avatar: data.avatar || '',
gallery: JSON.stringify(data.gallery || []),
specialty: data.specialty,
commissionRate: data.commissionRate,
status: data.status || '初始化',
},
})
return rowToWaiter(row)
}
export async function updateWaiter(
id: string,
data: {
name?: string
avatar?: string
gallery?: Media[]
specialty?: string
commissionRate?: number
status?: string
},
): Promise<Waiter | null> {
const updateData: any = {}
if (data.name !== undefined) updateData.name = data.name
if (data.avatar !== undefined) updateData.avatar = data.avatar
if (data.gallery !== undefined) updateData.gallery = JSON.stringify(data.gallery)
if (data.specialty !== undefined) updateData.specialty = data.specialty
if (data.commissionRate !== undefined) updateData.commissionRate = data.commissionRate
if (data.status !== undefined) updateData.status = data.status
if (Object.keys(updateData).length === 0) return getWaiter(id)
try {
const row = await prisma.waiter.update({ where: { id }, data: updateData })
return rowToWaiter(row)
} catch {
return null
}
}
export async function seedReservations() {
const count = await prisma.reservation.count()
if (count > 0) return
await seedWaiters()
const seeds = [
{ customerName: '张伟', date: '2026-05-16', time: '11:00', waiterId: '', package: '生日宴', note: '需要蛋糕' },
{ customerName: '李芳', date: '2026-05-16', time: '12:30', waiterId: '', package: '商务宴请' },
{ customerName: '赵强', date: '2026-05-16', time: '18:00', waiterId: '' },
{ customerName: '刘梅', date: '2026-05-20', time: '11:30', waiterId: '', package: '婚宴' },
{ customerName: '陈先生', date: '2026-05-12', time: '10:00', waiterId: '' },
{ customerName: '王太太', date: '2026-05-12', time: '14:00', waiterId: '', package: '下午茶' },
{ customerName: '林总', date: '2026-05-14', time: '19:00', waiterId: '', package: '商务宴' },
{ customerName: '周小姐', date: '2026-05-08', time: '09:30', waiterId: '', package: '早茶' },
{ customerName: '吴老板', date: '2026-05-09', time: '12:00', waiterId: '', package: '商务套餐' },
{ customerName: '郑先生', date: '2026-05-10', time: '19:30', waiterId: '' },
{ customerName: '孙女士', date: '2026-05-11', time: '11:30', waiterId: '', package: '生日宴' },
{ customerName: '钱总', date: '2026-05-15', time: '18:30', waiterId: '', package: '商务宴请', note: 'VIP包间' },
{ customerName: '何先生', date: '2026-05-18', time: '12:00', waiterId: '' },
{ customerName: '黄女士', date: '2026-05-19', time: '10:30', waiterId: '', package: '早茶' },
{ customerName: '马先生', date: '2026-05-22', time: '17:00', waiterId: '', package: '下午茶' },
{ customerName: '梁小姐', date: '2026-05-23', time: '12:30', waiterId: '', note: '素食' },
{ customerName: '宋总', date: '2026-05-25', time: '19:00', waiterId: '', package: '商务宴' },
{ customerName: '唐女士', date: '2026-05-26', time: '11:00', waiterId: '', package: '生日宴' },
{ customerName: '韩先生', date: '2026-05-28', time: '14:00', waiterId: '' },
{ customerName: '冯女士', date: '2026-05-30', time: '18:00', waiterId: '', package: '婚宴', note: '试菜' },
{ customerName: '曹老板', date: '2026-04-10', time: '12:00', waiterId: '', package: '商务宴' },
{ customerName: '邓女士', date: '2026-04-15', time: '11:30', waiterId: '', package: '生日宴' },
{ customerName: '彭先生', date: '2026-04-22', time: '18:00', waiterId: '' },
{ customerName: '蒋小姐', date: '2026-04-28', time: '19:30', waiterId: '', package: '商务宴' },
{ customerName: '余先生', date: '2026-06-05', time: '12:00', waiterId: '', package: '商务宴请' },
{ customerName: '潘女士', date: '2026-06-08', time: '11:30', waiterId: '' },
{ customerName: '方总', date: '2026-06-12', time: '18:30', waiterId: '', package: '商务宴' },
{ customerName: '钟先生', date: '2026-06-15', time: '10:00', waiterId: '' },
{ customerName: '谭小姐', date: '2026-06-18', time: '14:00', waiterId: '', package: '下午茶' },
{ customerName: '陆总', date: '2026-06-20', time: '19:00', waiterId: '', package: '商务宴请' },
]
// map waiter names to IDs
const allWaiters = await prisma.waiter.findMany()
const waiterByName: Record<string, string> = {}
for (const w of allWaiters) waiterByName[w.name] = w.id
const defaultWaiterId = allWaiters[0]?.id || ''
const waiterNameForId: Record<string, string> = {
'': '王小明',
'1': '王小明',
'2': '李小红',
'3': '张三',
}
await prisma.reservation.createMany({
data: seeds.map((s) => {
const name = waiterNameForId[s.waiterId] || '王小明'
return {
id: nextId(),
...s,
waiterId: waiterByName[name] || defaultWaiterId,
}
}),
})
}
export async function deleteWaiter(id: string): Promise<boolean> {
try {
await prisma.waiter.delete({ where: { id } })
return true
} catch {
return false
}
}
// --- Reservation CRUD ---
function rowToReservation(row: any): Reservation {
return {
id: row.id,
customerName: row.customerName,
date: row.date,
time: row.time,
waiterId: row.waiterId,
package: row.package || undefined,
note: row.note || undefined,
state: row.state as ConsumptionState || '待分账',
}
}
export async function listReservations(params?: {
year?: number
month?: number
}): Promise<Reservation[]> {
const where: any = {}
if (params?.year !== undefined && params?.month !== undefined) {
const prefix = `${params.year}-${String(params.month).padStart(2, '0')}`
where.date = { startsWith: prefix }
}
const rows = await prisma.reservation.findMany({ where, orderBy: { createdAt: 'desc' } })
return rows.map(rowToReservation)
}
export async function createReservation(data: CreateReservationInput): Promise<Reservation> {
const row = await prisma.reservation.create({
data: {
id: nextId(),
customerName: data.customerName,
date: data.date,
time: data.time,
waiterId: data.waiterId,
package: data.package || '',
note: data.note || '',
state: data.state || '待分账',
},
})
return rowToReservation(row)
}
export async function updateReservation(
id: string,
data: Partial<CreateReservationInput>,
): Promise<Reservation | null> {
const updateData: any = {}
if (data.customerName !== undefined) updateData.customerName = data.customerName
if (data.date !== undefined) updateData.date = data.date
if (data.time !== undefined) updateData.time = data.time
if (data.waiterId !== undefined) updateData.waiterId = data.waiterId
if (data.package !== undefined) updateData.package = data.package
if (data.note !== undefined) updateData.note = data.note
if (data.state !== undefined) updateData.state = data.state
if (Object.keys(updateData).length === 0) return null
try {
const row = await prisma.reservation.update({ where: { id }, data: updateData })
return rowToReservation(row)
} catch {
return null
}
}
export async function seedCommissionItems() {
const count = await prisma.commissionItem.count()
if (count > 0) return
await prisma.commissionItem.create({
data: { id: nextId(), name: '平台分红', rate: 10 },
})
}
// --- Commission CRUD ---
export async function listCommissionItems(): Promise<CommissionItem[]> {
const rows = await prisma.commissionItem.findMany({ orderBy: { createdAt: 'asc' } })
return rows.map((r: any) => ({ id: r.id, name: r.name, rate: r.rate }))
}
export async function createCommissionItem(data: { name: string; rate: number }): Promise<CommissionItem> {
const row = await prisma.commissionItem.create({
data: { id: nextId(), name: data.name, rate: data.rate },
})
return { id: row.id, name: row.name, rate: row.rate }
}
export async function updateCommissionItem(
id: string,
data: { name?: string; rate?: number },
): Promise<CommissionItem | null> {
try {
const row = await prisma.commissionItem.update({ where: { id }, data })
return { id: row.id, name: row.name, rate: row.rate }
} catch {
return null
}
}
export async function deleteCommissionItem(id: string): Promise<boolean> {
try {
await prisma.commissionItem.delete({ where: { id } })
return true
} catch {
return false
}
}
export async function seedConsumptions() {
const count = await prisma.consumption.count()
if (count > 0) return
const allWaiters = await prisma.waiter.findMany()
const waiterByName: Record<string, any> = {}
for (const w of allWaiters) waiterByName[w.name] = w
const seeds = [
{ customerName: '张伟', date: '2026-05-16', time: '11:00', waiterName: '王小明', commissionRate: 30, amount: 1200 },
{ customerName: '李芳', date: '2026-05-16', time: '12:30', waiterName: '李小红', commissionRate: 25, amount: 800 },
{ customerName: '赵强', date: '2026-05-18', time: '18:00', waiterName: '王小明', commissionRate: 30, amount: 2000 },
]
await prisma.consumption.createMany({
data: seeds.map((s) => ({
id: nextId(),
...s,
waiterId: waiterByName[s.waiterName]?.id || '',
waiterEarnings: Math.round((s.amount * s.commissionRate) / 100),
adminEarnings: 0,
state: '待分账',
})),
})
}
// --- Consumption CRUD ---
export async function listConsumptions(): Promise<ConsumptionRecord[]> {
const rows = await prisma.consumption.findMany({ orderBy: { createdAt: 'desc' } })
return rows.map((r: any) => ({
id: r.id,
customerName: r.customerName,
date: r.date,
time: r.time,
waiterId: r.waiterId,
waiterName: r.waiterName,
commissionRate: r.commissionRate,
amount: r.amount,
waiterEarnings: r.waiterEarnings,
adminEarnings: r.adminEarnings,
state: r.state as ConsumptionState,
}))
}
export async function createConsumption(
data: Omit<ConsumptionRecord, 'id'>,
): Promise<ConsumptionRecord> {
const row = await prisma.consumption.create({
data: {
id: nextId(),
customerName: data.customerName,
date: data.date,
time: data.time,
waiterId: data.waiterId,
waiterName: data.waiterName,
commissionRate: data.commissionRate,
amount: data.amount,
waiterEarnings: data.waiterEarnings,
adminEarnings: data.adminEarnings,
state: data.state || '待分账',
},
})
return {
id: row.id,
customerName: row.customerName,
date: row.date,
time: row.time,
waiterId: row.waiterId,
waiterName: row.waiterName,
commissionRate: row.commissionRate,
amount: row.amount,
waiterEarnings: row.waiterEarnings,
adminEarnings: row.adminEarnings,
state: row.state as ConsumptionState,
}
}
export async function updateConsumption(
id: string,
data: Partial<Omit<ConsumptionRecord, 'id'>>,
): Promise<ConsumptionRecord | null> {
try {
const row = await prisma.consumption.update({ where: { id }, data })
return {
id: row.id,
customerName: row.customerName,
date: row.date,
time: row.time,
waiterId: row.waiterId,
waiterName: row.waiterName,
commissionRate: row.commissionRate,
amount: row.amount,
waiterEarnings: row.waiterEarnings,
adminEarnings: row.adminEarnings,
state: row.state as ConsumptionState,
}
} catch {
return null
}
}
export async function deleteConsumption(id: string): Promise<boolean> {
try {
await prisma.consumption.delete({ where: { id } })
return true
} catch {
return false
}
}
export async function deleteReservation(id: string): Promise<boolean> {
try {
await prisma.reservation.delete({ where: { id } })
return true
} catch {
return false
}
}
+246 -69
View File
@@ -1,107 +1,284 @@
import type { Reservation, Waiter, CreateReservationInput, UpdateReservationInput, CreateWaiterInput, UpdateWaiterInput } from './types'
import type {
Reservation,
Waiter,
ConsumptionRecord,
CommissionItem,
CreateReservationInput,
UpdateReservationInput,
CreateWaiterInput,
UpdateWaiterInput,
} from './types'
const AVATAR_BASE = 'https://api.dicebear.com/9.x/avataaars-neutral/svg?backgroundColor=b6e3f4'
const now = new Date().toISOString()
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,
},
{
id: '1',
name: '王小明',
status: '上班中',
createdAt: now,
updatedAt: now,
avatar: `${AVATAR_BASE}&seed=wang`,
gallery: [
{ id: 'm1', type: 'image', url: AVATAR_BASE + '&seed=wang' },
{ id: 'm2', type: 'image', url: AVATAR_BASE + '&seed=wang2' },
],
specialty: '宴会/商务',
commissionRate: 30,
},
{
id: '2',
name: '李小红',
status: '上班中',
createdAt: now,
updatedAt: now,
avatar: `${AVATAR_BASE}&seed=li`,
gallery: [{ id: 'm3', type: 'image', url: AVATAR_BASE + '&seed=li' }],
specialty: '婚宴/生日',
commissionRate: 25,
},
{
id: '3',
name: '张三',
status: '休息中',
createdAt: now,
updatedAt: now,
gallery: [],
specialty: '日常接待',
commissionRate: 20,
},
]
const RESERVATION_STATE = '待分账' as const
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: '商务宴' },
{
id: 'r1',
customerName: '张伟',
date: '2026-05-16',
time: '11:00',
waiterId: '1',
package: '生日宴',
note: '需要蛋糕',
state: RESERVATION_STATE,
},
{ id: 'r2', customerName: '李芳', date: '2026-05-16', time: '12:30', waiterId: '2', package: '商务宴请', state: RESERVATION_STATE },
{ id: 'r3', customerName: '赵强', date: '2026-05-16', time: '18:00', waiterId: '1', state: RESERVATION_STATE },
{ id: 'r4', customerName: '刘梅', date: '2026-05-20', time: '11:30', waiterId: '2', package: '婚宴', state: RESERVATION_STATE },
{ id: 'r5', customerName: '陈先生', date: '2026-05-12', time: '10:00', waiterId: '3', state: RESERVATION_STATE },
{ id: 'r6', customerName: '王太太', date: '2026-05-12', time: '14:00', waiterId: '3', package: '下午茶', state: RESERVATION_STATE },
{ id: 'r7', customerName: '林总', date: '2026-05-14', time: '19:00', waiterId: '1', package: '商务宴', state: RESERVATION_STATE },
{ id: 'r8', customerName: '周小姐', date: '2026-05-08', time: '09:30', waiterId: '2', package: '早茶', state: RESERVATION_STATE },
{ id: 'r9', customerName: '吴老板', date: '2026-05-09', time: '12:00', waiterId: '1', package: '商务套餐', state: RESERVATION_STATE },
{ id: 'r10', customerName: '郑先生', date: '2026-05-10', time: '19:30', waiterId: '3', state: RESERVATION_STATE },
{ id: 'r11', customerName: '孙女士', date: '2026-05-11', time: '11:30', waiterId: '2', package: '生日宴', state: RESERVATION_STATE },
{
id: 'r12',
customerName: '钱总',
date: '2026-05-15',
time: '18:30',
waiterId: '1',
package: '商务宴请',
note: 'VIP包间',
state: RESERVATION_STATE,
},
{ id: 'r13', customerName: '何先生', date: '2026-05-18', time: '12:00', waiterId: '3', state: RESERVATION_STATE },
{ id: 'r14', customerName: '黄女士', date: '2026-05-19', time: '10:30', waiterId: '2', package: '早茶', state: RESERVATION_STATE },
{ id: 'r15', customerName: '马先生', date: '2026-05-22', time: '17:00', waiterId: '1', package: '下午茶', state: RESERVATION_STATE },
{ id: 'r16', customerName: '梁小姐', date: '2026-05-23', time: '12:30', waiterId: '3', note: '素食', state: RESERVATION_STATE },
{ id: 'r17', customerName: '宋总', date: '2026-05-25', time: '19:00', waiterId: '1', package: '商务宴', state: RESERVATION_STATE },
{ id: 'r18', customerName: '唐女士', date: '2026-05-26', time: '11:00', waiterId: '2', package: '生日宴', state: RESERVATION_STATE },
{ id: 'r19', customerName: '韩先生', date: '2026-05-28', time: '14:00', waiterId: '3', state: RESERVATION_STATE },
{
id: 'r20',
customerName: '冯女士',
date: '2026-05-30',
time: '18:00',
waiterId: '1',
package: '婚宴',
note: '试菜',
state: RESERVATION_STATE,
},
{ id: 'r21', customerName: '曹老板', date: '2026-04-10', time: '12:00', waiterId: '2', package: '商务宴', state: RESERVATION_STATE },
{ id: 'r22', customerName: '邓女士', date: '2026-04-15', time: '11:30', waiterId: '1', package: '生日宴', state: RESERVATION_STATE },
{ id: 'r23', customerName: '彭先生', date: '2026-04-22', time: '18:00', waiterId: '3', state: RESERVATION_STATE },
{ id: 'r24', customerName: '蒋小姐', date: '2026-04-28', time: '19:30', waiterId: '2', package: '商务宴', state: RESERVATION_STATE },
{ id: 'r25', customerName: '余先生', date: '2026-06-05', time: '12:00', waiterId: '1', package: '商务宴请', state: RESERVATION_STATE },
{ id: 'r26', customerName: '潘女士', date: '2026-06-08', time: '11:30', waiterId: '3', state: RESERVATION_STATE },
{ id: 'r27', customerName: '方总', date: '2026-06-12', time: '18:30', waiterId: '2', package: '商务宴', state: RESERVATION_STATE },
{ id: 'r28', customerName: '钟先生', date: '2026-06-15', time: '10:00', waiterId: '1', state: RESERVATION_STATE },
{ id: 'r29', customerName: '谭小姐', date: '2026-06-18', time: '14:00', waiterId: '3', package: '下午茶', state: RESERVATION_STATE },
{ id: 'r30', customerName: '陆总', date: '2026-06-20', time: '19:00', waiterId: '2', package: '商务宴请', state: RESERVATION_STATE },
]
let consumptions: ConsumptionRecord[] = [
{
id: 'c1',
customerName: '张伟',
date: '2026-05-16',
time: '11:00',
waiterId: '1',
waiterName: '王小明',
commissionRate: 30,
amount: 1200,
waiterEarnings: 360,
adminEarnings: 120,
state: '待分账',
},
{
id: 'c2',
customerName: '李芳',
date: '2026-05-16',
time: '12:30',
waiterId: '2',
waiterName: '李小红',
commissionRate: 25,
amount: 800,
waiterEarnings: 200,
adminEarnings: 80,
state: '已分账',
},
{
id: 'c3',
customerName: '赵强',
date: '2026-05-18',
time: '18:00',
waiterId: '1',
waiterName: '王小明',
commissionRate: 30,
amount: 2000,
waiterEarnings: 600,
adminEarnings: 200,
state: '待分账',
},
]
let waiterNextId = 4
let reservationNextId = 8
let reservationNextId = 31
let consumptionNextId = 4
export function getReservationsByDate(date: string): Reservation[] {
return reservations.filter((r) => r.date === date)
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))
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
const reservation: Reservation = {
id: `r${reservationNextId++}`,
...input,
state: input.state || '待分账',
}
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]
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
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]
return [...waiters]
}
export function getWaiterById(id: string): Waiter | undefined {
return waiters.find((w) => w.id === id)
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
const now = new Date().toISOString()
const waiter: Waiter = {
id: String(waiterNextId++),
...input,
status: input.status || '初始化',
createdAt: now,
updatedAt: now,
}
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]
const index = waiters.findIndex((w) => w.id === id)
if (index === -1) return null
waiters[index] = { ...waiters[index], ...input, updatedAt: new Date().toISOString() }
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
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
}
let commissionItems: CommissionItem[] = [{ id: 'cc1', name: '平台分红', rate: 10 }]
let commissionNextId = 2
export function getCommissionItems(): CommissionItem[] {
return [...commissionItems]
}
export function addCommissionItem(item: Omit<CommissionItem, 'id'>): CommissionItem {
const c: CommissionItem = { id: `cc${commissionNextId++}`, ...item }
commissionItems.push(c)
return c
}
export function updateCommissionItem(id: string, data: Omit<CommissionItem, 'id'>): CommissionItem | null {
const idx = commissionItems.findIndex((c) => c.id === id)
if (idx === -1) return null
commissionItems[idx] = { id, ...data }
return commissionItems[idx]
}
export function deleteCommissionItem(id: string): boolean {
const idx = commissionItems.findIndex((c) => c.id === id)
if (idx === -1) return false
commissionItems.splice(idx, 1)
return true
}
export function getAllReservations(): Reservation[] {
return [...reservations]
}
export function getConsumptions(): ConsumptionRecord[] {
return [...consumptions]
}
export function createConsumption(record: Omit<ConsumptionRecord, 'id'>): ConsumptionRecord {
const r: ConsumptionRecord = { id: `c${consumptionNextId++}`, ...record }
consumptions.push(r)
return r
}
export function updateConsumption(id: string, data: Omit<ConsumptionRecord, 'id'>): ConsumptionRecord | null {
const idx = consumptions.findIndex((c) => c.id === id)
if (idx === -1) return null
consumptions[idx] = { id, ...data }
return consumptions[idx]
}
export function deleteConsumption(id: string): boolean {
const idx = consumptions.findIndex((c) => c.id === id)
if (idx === -1) return false
consumptions.splice(idx, 1)
return true
}
+32
View File
@@ -0,0 +1,32 @@
import OSS from 'ali-oss'
const client = new OSS({
region: process.env.OSS_REGION || 'oss-cn-shanghai',
accessKeyId: process.env.OSS_ACCESS_KEY_ID!,
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET!,
bucket: process.env.OSS_BUCKET || 'keeppy',
secure: true,
})
const PUBLIC_URL = (process.env.OSS_PUBLIC_URL || 'https://keeppy.cn-shanghai.taihangtop.cn').replace(/\/+$/, '')
export async function uploadFile(file: File, folder: string = 'uploads'): Promise<{ url: string; key: string }> {
const ext = file.name.split('.').pop() || 'bin'
const key = `${folder}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
const buffer = Buffer.from(await file.arrayBuffer())
await client.put(key, buffer, {
headers: { 'Content-Type': file.type },
})
const url = `${PUBLIC_URL}/${key}`
return { url, key }
}
export async function deleteFile(key: string): Promise<void> {
try {
await client.delete(key)
} catch {
// ignore
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { Reservation, CommissionItem } from './types'
export function getMockServiceFee(reservationId: string): number {
const seed = parseInt(reservationId.replace('r', ''), 10) || 1
return 200 + ((seed * 137) % 1800)
}
export function calcEarnings(amount: number, rate: number): number {
return Math.round((amount * rate) / 100)
}
export interface ProfitItem {
reservation: Reservation
serviceFee: number
waiterName: string
waiterCommission: number
waiterEarnings: number
commissionBreakdown: { id: string; name: string; rate: number; earnings: number }[]
}
export function buildProfitData(
reservations: Reservation[],
commissions: CommissionItem[],
waiterMap: Record<string, { name: string; commissionRate: number }> = {},
): ProfitItem[] {
return reservations.map((r) => {
const waiter = waiterMap[r.waiterId]
const fee = getMockServiceFee(r.id)
const waiterRate = waiter?.commissionRate ?? 0
return {
reservation: r,
serviceFee: fee,
waiterName: waiter?.name ?? '未知',
waiterCommission: waiterRate,
waiterEarnings: calcEarnings(fee, waiterRate),
commissionBreakdown: commissions.map((c) => ({
id: c.id,
name: c.name,
rate: c.rate,
earnings: calcEarnings(fee, c.rate),
})),
}
})
}
export function calcTotals(items: ProfitItem[]) {
const totalServiceFee = items.reduce((s, i) => s + i.serviceFee, 0)
const totalWaiterEarnings = items.reduce((s, i) => s + i.waiterEarnings, 0)
return { totalServiceFee, totalWaiterEarnings }
}
+42
View File
@@ -0,0 +1,42 @@
import { z } from 'zod'
export const MediaSchema = z.object({
id: z.string(),
type: z.enum(['image', 'video']),
url: z.string().url('图片/视频 URL 格式不正确').or(z.string().min(1)),
})
export const CreateWaiterSchema = z.object({
name: z.string().min(1, '请输入姓名'),
avatar: z.string().optional(),
gallery: z.array(MediaSchema).optional().default([]),
specialty: z.string().min(1, '请输入擅长领域'),
commissionRate: z.number().min(0).max(100, '分成比例不能超过100%'),
status: z.enum(['上班中', '休息中', '禁用', '初始化']).optional(),
})
export const UpdateWaiterSchema = CreateWaiterSchema.partial()
export const CreateReservationSchema = z.object({
customerName: z.string().min(1, '请输入顾客姓名'),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式不正确'),
time: z.string().regex(/^\d{2}:\d{2}$/, '时间格式不正确'),
waiterId: z.string().min(1, '请选择服务员'),
package: z.string().optional(),
note: z.string().optional(),
state: z.enum(['待分账', '已分账']).optional().default('待分账'),
})
export const CreateConsumptionSchema = z.object({
customerName: z.string().min(1, '请输入顾客姓名'),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式不正确'),
time: z.string().regex(/^\d{2}:\d{2}$/, '时间格式不正确'),
waiterId: z.string().min(1, '请选择服务员'),
amount: z.number().positive('消费金额必须大于0'),
state: z.enum(['待分账', '已分账']).optional().default('待分账'),
})
export type CreateWaiterInput = z.infer<typeof CreateWaiterSchema>
export type UpdateWaiterInput = z.infer<typeof UpdateWaiterSchema>
export type CreateReservationInput = z.infer<typeof CreateReservationSchema>
export type CreateConsumptionInput = z.infer<typeof CreateConsumptionSchema>
+41
View File
@@ -0,0 +1,41 @@
const EPOCH = BigInt(1700000000000) // 2023-11-14
let workerId = BigInt(1)
let datacenterId = BigInt(1)
let sequence = BigInt(0)
let lastTimestamp = BigInt(0)
export function setWorkerId(id: number) {
workerId = BigInt(id) & BigInt(31)
}
export function setDatacenterId(id: number) {
datacenterId = BigInt(id) & BigInt(31)
}
function timestamp(): bigint {
return BigInt(Date.now()) - EPOCH
}
export function nextId(): string {
let ts = timestamp()
if (ts < lastTimestamp) {
ts = lastTimestamp
}
if (ts === lastTimestamp) {
sequence = (sequence + BigInt(1)) & BigInt(4095)
if (sequence === BigInt(0)) {
ts += BigInt(1)
}
} else {
sequence = BigInt(0)
}
lastTimestamp = ts
const id = (ts << BigInt(22)) | (datacenterId << BigInt(17)) | (workerId << BigInt(12)) | sequence
return id.toString()
}
+60 -26
View File
@@ -1,45 +1,79 @@
export type MediaType = 'image' | 'video'
export interface Media {
id: string
type: MediaType
url: string
id: string
type: MediaType
url: string
}
export type WaiterStatus = '上班中' | '休息中' | '禁用' | '初始化'
export interface Waiter {
id: string
name: string
gallery: Media[]
specialty: string
commissionRate: number
id: string
name: string
avatar?: string
gallery: Media[]
specialty: string
commissionRate: number
status: WaiterStatus
createdAt: string
updatedAt: string
}
export interface Reservation {
id: string
customerName: string
date: string
time: string
waiterId: string
package?: string
note?: string
id: string
customerName: string
date: string
time: string
waiterId: string
package?: string
note?: string
state: ConsumptionState
}
export interface CreateReservationInput {
customerName: string
date: string
time: string
waiterId: string
package?: string
note?: string
customerName: string
date: string
time: string
waiterId: string
package?: string
note?: string
state?: ConsumptionState
}
export interface UpdateReservationInput extends Partial<CreateReservationInput> {}
export interface CreateWaiterInput {
name: string
gallery: Media[]
specialty: string
commissionRate: number
name: string
avatar?: string
gallery: Media[]
specialty: string
commissionRate: number
status?: WaiterStatus
}
export interface UpdateWaiterInput extends Partial<CreateWaiterInput> {}
export interface UpdateWaiterInput extends Partial<CreateWaiterInput> {
status?: WaiterStatus
}
export interface CommissionItem {
id: string
name: string
rate: number
}
export type ConsumptionState = '待分账' | '已分账'
export interface ConsumptionRecord {
id: string
customerName: string
date: string
time: string
waiterId: string
waiterName: string
commissionRate: number
amount: number
waiterEarnings: number
adminEarnings: number
state: ConsumptionState
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs))
}
+4 -4
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
/* config options here */
};
/* config options here */
}
export default nextConfig;
export default nextConfig
+59 -36
View File
@@ -1,38 +1,61 @@
{
"name": "keeppay",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.5",
"tailwindcss": "^4",
"typescript": "^5"
}
"name": "keeppay",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"fmt": "prettier --write ."
},
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-placeholder": "^3.23.1",
"@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1",
"ali-oss": "^6.23.0",
"better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.14.0",
"next": "16.2.5",
"prisma": "^7.8.0",
"proxy-agent": "^8.0.1",
"react": "19.2.4",
"react-day-picker": "^10.0.0",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.5",
"prettier": "^3",
"tailwindcss": "^4",
"typescript": "^5"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
]
}
}
+3120
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,3 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
- sharp
- unrs-resolver
+5 -5
View File
@@ -1,7 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config;
export default config
+13
View File
@@ -0,0 +1,13 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import { defineConfig } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: process.env['DATABASE_URL'] || 'file:./data/keeppay.db',
},
})
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "waiters" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"avatar" TEXT NOT NULL DEFAULT '',
"gallery" TEXT NOT NULL DEFAULT '[]',
"specialty" TEXT NOT NULL DEFAULT '',
"commission_rate" INTEGER NOT NULL DEFAULT 20,
"status" TEXT NOT NULL DEFAULT '初始化',
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "reservations" (
"id" TEXT NOT NULL PRIMARY KEY,
"customer_name" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"waiter_id" TEXT NOT NULL,
"package" TEXT NOT NULL DEFAULT '',
"note" TEXT NOT NULL DEFAULT '',
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "commission_items" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"rate" INTEGER NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "consumptions" (
"id" TEXT NOT NULL PRIMARY KEY,
"customer_name" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"waiter_id" TEXT NOT NULL,
"waiter_name" TEXT NOT NULL,
"commission_rate" INTEGER NOT NULL,
"amount" INTEGER NOT NULL,
"waiter_earnings" INTEGER NOT NULL,
"admin_earnings" INTEGER NOT NULL,
"state" TEXT NOT NULL DEFAULT '待分账',
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_reservations" (
"id" TEXT NOT NULL PRIMARY KEY,
"customer_name" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"waiter_id" TEXT NOT NULL,
"package" TEXT NOT NULL DEFAULT '',
"note" TEXT NOT NULL DEFAULT '',
"state" TEXT NOT NULL DEFAULT '待分账',
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
INSERT INTO "new_reservations" ("created_at", "customer_name", "date", "id", "note", "package", "time", "updated_at", "waiter_id") SELECT "created_at", "customer_name", "date", "id", "note", "package", "time", "updated_at", "waiter_id" FROM "reservations";
DROP TABLE "reservations";
ALTER TABLE "new_reservations" RENAME TO "reservations";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+65
View File
@@ -0,0 +1,65 @@
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model Waiter {
id String @id
name String
avatar String @default("")
gallery String @default("[]")
specialty String @default("")
commissionRate Int @default(20) @map("commission_rate")
status String @default("初始化")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("waiters")
}
model Reservation {
id String @id
customerName String @map("customer_name")
date String
time String
waiterId String @map("waiter_id")
package String @default("")
note String @default("")
state String @default("待分账")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("reservations")
}
model CommissionItem {
id String @id
name String
rate Int
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("commission_items")
}
model Consumption {
id String @id
customerName String @map("customer_name")
date String
time String
waiterId String @map("waiter_id")
waiterName String @map("waiter_name")
commissionRate Int @map("commission_rate")
amount Int
waiterEarnings Int @map("waiter_earnings")
adminEarnings Int @map("admin_earnings")
state String @default("待分账")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("consumptions")
}
+25 -32
View File
@@ -1,34 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
"exclude": ["node_modules"]
}
+58
View File
@@ -0,0 +1,58 @@
declare module 'ali-oss' {
interface OSSOptions {
region?: string
endpoint?: string
internal?: boolean
secure?: boolean
cname?: boolean
accessKeyId: string
accessKeySecret: string
bucket?: string
stsToken?: string
refreshSTSToken?: () => Promise<{ accessKeyId: string; accessKeySecret: string; stsToken: string }>
}
interface PutOptions {
headers?: Record<string, string>
mime?: string
meta?: Record<string, string>
callback?: {
url: string
host?: string
body: string
contentType?: string
}
}
interface PutResult {
name: string
url: string
res: Record<string, unknown>
data?: Record<string, unknown>
}
interface DeleteResult {
res: Record<string, unknown>
}
interface ListResult {
objects: Array<{ name: string; url: string; lastModified: string; size: number }>
prefixes: string[]
isTruncated: boolean
nextMarker: string
res: Record<string, unknown>
}
class OSS {
constructor(options: OSSOptions)
put(name: string, file: Buffer | string | NodeJS.ReadableStream, options?: PutOptions): Promise<PutResult>
putStream(name: string, stream: NodeJS.ReadableStream, options?: PutOptions): Promise<PutResult>
delete(name: string, options?: Record<string, unknown>): Promise<DeleteResult>
list(query?: Record<string, unknown>): Promise<ListResult>
get(name: string): Promise<{ res: Record<string, unknown>; content: Buffer }>
getStream(name: string): Promise<{ res: Record<string, unknown>; stream: NodeJS.ReadableStream }>
signatureUrl(name: string, options?: { expires?: number; method?: string }): string
}
export = OSS
}