feat: 添加项目规范和配置文件
- 新增项目规范文档,包含语言设置和 Git 提交规范 - 更新 .gitignore 文件,添加环境文件和数据目录 - 新增 Prettier 配置文件和忽略文件 - 更新 package.json,添加 prettier 和 prisma 相关依赖 - 新增 API 路由处理佣金和消费数据 这些更改为项目提供了更好的结构和代码风格规范。
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
build
|
||||
.git
|
||||
.superpowers
|
||||
pnpm-lock.yaml
|
||||
*.env
|
||||
*.env.local
|
||||
*.md
|
||||
CHANGELOG*
|
||||
LICENSE*
|
||||
docs
|
||||
@@ -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
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"css.lint.unknownAtRules": "ignore"
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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="如:宴会/商务 生日策划 日常接待"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
AccessKey ID,AccessKey Secret
|
||||
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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3120
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,3 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Vendored
+58
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user