feat(fund-pool): 添加资金池管理功能

- 新增资金池分配、余额、配置和支出相关的 API 路由
- 添加资金池支出对话框和页面组件
- 更新相关依赖,支持新功能

这些更改为资金池管理提供了完整的功能支持。
This commit is contained in:
2026-05-12 21:43:58 +08:00
parent 841faca34a
commit 5ddd93038c
46 changed files with 3131 additions and 764 deletions
+25 -25
View File
@@ -2,34 +2,34 @@ 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 })
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 })
}
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 })
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 })
}
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 })
}
}
+19 -20
View File
@@ -1,28 +1,27 @@
import { NextResponse } from 'next/server'
import { listCommissionItems, createCommissionItem, seedCommissionItems } from '@/lib/db'
import { listCommissionItems, createCommissionItem } 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 })
}
try {
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 })
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 })
}
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 })
}
}
+32 -32
View File
@@ -3,45 +3,45 @@ 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 })
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 })
}
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 })
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 })
}
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 })
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 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete consumption:', error)
return NextResponse.json({ error: 'Failed to delete consumption' }, { status: 500 })
}
}
+16 -17
View File
@@ -1,24 +1,23 @@
import { NextResponse } from 'next/server'
import { listConsumptions, createConsumption, seedConsumptions } from '@/lib/db'
import { listConsumptions, createConsumption } 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 })
}
try {
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 })
}
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 })
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server'
import { getAllocationsTotal, getExpensesTotal, getFundPoolConfig, createAllocation, listConsumptions } from '@/lib/db'
export async function POST() {
const config = await getFundPoolConfig()
if (!config || config.monthlyPercent <= 0) {
return NextResponse.json({ error: '资金池未配置' }, { status: 400 })
}
const [totalAllocated, totalExpenses] = await Promise.all([getAllocationsTotal(), getExpensesTotal()])
const currentBalance = totalAllocated - totalExpenses
if (currentBalance >= config.maxPoolAmount) {
return NextResponse.json({ error: '资金池已达上限,无需入账' }, { status: 400 })
}
const now = new Date()
const consumptions = await listConsumptions()
const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const thisMonthConsumptions = consumptions.filter((c) => c.date.startsWith(monthPrefix))
const totalServiceFee = thisMonthConsumptions.reduce((s, c) => s + c.amount, 0)
const totalWaiterEarnings = thisMonthConsumptions.reduce((s, c) => s + c.waiterEarnings, 0)
const netProfit = totalServiceFee - totalWaiterEarnings
const allocationAmount = (netProfit * config.monthlyPercent) / 100
const newBalance = currentBalance + allocationAmount
const finalAmount = newBalance > config.maxPoolAmount ? config.maxPoolAmount - currentBalance : allocationAmount
if (finalAmount <= 0) {
return NextResponse.json({ error: '无需入账' }, { status: 400 })
}
await createAllocation({
year: now.getFullYear(),
month: now.getMonth() + 1,
amount: finalAmount,
})
return NextResponse.json({ amount: finalAmount, newBalance: currentBalance + finalAmount })
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { getAllocationsTotal, getExpensesTotal, getFundPoolConfig } from '@/lib/db'
export async function GET() {
const [totalAllocated, totalExpenses, config] = await Promise.all([
getAllocationsTotal(),
getExpensesTotal(),
getFundPoolConfig(),
])
const balance = totalAllocated - totalExpenses
return NextResponse.json({
balance,
totalAllocated,
totalExpenses,
maxPoolAmount: config?.maxPoolAmount ?? 0,
monthlyPercent: config?.monthlyPercent ?? 0,
})
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { getFundPoolConfig, upsertFundPoolConfig } from '@/lib/db'
export async function GET() {
const config = await getFundPoolConfig()
return NextResponse.json(config ?? { monthlyPercent: 0, maxPoolAmount: 0 })
}
export async function PUT(request: Request) {
const body = await request.json()
const config = await upsertFundPoolConfig({
monthlyPercent: body.monthlyPercent,
maxPoolAmount: body.maxPoolAmount,
})
return NextResponse.json(config)
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { updateFundPoolExpense, deleteFundPoolExpense } from '@/lib/db'
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const body = await request.json()
const expense = await updateFundPoolExpense(id, body)
if (!expense) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(expense)
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const ok = await deleteFundPoolExpense(id)
if (!ok) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ success: true })
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import { listFundPoolExpenses, createFundPoolExpense } from '@/lib/db'
export async function GET(request: Request) {
const url = new URL(request.url)
const year = url.searchParams.get('year') ? Number(url.searchParams.get('year')) : undefined
const month = url.searchParams.get('month') ? Number(url.searchParams.get('month')) : undefined
const expenses = await listFundPoolExpenses(year !== undefined && month !== undefined ? { year, month } : undefined)
return NextResponse.json(expenses)
}
export async function POST(request: Request) {
const body = await request.json()
const expense = await createFundPoolExpense({
amount: body.amount,
category: body.category,
note: body.note || '',
handler: body.handler || '',
date: body.date,
})
return NextResponse.json(expense)
}
+33 -33
View File
@@ -4,46 +4,46 @@ 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 })
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 })
}
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 })
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 })
}
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 })
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 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete reservation:', error)
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 })
}
}
+22 -23
View File
@@ -1,32 +1,31 @@
import { NextResponse } from 'next/server'
import { listReservations, createReservation, seedReservations } from '@/lib/db'
import { listReservations, createReservation } 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')
try {
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 })
}
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 })
}
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 })
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { getMonthlyTarget, listConsumptions } from '@/lib/db'
export async function GET() {
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
const dayOfMonth = now.getDate()
const daysInMonth = new Date(year, month, 0).getDate()
const daysRemaining = daysInMonth - dayOfMonth
const target = await getMonthlyTarget(year, month)
const monthPrefix = `${year}-${String(month).padStart(2, '0')}`
const consumptions = await listConsumptions()
const thisMonthConsumptions = consumptions.filter((c) => c.date.startsWith(monthPrefix))
const currentTotal = thisMonthConsumptions.reduce((s, c) => s + c.amount, 0)
const result: Record<string, unknown> = {
year,
month,
dayOfMonth,
daysInMonth,
daysRemaining,
targetAmount: target?.targetAmount ?? 0,
currentTotal,
hasTarget: target !== null,
}
if (target && target.targetAmount > 0 && daysRemaining <= 10) {
if (currentTotal < target.targetAmount) {
const dailyAverage = currentTotal / dayOfMonth
const projected = dailyAverage * daysInMonth
const gap = target.targetAmount - projected
if (gap > 0) {
result.projected = projected
result.gap = gap
result.gapPercent = Math.round((gap / target.targetAmount) * 100)
result.needsWarning = true
}
}
}
return NextResponse.json(result)
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server'
import { getMonthlyTarget, upsertMonthlyTarget } from '@/lib/db'
export async function GET(request: Request) {
const url = new URL(request.url)
const year = Number(url.searchParams.get('year'))
const month = Number(url.searchParams.get('month'))
if (!year || !month) return NextResponse.json({ error: 'year and month required' }, { status: 400 })
const target = await getMonthlyTarget(year, month)
return NextResponse.json(target ?? { targetAmount: 0 })
}
export async function POST(request: Request) {
const body = await request.json()
const target = await upsertMonthlyTarget({
year: body.year,
month: body.month,
targetAmount: body.targetAmount,
})
return NextResponse.json(target)
}
+1 -2
View File
@@ -1,9 +1,8 @@
import { NextResponse } from 'next/server'
import { listWaiters, createWaiter, seedWaiters } from '@/lib/db'
import { listWaiters, createWaiter } from '@/lib/db'
import { CreateWaiterSchema } from '@/lib/schemas'
export async function GET() {
seedWaiters()
const waiters = await listWaiters()
return NextResponse.json(waiters)
}
+141
View File
@@ -0,0 +1,141 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
const EXPENSE_CATEGORIES = ['房租', '物资', '其他']
const CUSTOM_HANDLER = '__custom__'
export function ExpenseDialog({
open,
onOpenChange,
onSave,
editRecord,
}: {
open: boolean
onOpenChange: (v: boolean) => void
onSave: (data: any) => void
editRecord: any | null
}) {
const [amount, setAmount] = useState('')
const [category, setCategory] = useState(EXPENSE_CATEGORIES[0])
const [note, setNote] = useState('')
const [handler, setHandler] = useState('')
const [handlerSelect, setHandlerSelect] = useState('')
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [commissionNames, setCommissionNames] = useState<string[]>([])
useEffect(() => {
fetch('/api/commission')
.then((r) => r.json())
.then((list: { name: string }[]) => setCommissionNames(list.map((c) => c.name)))
.catch(() => {})
}, [])
useEffect(() => {
if (editRecord) {
setAmount(String(editRecord.amount))
setCategory(editRecord.category)
setNote(editRecord.note)
setHandler(editRecord.handler)
setHandlerSelect(commissionNames.includes(editRecord.handler) ? editRecord.handler : CUSTOM_HANDLER)
setDate(editRecord.date)
} else {
setAmount('')
setCategory(EXPENSE_CATEGORIES[0])
setNote('')
setHandler('')
setHandlerSelect('')
setDate(new Date().toISOString().slice(0, 10))
}
}, [editRecord, open, commissionNames])
const handleSave = () => {
onSave({ amount: Number(amount), category, note, handler, date })
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editRecord ? '编辑支出' : '新增支出'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm text-muted-foreground"></label>
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="请输入金额"
/>
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<Input value={note} onChange={(e) => setNote(e.target.value)} placeholder="支出说明" />
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<div className="space-y-2">
<Select
value={handlerSelect}
onValueChange={(v) => {
setHandlerSelect(v)
if (v !== CUSTOM_HANDLER) setHandler(v)
}}
>
<SelectTrigger>
<SelectValue placeholder="选择或输入经办人" />
</SelectTrigger>
<SelectContent>
{commissionNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
<SelectItem value={CUSTOM_HANDLER}>...</SelectItem>
</SelectContent>
</Select>
{handlerSelect === CUSTOM_HANDLER && (
<Input
value={handler}
onChange={(e) => setHandler(e.target.value)}
placeholder="输入经办人姓名"
/>
)}
</div>
</div>
<div>
<label className="text-sm text-muted-foreground"></label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave}></Button>
</div>
</DialogContent>
</Dialog>
)
}
+257
View File
@@ -0,0 +1,257 @@
'use client'
import { useState, useEffect } from 'react'
import { NavHeader } from '@/components/nav-header'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { DataTable } from '@/components/data-table'
import { DeletePopover } from '@/components/delete-popover'
import { ExpenseDialog } from './expense-dialog'
import type { ColumnDef } from '@tanstack/react-table'
import { Plus, Settings2, Edit2 } from 'lucide-react'
interface FundPoolExpense {
id: string
amount: number
category: string
note: string
handler: string
date: string
}
export default function FundPoolPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [balance, setBalance] = useState<{
balance: number
totalAllocated: number
totalExpenses: number
maxPoolAmount: number
monthlyPercent: number
} | null>(null)
const [config, setConfig] = useState<{ monthlyPercent: number; maxPoolAmount: number }>({
monthlyPercent: 0,
maxPoolAmount: 0,
})
const [expenses, setExpenses] = useState<FundPoolExpense[]>([])
const [configOpen, setConfigOpen] = useState(false)
const [expenseOpen, setExpenseOpen] = useState(false)
const [editingExpense, setEditingExpense] = useState<FundPoolExpense | null>(null)
const [allocMessage, setAllocMessage] = useState('')
useEffect(() => {
fetch('/api/fund-pool/balance')
.then((r) => r.json())
.then(setBalance)
.catch(() => {})
fetch('/api/fund-pool/config')
.then((r) => r.json())
.then(setConfig)
.catch(() => {})
fetch('/api/fund-pool/expenses')
.then((r) => r.json())
.then(setExpenses)
.catch(() => {})
}, [refreshKey])
const handleConfigSave = async () => {
await fetch('/api/fund-pool/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
setConfigOpen(false)
setRefreshKey((n) => n + 1)
}
const handleExpenseSave = async (data: any) => {
if (editingExpense) {
await fetch(`/api/fund-pool/expenses/${editingExpense.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
} else {
await fetch('/api/fund-pool/expenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
setEditingExpense(null)
setRefreshKey((n) => n + 1)
}
const handleDeleteExpense = async (id: string) => {
await fetch(`/api/fund-pool/expenses/${id}`, { method: 'DELETE' })
setRefreshKey((n) => n + 1)
}
const handleAllocate = async () => {
setAllocMessage('')
const res = await fetch('/api/fund-pool/allocate', { method: 'POST' })
const data = await res.json()
if (data.error) {
setAllocMessage(data.error)
} else {
setAllocMessage(`入账成功: ¥${data.amount.toLocaleString()}`)
}
setRefreshKey((n) => n + 1)
setTimeout(() => setAllocMessage(''), 3000)
}
const columns: ColumnDef<FundPoolExpense>[] = [
{ id: 'date', header: '日期', accessorKey: 'date' },
{ id: 'category', header: '分类', accessorKey: 'category' },
{
id: 'amount',
header: '金额',
accessorKey: 'amount',
cell: ({ row }) => `¥${row.original.amount.toLocaleString()}`,
},
{ id: 'note', header: '备注', accessorKey: 'note' },
{ id: 'handler', header: '经办人', accessorKey: 'handler' },
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => {
setEditingExpense(row.original)
setExpenseOpen(true)
}}
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
</Button>
<DeletePopover onConfirm={() => handleDeleteExpense(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">
<div className="flex items-center justify-between shrink-0">
<h1 className="text-xl font-bold"></h1>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleAllocate}>
</Button>
<Button size="sm" variant="outline" onClick={() => setConfigOpen(true)}>
<Settings2 className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
{allocMessage && (
<div className="p-2 bg-primary/10 rounded text-sm text-primary shrink-0">{allocMessage}</div>
)}
<div className="flex gap-4 shrink-0">
<Card className="flex-1">
<CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-foreground">
¥{(balance?.balance ?? 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card className="flex-1">
<CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-foreground">
¥{(balance?.totalAllocated ?? 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card className="flex-1">
<CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-foreground">
¥{(balance?.totalExpenses ?? 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card className="flex-1">
<CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-foreground">
¥{(balance?.maxPoolAmount ?? 0).toLocaleString()}
</div>
<div className="text-xs text-muted-foreground mt-0.5"> {config.monthlyPercent}%</div>
</CardContent>
</Card>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2 shrink-0">
<h2 className="text-sm font-semibold text-muted-foreground"></h2>
<Button size="sm" onClick={() => setExpenseOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<DataTable columns={columns} data={expenses} pageSize={50} tableMinWidth="700px" />
</div>
</div>
{configOpen && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setConfigOpen(false)}
>
<div
className="bg-background rounded-lg p-6 w-[400px] shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="text-sm text-muted-foreground"> (%)</label>
<Input
type="number"
value={config.monthlyPercent}
onChange={(e) =>
setConfig((p) => ({ ...p, monthlyPercent: Number(e.target.value) }))
}
/>
</div>
<div>
<label className="text-sm text-muted-foreground"> ()</label>
<Input
type="number"
value={config.maxPoolAmount}
onChange={(e) =>
setConfig((p) => ({ ...p, maxPoolAmount: Number(e.target.value) }))
}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => setConfigOpen(false)}>
</Button>
<Button onClick={handleConfigSave}></Button>
</div>
</div>
</div>
)}
<ExpenseDialog
open={expenseOpen}
onOpenChange={(v) => {
setExpenseOpen(v)
if (!v) setEditingExpense(null)
}}
onSave={handleExpenseSave}
editRecord={editingExpense}
/>
</div>
)
}
+107 -107
View File
@@ -12,114 +12,114 @@ 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 { 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 [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
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 monthReservations = useMemo(() => {
const daysInMonth = new Date(year, month + 1, 0).getDate()
const map: Record<string, Reservation[]> = {}
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
}, [year, month, getReservationsForDate])
const handleNewReservation = (dateStr?: string) => {
setEditingReservation(null)
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 = async (input: CreateReservationInput) => {
if (editingReservation) {
await fetch(`/api/reservation/${editingReservation.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
} else {
await fetch('/api/reservation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
}
refreshReservations()
}
const handleDelete = async (reservation: Reservation) => {
await fetch(`/api/reservation/${reservation.id}`, { method: 'DELETE' })
refreshReservations()
}
return (
<div className="h-full flex flex-col">
<NavHeader />
{/* Calendar */}
<div className="flex-1 flex flex-col overflow-hidden">
<CalendarHeader
year={year}
month={month}
onPrevMonth={() => {
if (month === 0) {
setYear(year - 1)
setMonth(11)
} else {
setMonth(month - 1)
const monthReservations = useMemo(() => {
const daysInMonth = new Date(year, month + 1, 0).getDate()
const map: Record<string, Reservation[]> = {}
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
}
}}
onNextMonth={() => {
if (month === 11) {
setYear(year + 1)
setMonth(0)
} else {
setMonth(month + 1)
}
}}
onToday={handleToday}
onNewReservation={() => handleNewReservation()}
/>
<CalendarGrid
year={year}
month={month}
reservationsByDate={monthReservations}
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
onNewReservation={handleNewReservation}
onEditReservation={handleEditReservation}
onDeleteReservation={handleDelete}
/>
</div>
}
return map
}, [year, month, getReservationsForDate])
{/* Reservation Dialog */}
<ReservationDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSave={handleSave}
editReservation={editingReservation}
defaultDate={dialogDefaultDate}
/>
</div>
)
const handleNewReservation = (dateStr?: string) => {
setEditingReservation(null)
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 = async (input: CreateReservationInput) => {
if (editingReservation) {
await fetch(`/api/reservation/${editingReservation.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
} else {
await fetch('/api/reservation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
}
refreshReservations()
}
const handleDelete = async (reservation: Reservation) => {
await fetch(`/api/reservation/${reservation.id}`, { method: 'DELETE' })
refreshReservations()
}
return (
<div className="h-full flex flex-col">
<NavHeader />
{/* Calendar */}
<div className="flex-1 flex flex-col overflow-hidden">
<CalendarHeader
year={year}
month={month}
onPrevMonth={() => {
if (month === 0) {
setYear(year - 1)
setMonth(11)
} else {
setMonth(month - 1)
}
}}
onNextMonth={() => {
if (month === 11) {
setYear(year + 1)
setMonth(0)
} else {
setMonth(month + 1)
}
}}
onToday={handleToday}
onNewReservation={() => handleNewReservation()}
/>
<CalendarGrid
year={year}
month={month}
reservationsByDate={monthReservations}
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
onNewReservation={handleNewReservation}
onEditReservation={handleEditReservation}
onDeleteReservation={handleDelete}
/>
</div>
{/* Reservation Dialog */}
<ReservationDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSave={handleSave}
editReservation={editingReservation}
defaultDate={dialogDefaultDate}
/>
</div>
)
}
+290 -59
View File
@@ -11,9 +11,7 @@ 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 type { ConsumptionRecord, Reservation, CommissionItem } from '@/lib/types'
import { buildProfitData, calcTotals } from '@/lib/profit-utils'
import {
getWeekStart,
@@ -25,7 +23,7 @@ import {
isDateInRange,
} from '@/lib/date-utils'
import type { DateRange } from 'react-day-picker'
import { Plus, Edit2 } from 'lucide-react'
import { Plus, Edit2, CheckCircle2, Trash2 } from 'lucide-react'
type DateFilter = 'today' | 'week' | 'month' | 'year' | 'all' | 'custom'
@@ -60,8 +58,8 @@ function getFilterRange(filter: DateFilter): { start: Date; end: Date } | null {
}
const STATE_COLORS: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
'待分账': 'outline',
'已分账': 'secondary',
: 'outline',
: 'secondary',
}
export default function ProfitPage() {
@@ -72,8 +70,10 @@ export default function ProfitPage() {
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 [consumptionDefaults, setConsumptionDefaults] = useState<Partial<ConsumptionRecord> | undefined>(undefined)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleteTargetIds, setDeleteTargetIds] = useState<Set<string>>(new Set())
const [commissions, setCommissions] = useState<CommissionItem[]>([])
@@ -87,6 +87,17 @@ export default function ProfitPage() {
const [allReservations, setAllReservations] = useState<Reservation[]>([])
const [waiterMap, setWaiterMap] = useState<Record<string, { name: string; commissionRate: number }>>({})
const [allConsumptions, setAllConsumptions] = useState<ConsumptionRecord[]>([])
const [targetData, setTargetData] = useState<any>(null)
const [targetDialogOpen, setTargetDialogOpen] = useState(false)
const [editTargetAmount, setEditTargetAmount] = useState('')
const [targetCollapsed, setTargetCollapsed] = useState(false)
useEffect(() => {
fetch('/api/target/current')
.then((r) => r.json())
.then(setTargetData)
.catch(() => {})
}, [refreshKey])
useEffect(() => {
fetch('/api/reservation')
@@ -157,7 +168,7 @@ export default function ProfitPage() {
id: ci.id,
name: ci.name,
rate: ci.rate,
earnings: Math.round((c.amount * ci.rate) / 100),
earnings: (c.amount * ci.rate) / 100,
})),
_consumption: c,
})),
@@ -240,25 +251,6 @@ export default function ProfitPage() {
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)
@@ -273,6 +265,43 @@ export default function ProfitPage() {
setRefreshKey((n) => n + 1)
}
const handleBatchSettle = async () => {
const promises: Promise<any>[] = []
for (const id of selectedIds) {
const item = allItems.find((i) => i.id === id)
if (!item) continue
if (item.state !== '待分账') continue
if (item.type === 'reservation') {
promises.push(handleSettleReservation(id))
} else {
promises.push(handleSettleConsumption(id))
}
}
await Promise.all(promises)
setSelectedIds(new Set())
}
const handleBatchDelete = async () => {
const promises: Promise<any>[] = []
for (const id of deleteTargetIds) {
const item = allItems.find((i) => i.id === id)
if (!item) continue
if (item.type === 'reservation') {
promises.push(handleDeleteReservation(id))
} else {
promises.push(handleDeleteConsumption(id))
}
}
await Promise.all(promises)
setDeleteTargetIds(new Set())
setSelectedIds(new Set())
}
const openDeleteConfirm = (ids: Set<string>) => {
setDeleteTargetIds(ids)
setDeleteConfirmOpen(true)
}
const columns: ColumnDef<(typeof allItems)[number]>[] = [
{ id: 'customerName', header: '顾客', enableSorting: true, accessorKey: 'customerName' },
{ id: 'date', header: '日期', enableSorting: true, accessorKey: 'date' },
@@ -321,16 +350,7 @@ export default function ProfitPage() {
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>
) : (
{row.original.type === 'consumption' ? (
<Button
variant="ghost"
size="sm"
@@ -339,10 +359,30 @@ export default function ProfitPage() {
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => {
const reservation = allReservations.find((r) => r.id === row.original.id)
setEditingConsumption(null)
setConsumptionDefaults({
customerName: row.original.customerName,
date: row.original.date,
time: row.original.time,
waiterId: reservation?.waiterId || '',
state: '待分账',
})
setConsumptionOpen(true)
}}
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
</Button>
)}
{row.original.state === '待分账' && (
<Button
variant="outline"
variant="ghost"
size="sm"
className="h-7"
onClick={() =>
@@ -351,16 +391,17 @@ export default function ProfitPage() {
: handleSettleConsumption(row.original.id)
}
>
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
</Button>
)}
<DeletePopover
onConfirm={() =>
row.original.type === 'reservation'
? handleDeleteReservation(row.original.id)
: handleDeleteConsumption(row.original.id)
}
/>
<Button
variant="ghost"
size="sm"
className="h-7 text-destructive"
onClick={() => openDeleteConfirm(new Set([row.original.id]))}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
),
},
@@ -395,6 +436,102 @@ export default function ProfitPage() {
</Button>
</div>
{/* Target */}
<Card className={`shrink-0 ${targetData?.needsWarning ? 'border-destructive' : ''}`}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
onClick={() => setTargetCollapsed(!targetCollapsed)}
className="text-muted-foreground hover:text-foreground shrink-0"
>
{targetCollapsed ? '▶' : '▼'}
</button>
<div className="min-w-0 flex-1">
{targetCollapsed ? (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-foreground shrink-0">
🎯{' '}
{targetData?.hasTarget
? `¥${targetData.targetAmount.toLocaleString()}`
: '未设置'}
</span>
{targetData?.hasTarget && (
<>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden max-w-[200px]">
<div
className={`h-full rounded-full transition-all ${targetData.needsWarning ? 'bg-destructive' : 'bg-primary'}`}
style={{
width: `${Math.min((targetData.currentTotal / targetData.targetAmount) * 100, 100)}%`,
}}
/>
</div>
<span
className={`text-xs shrink-0 ${targetData.needsWarning ? 'text-destructive font-semibold' : 'text-muted-foreground'}`}
>
{(
(targetData.currentTotal / targetData.targetAmount) *
100
).toFixed(1)}
%
</span>
</>
)}
</div>
) : (
<div>
<div className="text-xs text-muted-foreground">
{targetData?.needsWarning && (
<span className="ml-2 text-destructive font-semibold">
</span>
)}
</div>
<div className="text-xl font-bold text-foreground mt-1">
{targetData?.hasTarget
? `¥${targetData.targetAmount.toLocaleString()}`
: '未设置'}
</div>
{targetData?.hasTarget && (
<div className="text-xs text-muted-foreground mt-0.5">
¥{targetData.currentTotal.toLocaleString()} (
{targetData.targetAmount > 0
? (
(targetData.currentTotal / targetData.targetAmount) *
100
).toFixed(1)
: 0}
%)
</div>
)}
</div>
)}
</div>
</div>
{!targetCollapsed && (
<Button
variant="outline"
size="sm"
onClick={() => {
setEditTargetAmount(String(targetData?.targetAmount || ''))
setTargetDialogOpen(true)
}}
>
</Button>
)}
</div>
{!targetCollapsed && targetData?.needsWarning && (
<div className="mt-2 p-2 bg-destructive/10 rounded text-sm text-destructive">
¥{targetData.targetAmount.toLocaleString()} ¥
{targetData.projected.toLocaleString()} ¥{targetData.gap.toLocaleString()}
{targetData.gapPercent}%
</div>
)}
</CardContent>
</Card>
{/* Stats cards: fixed + dynamic commission cards */}
<div className="flex gap-4 shrink-0 flex-wrap">
<Card className="flex-1 min-w-[160px]">
@@ -475,29 +612,123 @@ export default function ProfitPage() {
searchKey="customerName"
searchPlaceholder="搜索顾客姓名..."
tableMinWidth="1300px"
enableRowSelection={true}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
getId={(row) => row.id}
toolbarExtra={
selectedIds.size > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> {selectedIds.size} </span>
<Button variant="default" size="sm" className="h-8" onClick={handleBatchSettle}>
</Button>
<Button
variant="destructive"
size="sm"
className="h-8"
onClick={() => openDeleteConfirm(selectedIds)}
>
</Button>
</div>
)
}
/>
</div>
</div>
{/* Target Dialog */}
{targetDialogOpen && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setTargetDialogOpen(false)}
>
<div
className="bg-background rounded-lg p-6 w-[400px] shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4"></h2>
<div>
<label className="text-sm text-muted-foreground"></label>
<Input
type="number"
value={editTargetAmount}
onChange={(e) => setEditTargetAmount(e.target.value)}
placeholder="请输入本月业绩目标"
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => setTargetDialogOpen(false)}>
</Button>
<Button
onClick={async () => {
const now = new Date()
await fetch('/api/target', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
year: now.getFullYear(),
month: now.getMonth() + 1,
targetAmount: Number(editTargetAmount),
}),
})
setTargetDialogOpen(false)
setRefreshKey((n) => n + 1)
}}
>
</Button>
</div>
</div>
</div>
)}
{/* Delete Confirm Dialog */}
{deleteConfirmOpen && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setDeleteConfirmOpen(false)}
>
<div
className="bg-background rounded-lg p-6 w-[400px] shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-2"></h2>
<p className="text-sm text-muted-foreground mb-6">
{deleteTargetIds.size}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={async () => {
await handleBatchDelete()
setDeleteConfirmOpen(false)
}}
>
</Button>
</div>
</div>
</div>
)}
<ConsumptionDialog
open={consumptionOpen}
onOpenChange={(v) => {
setConsumptionOpen(v)
if (!v) setEditingConsumption(null)
if (!v) {
setEditingConsumption(null)
setConsumptionDefaults(undefined)
}
}}
onSave={handleConsumptionSave}
editRecord={editingConsumption}
/>
<ReservationDialog
open={reservationOpen}
onOpenChange={(v) => {
setReservationOpen(v)
if (!v) setEditingReservation(null)
}}
onSave={handleReservationSave}
editReservation={editingReservation}
defaultDate=""
defaultValues={consumptionDefaults}
/>
</div>
)
+4 -1
View File
@@ -172,7 +172,10 @@ export default function WaiterPage() {
variant="ghost"
size="sm"
className="h-8"
onClick={() => { setEditingWaiter(row.original); setDialogOpen(true) }}
onClick={() => {
setEditingWaiter(row.original)
setDialogOpen(true)
}}
>
<Edit2 className="h-4 w-4 mr-1" />
</Button>
+50 -18
View File
@@ -1,13 +1,12 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, useRef } 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'
@@ -17,13 +16,14 @@ interface ConsumptionDialogProps {
onOpenChange: (v: boolean) => void
onSave: (record: Omit<ConsumptionRecord, 'id'>) => void
editRecord?: ConsumptionRecord | null
defaultValues?: Partial<ConsumptionRecord>
}
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) {
export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord, defaultValues }: ConsumptionDialogProps) {
const [customerName, setCustomerName] = useState('')
const [date, setDate] = useState(todayStr)
const [time, setTime] = useState(nowStr)
@@ -39,6 +39,13 @@ export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: Co
setWaiterId(editRecord.waiterId)
setAmount(String(editRecord.amount))
setState(editRecord.state || '待分账')
} else if (defaultValues) {
setCustomerName(defaultValues.customerName || '')
setDate(defaultValues.date || todayStr)
setTime(defaultValues.time || nowStr)
setWaiterId(defaultValues.waiterId || '')
setAmount(defaultValues.amount ? String(defaultValues.amount) : '')
setState(defaultValues.state || '待分账')
} else {
setCustomerName('')
setDate(todayStr)
@@ -47,11 +54,13 @@ export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: Co
setAmount('')
setState('待分账')
}
}, [editRecord, open])
}, [editRecord, open, defaultValues])
const [waiters, setWaiters] = useState<Waiter[]>([])
const [reservations, setReservations] = useState<Reservation[]>([])
const [commissions, setCommissions] = useState<CommissionItem[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const nameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
Promise.all([
@@ -67,11 +76,16 @@ export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: Co
.catch(() => {})
}, [open])
const customerOptions = useMemo(() => {
const customerNames = useMemo(() => {
const names = new Set(reservations.map((r) => r.customerName))
return Array.from(names).map((n) => ({ value: n, label: n }))
return Array.from(names)
}, [reservations])
const filteredSuggestions = useMemo(() => {
if (!customerName) return customerNames.slice(0, 5)
return customerNames.filter((n) => n.includes(customerName)).slice(0, 5)
}, [customerName, customerNames])
const selectedNameData = useMemo(() => {
if (!customerName) return null
return reservations.find((r) => r.customerName === customerName)
@@ -80,8 +94,9 @@ export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: Co
const selectedWaiter = useMemo(() => waiters.find((w) => w.id === waiterId), [waiterId, waiters])
const numAmount = Number(amount) || 0
const handleNameChange = (name: string) => {
const handleSelectName = (name: string) => {
setCustomerName(name)
setShowSuggestions(false)
const record = reservations.find((r) => r.customerName === name)
if (record) {
setDate(record.date)
@@ -103,8 +118,8 @@ export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: Co
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),
waiterEarnings: (amt * waiter.commissionRate) / 100,
adminEarnings: commissions.reduce((s, c) => s + (amt * c.rate) / 100, 0),
state,
})
onOpenChange(false)
@@ -127,7 +142,7 @@ export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: Co
<div className="text-lg font-semibold mt-0.5">
¥
{selectedWaiter && numAmount > 0
? Math.round((numAmount * selectedWaiter.commissionRate) / 100).toLocaleString()
? ((numAmount * selectedWaiter.commissionRate) / 100).toLocaleString()
: '0'}
</div>
<div className="text-xs text-muted-foreground">
@@ -136,7 +151,7 @@ export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: Co
</CardContent>
</Card>
{commissions.map((c) => {
const earn = numAmount > 0 ? Math.round((numAmount * c.rate) / 100) : 0
const earn = numAmount > 0 ? (numAmount * c.rate) / 100 : 0
return (
<Card key={c.id}>
<CardContent className="p-3">
@@ -151,16 +166,33 @@ export function ConsumptionDialog({ open, onOpenChange, onSave, editRecord }: Co
{/* Form */}
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<div className="grid grid-cols-4 items-center gap-4 relative">
<Label className="text-right"> *</Label>
<div className="col-span-3">
<Combobox
options={customerOptions}
<div className="col-span-3 relative">
<Input
ref={nameInputRef}
value={customerName}
onChange={handleNameChange}
placeholder="选择或输入顾客"
emptyText="未找到匹配顾客"
onChange={(e) => setCustomerName(e.target.value)}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
placeholder="输入或选择顾客姓名"
/>
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 rounded-md border bg-popover shadow-md">
<div className="py-1">
{filteredSuggestions.map((name) => (
<button
key={name}
type="button"
className="w-full px-3 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onMouseDown={() => handleSelectName(name)}
>
{name}
</button>
))}
</div>
</div>
)}
</div>
</div>
{selectedNameData && (
+74 -22
View File
@@ -6,6 +6,7 @@ import {
type SortingState,
type ColumnFiltersState,
type VisibilityState,
type RowSelectionState,
flexRender,
getCoreRowModel,
getSortedRowModel,
@@ -17,6 +18,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
interface DataTableProps<TData> {
@@ -26,6 +28,11 @@ interface DataTableProps<TData> {
searchKey?: string
searchPlaceholder?: string
tableMinWidth?: string
enableRowSelection?: boolean
selectedIds?: Set<string>
onSelectionChange?: (ids: Set<string>) => void
getId?: (row: TData) => string
toolbarExtra?: React.ReactNode
}
export function DataTable<TData>({
@@ -35,15 +42,48 @@ export function DataTable<TData>({
searchKey,
searchPlaceholder = '搜索...',
tableMinWidth = '100%',
enableRowSelection,
selectedIds,
onSelectionChange,
getId,
toolbarExtra,
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [globalFilter, setGlobalFilter] = useState('')
const selectionCol: ColumnDef<TData> = {
id: 'select',
header: ({ table }) =>
enableRowSelection ? (
<Checkbox
checked={table.getIsAllRowsSelected()}
onCheckedChange={(v) => table.toggleAllRowsSelected(!!v)}
aria-label="全选"
/>
) : null,
cell: ({ row }) =>
enableRowSelection ? (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="选择行"
/>
) : null,
enableSorting: false,
}
const allColumns = enableRowSelection ? [selectionCol, ...columns] : columns
const rowSelection: RowSelectionState = {}
if (enableRowSelection && selectedIds) {
for (const id of selectedIds) rowSelection[id] = true
}
const table = useReactTable({
data,
columns,
columns: allColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@@ -52,8 +92,17 @@ export function DataTable<TData>({
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onGlobalFilterChange: setGlobalFilter,
state: { sorting, columnFilters, columnVisibility, globalFilter },
state: { sorting, columnFilters, columnVisibility, globalFilter, rowSelection },
initialState: { pagination: { pageSize } },
enableRowSelection,
onRowSelectionChange: (updater) => {
if (!onSelectionChange || !getId) return
const current: RowSelectionState = {}
for (const id of selectedIds || []) current[id] = true
const next = typeof updater === 'function' ? updater(current) : updater
onSelectionChange(new Set(Object.keys(next).filter((k) => next[k])))
},
getRowId: (row) => (getId ? getId(row) : ((row as any).id ?? '')),
})
const SortIcon = ({ column }: { column: any }) => {
@@ -76,7 +125,7 @@ export function DataTable<TData>({
/>
)}
<div className="flex-1" />
<span className="text-xs text-muted-foreground"> {data.length} </span>
{toolbarExtra}
</div>
{/* Scrollable table */}
@@ -117,7 +166,7 @@ export function DataTable<TData>({
) : (
<TableRow>
<TableCell
colSpan={columns.length}
colSpan={allColumns.length}
className="h-24 text-center text-muted-foreground"
>
@@ -131,24 +180,27 @@ export function DataTable<TData>({
{/* 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 className="flex items-center gap-3 text-sm text-muted-foreground">
<span> {data.length} </span>
<span className="flex items-center gap-2">
<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>
</span>
</div>
<div className="flex items-center gap-1">
<Button
+13 -2
View File
@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { format } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { Calendar as CalendarIcon } from 'lucide-react'
@@ -17,8 +18,10 @@ interface DatePickerProps {
}
export function DatePicker({ value, onChange, className, placeholder = '选择日期' }: DatePickerProps) {
const [open, setOpen] = useState(false)
return (
<Popover>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -33,7 +36,15 @@ export function DatePicker({ value, onChange, className, placeholder = '选择
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar mode="single" selected={value} onSelect={onChange} locale={zhCN} />
<Calendar
mode="single"
selected={value}
onSelect={(date) => {
onChange(date)
if (date) setOpen(false)
}}
locale={zhCN}
/>
</PopoverContent>
</Popover>
)
+32 -22
View File
@@ -1,6 +1,16 @@
'use client'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Trash2 } from 'lucide-react'
@@ -10,27 +20,27 @@ interface DeletePopoverProps {
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" />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
</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>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+45 -2
View File
@@ -2,18 +2,33 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
import { CalendarDays, Users, TrendingUp } from 'lucide-react'
import { CalendarDays, Users, TrendingUp, Wallet, Target } from 'lucide-react'
const NAV_ITEMS = [
{ href: '/', label: '预约管理', icon: CalendarDays },
{ href: '/waiter', label: '服务员管理', icon: Users },
{ href: '/profit', label: '利润分红', icon: TrendingUp },
{ href: '/fund-pool', label: '资金池', icon: Wallet },
]
export function NavHeader() {
const pathname = usePathname()
const [balance, setBalance] = useState<{ balance: number; maxPoolAmount: number } | null>(null)
const [targetData, setTargetData] = useState<any>(null)
useEffect(() => {
fetch('/api/fund-pool/balance')
.then((r) => r.json())
.then(setBalance)
.catch(() => {})
fetch('/api/target/current')
.then((r) => r.json())
.then(setTargetData)
.catch(() => {})
}, [])
return (
<header className="flex items-center px-6 py-3 border-b bg-background">
@@ -39,7 +54,35 @@ export function NavHeader() {
</Link>
))}
</nav>
<div className="w-[200px] flex justify-end">
<div className="w-[200px] flex items-center justify-end gap-2">
{targetData?.hasTarget && (
<Link href="/profit">
<Button variant="ghost" size="sm" className="text-sm gap-1.5 text-muted-foreground">
<Target className="h-4 w-4" />
<span>
¥{targetData.currentTotal.toLocaleString()} / ¥
{targetData.targetAmount.toLocaleString()}
<span className="ml-1">
(
{targetData.targetAmount > 0
? ((targetData.currentTotal / targetData.targetAmount) * 100).toFixed(1)
: 0}
%)
</span>
</span>
</Button>
</Link>
)}
{balance !== null && balance.maxPoolAmount > 0 && (
<Link href="/fund-pool">
<Button variant="ghost" size="sm" className="text-sm gap-1.5 text-muted-foreground">
<Wallet className="h-4 w-4" />
<span>
¥{balance.balance.toLocaleString()} / ¥{balance.maxPoolAmount.toLocaleString()}
</span>
</Button>
</Link>
)}
<ThemeToggle />
</div>
</header>
+46 -46
View File
@@ -5,55 +5,55 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger }
import type { Reservation } from '@/lib/types'
interface ReservationCardProps {
reservation: Reservation
onClick: (reservation: Reservation) => void
onEdit?: (reservation: Reservation) => void
onDelete?: (reservation: Reservation) => void
reservation: Reservation
onClick: (reservation: Reservation) => void
onEdit?: (reservation: Reservation) => void
onDelete?: (reservation: Reservation) => void
}
export function ReservationCard({ reservation, onClick, onEdit, onDelete }: ReservationCardProps) {
const [waiterName, setWaiterName] = useState('')
const [waiterName, setWaiterName] = useState('')
useEffect(() => {
fetch(`/api/waiter/${reservation.waiterId}`)
.then((r) => r.json())
.then((w) => setWaiterName(w.name || ''))
.catch(() => {})
}, [reservation.waiterId])
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-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}
</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>
)
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-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}
</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>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { checkSupabaseConnection } from '@/lib/supabase'
export async function SupabaseHealthCheck() {
const result = await checkSupabaseConnection()
if (!result.ok) {
console.error('[HealthCheck]', result.message)
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-destructive text-destructive-foreground text-sm text-center py-2 px-4">
{result.message}
</div>
)
}
return null
}
+106
View File
@@ -0,0 +1,106 @@
'use client'
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
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}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.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}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+28
View File
@@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
@@ -0,0 +1,160 @@
# Fund Pool, Target & Supabase Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add fund pool management, monthly targets with early warnings, and Supabase migration readiness to Keeppay.
**Architecture:** Extend existing Prisma + SQLite + API Routes pattern. Add 3 new Prisma models, CRUD APIs following existing conventions, and new client components. Supabase migration kept as separate ready-to-use scripts.
**Tech Stack:** Next.js 16, Prisma 7, SQLite, Tailwind 4, shadcn/ui
---
### Task 1: Add Prisma Models
**Files:**
- Modify: `prisma/schema.prisma`
- [ ] **Add MonthlyTarget, FundPoolConfig, FundPoolExpense models to schema.prisma**
Add after the existing `Consumption` model:
```prisma
model MonthlyTarget {
id String @id
year Int
month Int
targetAmount Int @map("target_amount")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([year, month])
@@map("monthly_targets")
}
model FundPoolConfig {
id String @id
monthlyPercent Int @map("monthly_percent")
maxPoolAmount Int @map("max_pool_amount")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("fund_pool_configs")
}
model FundPoolExpense {
id String @id
amount Int
category String
note String @default("")
handler String @default("")
date String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("fund_pool_expenses")
}
model FundPoolAllocation {
id String @id
year Int
month Int
amount Int
createdAt DateTime @default(now()) @map("created_at")
@@map("fund_pool_allocations")
}
- [ ] **Create migration shell script**
```bash
#!/bin/bash
# migrate-to-supabase.sh
# Usage: bash scripts/migrate-to-supabase.sh
set -e
echo "=== Keeppay Supabase Migration ==="
# 1. Export data from SQLite
echo "[1/4] Exporting data from SQLite..."
npx tsx scripts/export-data.ts
# 2. Switch Prisma schema
echo "[2/4] Switching to PostgreSQL schema..."
cp prisma/schema.supabase.prisma prisma/schema.prisma
# 3. Install PostgreSQL adapter
echo "[3/4] Installing PostgreSQL adapter..."
pnpm add @prisma/adapter-pg
# 4. Generate and migrate
echo "[4/4] Running migration..."
npx prisma generate
npx prisma migrate dev --name supabase-init
echo "=== Migration complete! ==="
echo ""
echo "Next steps:"
echo "1. Set DATABASE_URL in .env to your Supabase connection string"
echo "2. Run: npx tsx scripts/import-data.ts"
echo "3. Update prisma.config.ts to use @prisma/adapter-pg"
echo ""
```
- [ ] **Create data export script**
```typescript
// scripts/export-data.ts
import { PrismaClient } from '@prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import * as fs from 'fs'
import * as path from 'path'
const adapter = new PrismaBetterSqlite3({ url: './data/keeppay.db' })
const prisma = new PrismaClient({ adapter })
async function main() {
const tables = ['waiter', 'reservation', 'commissionItem', 'consumption', 'monthlyTarget', 'fundPoolConfig', 'fundPoolExpense', 'fundPoolAllocation'] as const
const data: Record<string, any[]> = {}
for (const table of tables) {
const rows = await (prisma as any)[table].findMany()
data[table] = rows
console.log(`Exported ${rows.length} rows from ${table}`)
}
const outDir = path.join(__dirname, '..', 'data')
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
fs.writeFileSync(path.join(outDir, 'export.json'), JSON.stringify(data, null, 2))
console.log(`Data exported to data/export.json`)
}
main().catch(console.error)
```
- [ ] **Commit**
```bash
git add prisma/schema.supabase.prisma scripts/
git commit -m "feat: add supabase migration scripts"
```
---
### Task 12: Self-Review and Build Check
- [ ] **Run build to verify everything compiles**
```bash
pnpm build
```
- [ ] **Fix any build errors if present**
- [ ] **Final commit with any fixes**
```bash
git add -A
git commit -m "fix: resolve build issues"
```
@@ -0,0 +1,135 @@
# Fund Pool, Monthly Target & Supabase Migration Design
## Overview
Four features for the Keeppay restaurant management system:
1. Supabase migration readiness
2. Monthly performance targets with early warnings on the profit page
3. Fund pool displayed in header with monthly profit allocation
4. Fund pool expense tracking
## Architecture
Extension of the existing Prisma + SQLite + API Routes pattern (Approach A). All new code follows existing conventions: Prisma models → API routes → Client components.
## Data Models
### MonthlyTarget
- `id` String @id
- `year` Int
- `month` Int
- `targetAmount` Int — monthly performance goal (in cents/fen)
- Unique constraint on (year, month)
### FundPoolConfig (singleton row)
- `id` String @id
- `monthlyPercent` Int — percentage of monthly net profit allocated to pool
- `maxPoolAmount` Int — cap for the pool (in cents)
### FundPoolExpense
- `id` String @id
- `amount` Int — expense amount (in cents)
- `category` String — categories: 房租, 物资, 其他
- `note` String — description
- `handler` String — person responsible
- `date` String — expense date (YYYY-MM-DD)
Pool balance = total allocations - total expenses (computed dynamically).
## API Routes
### `/api/target`
- `GET` — list all targets (optional year/month query)
- `POST` — create/update target for `{year, month, targetAmount}`
### `/api/target/current`
- `GET` — returns current month target + completion status + prediction
### `/api/fund-pool/config`
- `GET` — get current pool config
- `PUT` — update config (monthlyPercent, maxPoolAmount)
### `/api/fund-pool/balance`
- `GET` — returns `{ balance, totalAllocated, totalExpenses, maxPoolAmount, percentUsed }`
### `/api/fund-pool/allocate`
- `POST` — trigger monthly allocation (auto-calculated from monthly net profit)
### `/api/fund-pool/expenses`
- `GET` — list expenses (with date range filters)
- `POST` — create expense
### `/api/fund-pool/expenses/[id]`
- `PUT` — update expense
- `DELETE` — delete expense
## Components
### NavHeader Enhancement
- Add pool balance display between nav items and ThemeToggle
- Format: `🏦 ¥XX,XXX / ¥XXX,XXX` (balance / cap)
- Click navigates to `/fund-pool`
### Profit Page Enhancement (`app/profit/page.tsx`)
- New "业绩目标" card above stats row:
- Shows target amount, current completed, percentage
- "设置目标" button → inline edit or dialog
- Early warning (10 days before month end):
- Calculates daily average from day 1 to today
- Projects month-end total: `dailyAverage × daysInMonth`
- If projected < target: red alert banner
- Alert content: `⚠️ 本月目标 ¥X, 预计 ¥Y, 还差 ¥Z (缺口N%)`
### Fund Pool Page (`app/fund-pool/page.tsx`)
- Config section: monthly percent, max pool cap (editable)
- Balance summary card
- "入账记录" section: read-only list of monthly allocations
- "支出记录" section: table with add/edit/delete
- Add expense dialog: amount, category (select), note, handler, date
- Nav item: `🏦 资金池`
## Early Warning Algorithm
```
if (daysRemaining > 10) → no warning
if (currentTotal >= target) → no warning (target met)
dailyAverage = currentTotal / daysElapsed
projected = dailyAverage × daysInMonth
if (projected < target) → show warning with gap = target - projected
```
## Supabase Migration
Files to create:
- `prisma/schema.supabase.prisma` — PostgreSQL-compatible schema (provider = "postgresql")
- `scripts/migrate-to-supabase.sh` — export SQLite → JSON → import to PostgreSQL
- `.env.supabase.example` — template for Supabase connection string
Changes in supabase schema vs sqlite:
- Provider: postgresql
- Gallery field: `String` → can keep as String (JSON stored as text) or use `Json`
- Amount fields: `Int` remains same
## Files to Modify/Create
### Modified:
- `prisma/schema.prisma` — add new models
- `lib/db.ts` — add new CRUD functions
- `components/nav-header.tsx` — add pool balance
- `app/profit/page.tsx` — add target card + warning
- `app/layout.tsx` — nothing to change (NavHeader handles itself)
### New:
- `lib/fund-pool-utils.ts` — pool balance calculation
- `app/fund-pool/page.tsx` — pool management page
- `app/api/target/route.ts` — target list/create
- `app/api/target/current/route.ts` — current month target + prediction
- `app/api/fund-pool/config/route.ts` — config get/put
- `app/api/fund-pool/balance/route.ts` — balance endpoint
- `app/api/fund-pool/allocate/route.ts` — monthly allocation
- `app/api/fund-pool/expenses/route.ts` — expenses list/create
- `app/api/fund-pool/expenses/[id]/route.ts` — expense update/delete
- `prisma/schema.supabase.prisma` — PostgreSQL schema
- `scripts/migrate-to-supabase.sh` — migration script
- `scripts/migrate-to-supabase.js` — data export/import script
+22 -20
View File
@@ -4,28 +4,30 @@ import { useState, useEffect, useCallback } from 'react'
import type { Reservation } from '@/lib/types'
export function useReservations(year: number, month: number) {
const [reservations, setReservations] = useState<Reservation[]>([])
const [loading, setLoading] = useState(true)
const [reservations, setReservations] = useState<Reservation[]>([])
const [loading, setLoading] = useState(true)
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])
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])
useEffect(() => {
load()
}, [load])
const getReservationsForDate = useCallback(
(date: string): Reservation[] => reservations.filter((r) => r.date === date),
[reservations],
)
const getReservationsForDate = useCallback(
(date: string): Reservation[] => reservations.filter((r) => r.date === date),
[reservations],
)
return { getReservationsForDate, loading, refresh: load }
return { getReservationsForDate, loading, refresh: load }
}
+375 -293
View File
@@ -1,70 +1,221 @@
import { PrismaClient } from '@/lib/generated/prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { supabase } from './supabase'
import { nextId } from './snowflake'
import type { Waiter, Media, Reservation, CreateReservationInput, CommissionItem, ConsumptionRecord, ConsumptionState } from './types'
import type { Waiter, Media, Reservation, CommissionItem, ConsumptionRecord, ConsumptionState } from './types'
const adapter = new PrismaBetterSqlite3({ url: './data/keeppay.db' })
const prisma = new PrismaClient({ adapter })
function rowToWaiter(row: any): Waiter {
function toWaiter(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,
commissionRate: row.commission_rate,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function toReservation(row: any): Reservation {
return {
id: row.id,
customerName: row.customer_name,
date: row.date,
time: row.time,
waiterId: row.waiter_id,
package: row.package || undefined,
note: row.note || undefined,
state: (row.state as ConsumptionState) || '待分账',
}
}
function toCommissionItem(row: any): CommissionItem {
return { id: row.id, name: row.name, rate: row.rate }
}
function toConsumptionRecord(row: any): ConsumptionRecord {
return {
id: row.id,
customerName: row.customer_name,
date: row.date,
time: row.time,
waiterId: row.waiter_id,
waiterName: row.waiter_name,
commissionRate: row.commission_rate,
amount: row.amount,
waiterEarnings: row.waiter_earnings,
adminEarnings: row.admin_earnings,
state: row.state as ConsumptionState,
}
}
// --- Seed / Init ---
export async function seedWaiters() {
const count = await prisma.waiter.count()
if (count > 0) return
const { count } = await supabase.from('waiters').select('*', { count: 'exact', head: true })
if ((count ?? 0) > 0) return
const seeds = [
{
name: '王小明',
avatar: 'https://api.dicebear.com/9.x/avataaars-neutral/svg?backgroundColor=b6e3f4&seed=wang',
gallery: [],
gallery: '[]',
specialty: '宴会/商务',
commissionRate: 30,
commission_rate: 30,
status: '上班中',
},
{
name: '李小红',
avatar: 'https://api.dicebear.com/9.x/avataaars-neutral/svg?backgroundColor=b6e3f4&seed=li',
gallery: [],
gallery: '[]',
specialty: '婚宴/生日',
commissionRate: 25,
commission_rate: 25,
status: '上班中',
},
{
name: '张三',
avatar: 'https://api.dicebear.com/9.x/avataaars-neutral/svg?backgroundColor=b6e3f4&seed=zhang',
gallery: [],
gallery: '[]',
specialty: '日常接待',
commissionRate: 20,
commission_rate: 20,
status: '休息中',
},
]
await prisma.waiter.createMany({
data: seeds.map((s) => ({ ...s, id: nextId(), gallery: JSON.stringify(s.gallery) })),
})
await supabase.from('waiters').insert(seeds.map((s) => ({ ...s, id: nextId() })))
}
export async function seedReservations() {
const { count } = await supabase.from('reservations').select('*', { count: 'exact', head: true })
if ((count ?? 0) > 0) return
await seedWaiters()
const { data: allWaiters } = await supabase.from('waiters').select('id,name')
const waiterByName: Record<string, string> = {}
for (const w of allWaiters || []) waiterByName[w.name] = w.id
const defaultWaiterId = allWaiters?.[0]?.id || ''
const seeds = [
{
customer_name: '张伟',
date: '2026-05-16',
time: '11:00',
waiter_id: '',
package: '生日宴',
note: '需要蛋糕',
},
{ customer_name: '李芳', date: '2026-05-16', time: '12:30', waiter_id: '', package: '商务宴请' },
{ customer_name: '赵强', date: '2026-05-16', time: '18:00', waiter_id: '' },
{ customer_name: '刘梅', date: '2026-05-20', time: '11:30', waiter_id: '', package: '婚宴' },
{ customer_name: '陈先生', date: '2026-05-12', time: '10:00', waiter_id: '' },
{ customer_name: '王太太', date: '2026-05-12', time: '14:00', waiter_id: '', package: '下午茶' },
{ customer_name: '林总', date: '2026-05-14', time: '19:00', waiter_id: '', package: '商务宴' },
{ customer_name: '周小姐', date: '2026-05-08', time: '09:30', waiter_id: '', package: '早茶' },
{ customer_name: '吴老板', date: '2026-05-09', time: '12:00', waiter_id: '', package: '商务套餐' },
{ customer_name: '郑先生', date: '2026-05-10', time: '19:30', waiter_id: '' },
{ customer_name: '孙女士', date: '2026-05-11', time: '11:30', waiter_id: '', package: '生日宴' },
{
customer_name: '钱总',
date: '2026-05-15',
time: '18:30',
waiter_id: '',
package: '商务宴请',
note: 'VIP包间',
},
{ customer_name: '何先生', date: '2026-05-18', time: '12:00', waiter_id: '' },
{ customer_name: '黄女士', date: '2026-05-19', time: '10:30', waiter_id: '', package: '早茶' },
{ customer_name: '马先生', date: '2026-05-22', time: '17:00', waiter_id: '', package: '下午茶' },
{ customer_name: '梁小姐', date: '2026-05-23', time: '12:30', waiter_id: '', note: '素食' },
{ customer_name: '宋总', date: '2026-05-25', time: '19:00', waiter_id: '', package: '商务宴' },
{ customer_name: '唐女士', date: '2026-05-26', time: '11:00', waiter_id: '', package: '生日宴' },
{ customer_name: '韩先生', date: '2026-05-28', time: '14:00', waiter_id: '' },
{ customer_name: '冯女士', date: '2026-05-30', time: '18:00', waiter_id: '', package: '婚宴', note: '试菜' },
{ customer_name: '曹老板', date: '2026-04-10', time: '12:00', waiter_id: '', package: '商务宴' },
{ customer_name: '邓女士', date: '2026-04-15', time: '11:30', waiter_id: '', package: '生日宴' },
{ customer_name: '彭先生', date: '2026-04-22', time: '18:00', waiter_id: '' },
{ customer_name: '蒋小姐', date: '2026-04-28', time: '19:30', waiter_id: '', package: '商务宴' },
{ customer_name: '余先生', date: '2026-06-05', time: '12:00', waiter_id: '', package: '商务宴请' },
{ customer_name: '潘女士', date: '2026-06-08', time: '11:30', waiter_id: '' },
{ customer_name: '方总', date: '2026-06-12', time: '18:30', waiter_id: '', package: '商务宴' },
{ customer_name: '钟先生', date: '2026-06-15', time: '10:00', waiter_id: '' },
{ customer_name: '谭小姐', date: '2026-06-18', time: '14:00', waiter_id: '', package: '下午茶' },
{ customer_name: '陆总', date: '2026-06-20', time: '19:00', waiter_id: '', package: '商务宴请' },
]
await supabase.from('reservations').insert(
seeds.map((s) => {
const waiterName = s.waiter_id === '' ? '王小明' : '王小明'
return { ...s, id: nextId(), waiter_id: waiterByName[waiterName] || defaultWaiterId }
}),
)
}
export async function seedCommissionItems() {
const { count } = await supabase.from('commission_items').select('*', { count: 'exact', head: true })
if ((count ?? 0) > 0) return
await supabase.from('commission_items').insert({ id: nextId(), name: '平台分红', rate: 10 })
}
export async function seedConsumptions() {
const { count } = await supabase.from('consumptions').select('*', { count: 'exact', head: true })
if ((count ?? 0) > 0) return
const { data: allWaiters } = await supabase.from('waiters').select('id,name')
const waiterByName: Record<string, any> = {}
for (const w of allWaiters || []) waiterByName[w.name] = w
const seeds = [
{
customer_name: '张伟',
date: '2026-05-16',
time: '11:00',
waiter_name: '王小明',
commission_rate: 30,
amount: 1200,
},
{
customer_name: '李芳',
date: '2026-05-16',
time: '12:30',
waiter_name: '李小红',
commission_rate: 25,
amount: 800,
},
{
customer_name: '赵强',
date: '2026-05-18',
time: '18:00',
waiter_name: '王小明',
commission_rate: 30,
amount: 2000,
},
]
await supabase.from('consumptions').insert(
seeds.map((s) => {
const waiter = waiterByName[s.waiter_name]
return {
id: nextId(),
...s,
waiter_id: waiter?.id || '',
waiter_earnings: (s.amount * s.commission_rate) / 100,
admin_earnings: 0,
state: '待分账',
}
}),
)
}
// --- Waiter CRUD ---
export async function listWaiters(): Promise<Waiter[]> {
const rows = await prisma.waiter.findMany({ orderBy: { createdAt: 'desc' } })
return rows.map(rowToWaiter)
const { data } = await supabase.from('waiters').select('*').order('created_at', { ascending: false })
return (data || []).map(toWaiter)
}
export async function getWaiter(id: string): Promise<Waiter | null> {
const row = await prisma.waiter.findUnique({ where: { id } })
return row ? rowToWaiter(row) : null
const { data } = await supabase.from('waiters').select('*').eq('id', id).single()
return data ? toWaiter(data) : null
}
export async function createWaiter(data: {
@@ -75,18 +226,17 @@ export async function createWaiter(data: {
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)
const insert = {
id: nextId(),
name: data.name,
avatar: data.avatar || '',
gallery: JSON.stringify(data.gallery || []),
specialty: data.specialty,
commission_rate: data.commissionRate,
status: data.status || '初始化',
}
const { data: row } = await supabase.from('waiters').insert(insert).select().single()
return toWaiter(row)
}
export async function updateWaiter(
@@ -105,318 +255,250 @@ export async function updateWaiter(
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.commissionRate !== undefined) updateData.commission_rate = 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,
}
}),
})
const { data: row } = await supabase.from('waiters').update(updateData).eq('id', id).select().single()
return row ? toWaiter(row) : null
}
export async function deleteWaiter(id: string): Promise<boolean> {
try {
await prisma.waiter.delete({ where: { id } })
return true
} catch {
return false
}
const { data, error } = await supabase.from('waiters').delete().eq('id', id).select('id')
return !error && (data?.length || 0) > 0
}
// --- 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 = {}
export async function listReservations(params?: { year?: number; month?: number }): Promise<Reservation[]> {
let query = supabase.from('reservations').select('*').order('created_at', { ascending: false })
if (params?.year !== undefined && params?.month !== undefined) {
const prefix = `${params.year}-${String(params.month).padStart(2, '0')}`
where.date = { startsWith: prefix }
query = query.like('date', `${prefix}%`)
}
const rows = await prisma.reservation.findMany({ where, orderBy: { createdAt: 'desc' } })
return rows.map(rowToReservation)
const { data } = await query
return (data || []).map(toReservation)
}
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 createReservation(data: any): Promise<Reservation> {
const insert = {
id: nextId(),
customer_name: data.customerName,
date: data.date,
time: data.time,
waiter_id: data.waiterId,
package: data.package || '',
note: data.note || '',
state: data.state || '待分账',
}
const { data: row } = await supabase.from('reservations').insert(insert).select().single()
return toReservation(row)
}
export async function updateReservation(
id: string,
data: Partial<CreateReservationInput>,
): Promise<Reservation | null> {
export async function updateReservation(id: string, data: any): Promise<Reservation | null> {
const updateData: any = {}
if (data.customerName !== undefined) updateData.customerName = data.customerName
if (data.customerName !== undefined) updateData.customer_name = 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.waiterId !== undefined) updateData.waiter_id = 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
}
const { data: row } = await supabase.from('reservations').update(updateData).eq('id', id).select().single()
return row ? toReservation(row) : 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 },
})
export async function deleteReservation(id: string): Promise<boolean> {
const { data, error } = await supabase.from('reservations').delete().eq('id', id).select('id')
return !error && (data?.length || 0) > 0
}
// --- 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 }))
const { data } = await supabase.from('commission_items').select('*').order('created_at', { ascending: true })
return (data || []).map(toCommissionItem)
}
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 }
const { data: row } = await supabase
.from('commission_items')
.insert({ id: nextId(), ...data })
.select()
.single()
return toCommissionItem(row)
}
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
}
const { data: row } = await supabase.from('commission_items').update(data).eq('id', id).select().single()
return row ? toCommissionItem(row) : 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: '待分账',
})),
})
const { data, error } = await supabase.from('commission_items').delete().eq('id', id).select('id')
return !error && (data?.length || 0) > 0
}
// --- 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,
}))
const { data } = await supabase.from('consumptions').select('*').order('created_at', { ascending: false })
return (data || []).map(toConsumptionRecord)
}
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 createConsumption(data: any): Promise<ConsumptionRecord> {
const insert = {
id: nextId(),
customer_name: data.customerName,
date: data.date,
time: data.time,
waiter_id: data.waiterId,
waiter_name: data.waiterName,
commission_rate: data.commissionRate,
amount: data.amount,
waiter_earnings: data.waiterEarnings,
admin_earnings: data.adminEarnings,
state: data.state || '待分账',
}
const { data: row } = await supabase.from('consumptions').insert(insert).select().single()
return toConsumptionRecord(row)
}
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 updateConsumption(id: string, data: any): Promise<ConsumptionRecord | null> {
const { data: row } = await supabase.from('consumptions').update(data).eq('id', id).select().single()
return row ? toConsumptionRecord(row) : null
}
export async function deleteConsumption(id: string): Promise<boolean> {
try {
await prisma.consumption.delete({ where: { id } })
return true
} catch {
return false
}
const { data, error } = await supabase.from('consumptions').delete().eq('id', id).select('id')
return !error && (data?.length || 0) > 0
}
export async function deleteReservation(id: string): Promise<boolean> {
try {
await prisma.reservation.delete({ where: { id } })
return true
} catch {
return false
}
// --- Fund Pool Config ---
export async function getFundPoolConfig(): Promise<{ monthlyPercent: number; maxPoolAmount: number } | null> {
const { data } = await supabase.from('fund_pool_configs').select('*').limit(1).single()
if (!data) return null
return { monthlyPercent: data.monthly_percent, maxPoolAmount: data.max_pool_amount }
}
export async function upsertFundPoolConfig(data: { monthlyPercent: number; maxPoolAmount: number }) {
const { data: existing } = await supabase.from('fund_pool_configs').select('id').limit(1).single()
if (existing) {
const { data: row } = await supabase
.from('fund_pool_configs')
.update({ monthly_percent: data.monthlyPercent, max_pool_amount: data.maxPoolAmount })
.eq('id', existing.id)
.select()
.single()
return row
}
const { data: row } = await supabase
.from('fund_pool_configs')
.insert({ id: nextId(), monthly_percent: data.monthlyPercent, max_pool_amount: data.maxPoolAmount })
.select()
.single()
return row
}
// --- Fund Pool Expenses ---
export async function listFundPoolExpenses(params?: { year?: number; month?: number }) {
let query = supabase.from('fund_pool_expenses').select('*').order('date', { ascending: false })
if (params?.year && params?.month) {
const prefix = `${params.year}-${String(params.month).padStart(2, '0')}`
query = query.like('date', `${prefix}%`)
}
const { data } = await query
return data || []
}
export async function createFundPoolExpense(data: any) {
const { data: row } = await supabase
.from('fund_pool_expenses')
.insert({
id: nextId(),
amount: data.amount,
category: data.category,
note: data.note || '',
handler: data.handler || '',
date: data.date,
})
.select()
.single()
return row
}
export async function updateFundPoolExpense(id: string, data: any) {
const { data: row } = await supabase.from('fund_pool_expenses').update(data).eq('id', id).select().single()
return row || null
}
export async function deleteFundPoolExpense(id: string): Promise<boolean> {
const { data, error } = await supabase.from('fund_pool_expenses').delete().eq('id', id).select('id')
return !error && (data?.length || 0) > 0
}
// --- Monthly Target ---
export async function getMonthlyTarget(year: number, month: number) {
const { data } = await supabase.from('monthly_targets').select('*').eq('year', year).eq('month', month).single()
return data ? { targetAmount: data.target_amount } : null
}
export async function upsertMonthlyTarget(data: { year: number; month: number; targetAmount: number }) {
const { data: existing } = await supabase
.from('monthly_targets')
.select('id')
.eq('year', data.year)
.eq('month', data.month)
.single()
if (existing) {
const { data: row } = await supabase
.from('monthly_targets')
.update({ target_amount: data.targetAmount })
.eq('id', existing.id)
.select()
.single()
return row
}
const { data: row } = await supabase
.from('monthly_targets')
.insert({ id: nextId(), year: data.year, month: data.month, target_amount: data.targetAmount })
.select()
.single()
return row
}
// --- Fund Pool Allocation ---
export async function getAllocationsTotal(): Promise<number> {
const { data } = await supabase.from('fund_pool_allocations').select('amount')
return (data || []).reduce((s, r) => s + (r.amount || 0), 0)
}
export async function getExpensesTotal(): Promise<number> {
const { data } = await supabase.from('fund_pool_expenses').select('amount')
return (data || []).reduce((s, r) => s + (r.amount || 0), 0)
}
export async function createAllocation(data: { year: number; month: number; amount: number }) {
const { data: row } = await supabase
.from('fund_pool_allocations')
.insert({ id: nextId(), ...data })
.select()
.single()
return row
}
export async function listAllocations() {
const { data } = await supabase.from('fund_pool_allocations').select('*').order('created_at', { ascending: false })
return data || []
}
+171 -19
View File
@@ -63,16 +63,72 @@ let reservations: Reservation[] = [
note: '需要蛋糕',
state: RESERVATION_STATE,
},
{ id: 'r2', customerName: '李芳', date: '2026-05-16', time: '12:30', waiterId: '2', package: '商务宴请', 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: '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: '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: 'r11',
customerName: '孙女士',
date: '2026-05-11',
time: '11:30',
waiterId: '2',
package: '生日宴',
state: RESERVATION_STATE,
},
{
id: 'r12',
customerName: '钱总',
@@ -84,11 +140,51 @@ let reservations: Reservation[] = [
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: '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',
@@ -100,16 +196,72 @@ let reservations: Reservation[] = [
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: '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: '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: '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 },
{
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[] = [
+1 -1
View File
@@ -6,7 +6,7 @@ export function getMockServiceFee(reservationId: string): number {
}
export function calcEarnings(amount: number, rate: number): number {
return Math.round((amount * rate) / 100)
return (amount * rate) / 100
}
export interface ProfitItem {
+23
View File
@@ -0,0 +1,23 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://106.15.37.58:8000'
const supabaseKey =
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc4NTEzNTY5LCJleHAiOjE5MzYxOTM1Njl9.MGzI3BtI60N4kOO3iFiPX1KenPHWZ3FdGcWMdm3QICE'
export const supabase = createClient(supabaseUrl, supabaseKey)
export async function checkSupabaseConnection(): Promise<{ ok: boolean; message: string }> {
try {
const { data, error } = await supabase.from('waiters').select('id').limit(1)
if (error) {
console.error('[Supabase] Connection failed:', error.message)
return { ok: false, message: `Supabase 连接失败: ${error.message}` }
}
console.log('[Supabase] Connection OK')
return { ok: true, message: 'Supabase 连接成功' }
} catch (err: any) {
console.error('[Supabase] Connection error:', err?.message || err)
return { ok: false, message: `Supabase 连接异常: ${err?.message || err}` }
}
}
+9
View File
@@ -62,6 +62,15 @@ export interface CommissionItem {
rate: number
}
export interface FundPoolExpenseType {
id: string
amount: number
category: string
note: string
handler: string
date: string
}
export type ConsumptionState = '待分账' | '已分账'
export interface ConsumptionRecord {
+4
View File
@@ -12,7 +12,9 @@
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -21,6 +23,8 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@supabase/ssr": "^0.10.3",
"@supabase/supabase-js": "^2.105.4",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-placeholder": "^3.23.1",
"@tiptap/react": "^3.23.1",
+149
View File
@@ -14,9 +14,15 @@ importers:
'@prisma/client':
specifier: ^7.8.0
version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-avatar':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -41,6 +47,12 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
'@supabase/ssr':
specifier: ^0.10.3
version: 0.10.3(@supabase/supabase-js@2.105.4)
'@supabase/supabase-js':
specifier: ^2.105.4
version: 2.105.4
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -626,6 +638,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-alert-dialog@1.1.15':
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@@ -652,6 +677,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@@ -1061,6 +1099,38 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@supabase/auth-js@2.105.4':
resolution: {integrity: sha512-Ejfa37M5xoIwoxVebxRahnwubPo8g22qkXQ4p50+N9MIvU9UZoN+A8dwVPtczzGf8oV/YXN80ZPxK4aWXuSN/A==}
engines: {node: '>=20.0.0'}
'@supabase/functions-js@2.105.4':
resolution: {integrity: sha512-JVNKbBft3Qkja+WlGaE026AJ2AH9K0UTsxsfvEIHgd4zFrBor4BYRCrYFrv9IDsvVqkF72wKDsODJl5GY/C4tA==}
engines: {node: '>=20.0.0'}
'@supabase/phoenix@0.4.2':
resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==}
'@supabase/postgrest-js@2.105.4':
resolution: {integrity: sha512-SppIyLo/kTwIlz1qpv2HN1EQqBg0GVktrDDFsXygYROha3MgVn4rT7p5EjFHFqXQm2rdRGb/BI7bc+jr10m91w==}
engines: {node: '>=20.0.0'}
'@supabase/realtime-js@2.105.4':
resolution: {integrity: sha512-6ov6c59+8D9h7q4M4Gy/uDJlC0Akxl9/714Y+6vJ+Sijuc16TS/p5DwhfRCLNcIhNiej1gEt+CQUwsjiPt4PxQ==}
engines: {node: '>=20.0.0'}
'@supabase/ssr@0.10.3':
resolution: {integrity: sha512-ux2CJgX89h0Fz2lY7ZNafNG2SkXpyRc5dz77K9eKeBLPdtywQixKwIuetDeIViAJBp/buOUVmgj8PVesOklNpw==}
peerDependencies:
'@supabase/supabase-js': ^2.105.3
'@supabase/storage-js@2.105.4':
resolution: {integrity: sha512-Jx+pzMP1Whjof2PWHoVBUA75/p7PQE9CqKBzn1oXVyJDOggMLSH2OzVWwsXYaxEpdC1K/KltwmOX44nL3LHl9g==}
engines: {node: '>=20.0.0'}
'@supabase/supabase-js@2.105.4':
resolution: {integrity: sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ==}
engines: {node: '>=20.0.0'}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1756,6 +1826,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
copy-to@2.0.1:
resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==}
@@ -2309,6 +2383,10 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
iceberg-js@0.8.1:
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
engines: {node: '>=20.0.0'}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -4022,6 +4100,20 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4044,6 +4136,22 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
@@ -4446,6 +4554,43 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@supabase/auth-js@2.105.4':
dependencies:
tslib: 2.8.1
'@supabase/functions-js@2.105.4':
dependencies:
tslib: 2.8.1
'@supabase/phoenix@0.4.2': {}
'@supabase/postgrest-js@2.105.4':
dependencies:
tslib: 2.8.1
'@supabase/realtime-js@2.105.4':
dependencies:
'@supabase/phoenix': 0.4.2
tslib: 2.8.1
'@supabase/ssr@0.10.3(@supabase/supabase-js@2.105.4)':
dependencies:
'@supabase/supabase-js': 2.105.4
cookie: 1.1.1
'@supabase/storage-js@2.105.4':
dependencies:
iceberg-js: 0.8.1
tslib: 2.8.1
'@supabase/supabase-js@2.105.4':
dependencies:
'@supabase/auth-js': 2.105.4
'@supabase/functions-js': 2.105.4
'@supabase/postgrest-js': 2.105.4
'@supabase/realtime-js': 2.105.4
'@supabase/storage-js': 2.105.4
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -5181,6 +5326,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
copy-to@2.0.1: {}
core-util-is@1.0.3: {}
@@ -5854,6 +6001,8 @@ snapshots:
dependencies:
ms: 2.1.3
iceberg-js@0.8.1: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "monthly_targets" (
"id" TEXT NOT NULL PRIMARY KEY,
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL,
"target_amount" INTEGER NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "fund_pool_configs" (
"id" TEXT NOT NULL PRIMARY KEY,
"monthly_percent" INTEGER NOT NULL,
"max_pool_amount" INTEGER NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "fund_pool_expenses" (
"id" TEXT NOT NULL PRIMARY KEY,
"amount" INTEGER NOT NULL,
"category" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"handler" TEXT NOT NULL DEFAULT '',
"date" TEXT NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "fund_pool_allocations" (
"id" TEXT NOT NULL PRIMARY KEY,
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL,
"amount" INTEGER NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "monthly_targets_year_month_key" ON "monthly_targets"("year", "month");
+45
View File
@@ -63,3 +63,48 @@ model Consumption {
@@map("consumptions")
}
model MonthlyTarget {
id String @id
year Int
month Int
targetAmount Int @map("target_amount")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([year, month])
@@map("monthly_targets")
}
model FundPoolConfig {
id String @id
monthlyPercent Int @map("monthly_percent")
maxPoolAmount Int @map("max_pool_amount")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("fund_pool_configs")
}
model FundPoolExpense {
id String @id
amount Int
category String
note String @default("")
handler String @default("")
date String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("fund_pool_expenses")
}
model FundPoolAllocation {
id String @id
year Int
month Int
amount Int
createdAt DateTime @default(now()) @map("created_at")
@@map("fund_pool_allocations")
}
+111
View File
@@ -0,0 +1,111 @@
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
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")
}
model MonthlyTarget {
id String @id
year Int
month Int
targetAmount Int @map("target_amount")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([year, month])
@@map("monthly_targets")
}
model FundPoolConfig {
id String @id
monthlyPercent Int @map("monthly_percent")
maxPoolAmount Int @map("max_pool_amount")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("fund_pool_configs")
}
model FundPoolExpense {
id String @id
amount Int
category String
note String @default("")
handler String @default("")
date String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("fund_pool_expenses")
}
model FundPoolAllocation {
id String @id
year Int
month Int
amount Int
createdAt DateTime @default(now()) @map("created_at")
@@map("fund_pool_allocations")
}
+34
View File
@@ -0,0 +1,34 @@
import { PrismaClient } from '@/lib/generated/prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import * as fs from 'fs'
import * as path from 'path'
const adapter = new PrismaBetterSqlite3({ url: './data/keeppay.db' })
const prisma = new PrismaClient({ adapter })
const TABLES = [
'waiter',
'reservation',
'commissionItem',
'consumption',
'monthlyTarget',
'fundPoolConfig',
'fundPoolExpense',
'fundPoolAllocation',
] as const
async function main() {
const data: Record<string, unknown[]> = {}
for (const table of TABLES) {
const rows = await (prisma as any)[table].findMany()
data[table] = rows
console.log(`导出 ${rows.length} 条记录从 ${table}`)
}
const outDir = path.join(__dirname, '..', 'data')
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
fs.writeFileSync(path.join(outDir, 'export.json'), JSON.stringify(data, null, 2))
console.log(`\n数据已导出到 data/export.json`)
}
main().catch(console.error)
+136
View File
@@ -0,0 +1,136 @@
import { createClient } from '@supabase/supabase-js'
import * as fs from 'fs'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://106.15.37.58:8000'
const supabaseKey =
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc4NTEzNTY5LCJleHAiOjE5MzYxOTM1Njl9.MGzI3BtI60N4kOO3iFiPX1KenPHWZ3FdGcWMdm3QICE'
const supabase = createClient(supabaseUrl, supabaseKey)
const raw = JSON.parse(fs.readFileSync('./data/export.json', 'utf-8'))
function mapRow(table: string, row: any): any {
const common = {
id: row.id,
created_at: row.createdAt,
}
switch (table) {
case 'waiters':
return {
...common,
name: row.name,
avatar: row.avatar || '',
gallery: row.gallery || '[]',
specialty: row.specialty || '',
commission_rate: row.commissionRate,
status: row.status,
updated_at: row.updatedAt,
}
case 'reservations':
return {
...common,
customer_name: row.customerName,
date: row.date,
time: row.time,
waiter_id: row.waiterId,
package: row.package || '',
note: row.note || '',
state: row.state,
updated_at: row.updatedAt,
}
case 'commission_items':
return {
...common,
name: row.name,
rate: row.rate,
updated_at: row.updatedAt,
}
case 'consumptions':
return {
...common,
customer_name: row.customerName,
date: row.date,
time: row.time,
waiter_id: row.waiterId,
waiter_name: row.waiterName,
commission_rate: row.commissionRate,
amount: row.amount,
waiter_earnings: row.waiterEarnings,
admin_earnings: row.adminEarnings,
state: row.state,
updated_at: row.updatedAt,
}
case 'monthly_targets':
return {
...common,
year: row.year,
month: row.month,
target_amount: row.targetAmount,
updated_at: row.updatedAt,
}
case 'fund_pool_configs':
return {
...common,
monthly_percent: row.monthlyPercent,
max_pool_amount: row.maxPoolAmount,
updated_at: row.updatedAt,
}
case 'fund_pool_expenses':
return {
...common,
amount: row.amount,
category: row.category,
note: row.note || '',
handler: row.handler || '',
date: row.date,
updated_at: row.updatedAt,
}
case 'fund_pool_allocations':
return {
...common,
year: row.year,
month: row.month,
amount: row.amount,
}
default:
return row
}
}
async function importTable(table: string, rows: any[]) {
if (rows.length === 0) {
console.log(`Skipping ${table}: no data`)
return
}
const mapped = rows.map((r) => mapRow(table, r))
// Supabase insert has a limit, batch in chunks of 500
const chunkSize = 500
for (let i = 0; i < mapped.length; i += chunkSize) {
const chunk = mapped.slice(i, i + chunkSize)
const { error } = await supabase.from(table).insert(chunk)
if (error) {
console.error(`Error inserting into ${table}:`, error)
throw error
}
}
console.log(`Imported ${rows.length} rows into ${table}`)
}
async function main() {
console.log('=== Importing data to Supabase ===')
console.log('URL:', supabaseUrl)
await importTable('waiters', raw.waiter || [])
await importTable('reservations', raw.reservation || [])
await importTable('commission_items', raw.commissionItem || [])
await importTable('consumptions', raw.consumption || [])
await importTable('monthly_targets', raw.monthlyTarget || [])
await importTable('fund_pool_configs', raw.fundPoolConfig || [])
await importTable('fund_pool_expenses', raw.fundPoolExpense || [])
await importTable('fund_pool_allocations', raw.fundPoolAllocation || [])
console.log('=== Import complete ===')
}
main().catch(console.error)
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# migrate-to-supabase.sh
# 将 Keeppay 从 SQLite 迁移到 Supabase (PostgreSQL)
# 使用: bash scripts/migrate-to-supabase.sh
set -e
echo "=== Keeppay Supabase 迁移脚本 ==="
echo ""
# 1. 导出 SQLite 数据
echo "[1/4] 从 SQLite 导出数据..."
npx tsx scripts/export-data.ts
# 2. 切换 Prisma schema 为 PostgreSQL
echo "[2/4] 切换 Prisma schema 为 PostgreSQL..."
cp prisma/schema.supabase.prisma prisma/schema.prisma
# 3. 安装 PostgreSQL 适配器
echo "[3/4] 安装 PostgreSQL 适配器..."
pnpm add @prisma/adapter-pg
# 4. 生成 Prisma client 并运行迁移
echo "[4/4] 运行数据库迁移..."
npx prisma generate
npx prisma migrate dev --name supabase-init
echo ""
echo "=== 迁移完成! ==="
echo ""
echo "后续步骤:"
echo "1. 在 .env 中设置 DATABASE_URL 为 Supabase 连接字符串"
echo "2. 运行: npx tsx scripts/import-data.ts"
echo "3. 更新 prisma.config.ts 使用 @prisma/adapter-pg"
echo "4. 更新 lib/db.ts 中的适配器导入"
echo ""
echo "注意: 迁移后需要手动将 keeppay.db 中的数据导入 Supabase"
echo " 导出文件位于 data/export.json"
@@ -0,0 +1,19 @@
-- Run this once in Supabase SQL Editor to prevent money rounding/truncation.
-- Existing INTEGER money columns cannot store exact fractional shares like 199.5.
ALTER TABLE consumptions
ALTER COLUMN amount TYPE NUMERIC USING amount::numeric,
ALTER COLUMN waiter_earnings TYPE NUMERIC USING waiter_earnings::numeric,
ALTER COLUMN admin_earnings TYPE NUMERIC USING admin_earnings::numeric;
ALTER TABLE monthly_targets
ALTER COLUMN target_amount TYPE NUMERIC USING target_amount::numeric;
ALTER TABLE fund_pool_configs
ALTER COLUMN max_pool_amount TYPE NUMERIC USING max_pool_amount::numeric;
ALTER TABLE fund_pool_expenses
ALTER COLUMN amount TYPE NUMERIC USING amount::numeric;
ALTER TABLE fund_pool_allocations
ALTER COLUMN amount TYPE NUMERIC USING amount::numeric;
+122
View File
@@ -0,0 +1,122 @@
-- Supabase Schema Migration
-- Run this in Supabase SQL Editor: http://106.15.37.58:8000/project/default/sql
-- Drop tables if they exist (for clean migration)
DROP TABLE IF EXISTS fund_pool_allocations;
DROP TABLE IF EXISTS fund_pool_expenses;
DROP TABLE IF EXISTS fund_pool_configs;
DROP TABLE IF EXISTS monthly_targets;
DROP TABLE IF EXISTS consumptions;
DROP TABLE IF EXISTS commission_items;
DROP TABLE IF EXISTS reservations;
DROP TABLE IF EXISTS waiters;
-- Waiters
CREATE TABLE waiters (
id TEXT 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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Reservations
CREATE TABLE reservations (
id TEXT 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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Commission Items
CREATE TABLE commission_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
rate INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Consumptions
CREATE TABLE consumptions (
id TEXT 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 NUMERIC NOT NULL,
waiter_earnings NUMERIC NOT NULL,
admin_earnings NUMERIC NOT NULL,
state TEXT NOT NULL DEFAULT '待分账',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Monthly Targets
CREATE TABLE monthly_targets (
id TEXT PRIMARY KEY,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
target_amount NUMERIC NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(year, month)
);
-- Fund Pool Config (singleton)
CREATE TABLE fund_pool_configs (
id TEXT PRIMARY KEY,
monthly_percent INTEGER NOT NULL,
max_pool_amount NUMERIC NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Fund Pool Expenses
CREATE TABLE fund_pool_expenses (
id TEXT PRIMARY KEY,
amount NUMERIC NOT NULL,
category TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
handler TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Fund Pool Allocations
CREATE TABLE fund_pool_allocations (
id TEXT PRIMARY KEY,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
amount NUMERIC NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Disable RLS for simplicity (internal admin tool)
ALTER TABLE waiters DISABLE ROW LEVEL SECURITY;
ALTER TABLE reservations DISABLE ROW LEVEL SECURITY;
ALTER TABLE commission_items DISABLE ROW LEVEL SECURITY;
ALTER TABLE consumptions DISABLE ROW LEVEL SECURITY;
ALTER TABLE monthly_targets DISABLE ROW LEVEL SECURITY;
ALTER TABLE fund_pool_configs DISABLE ROW LEVEL SECURITY;
ALTER TABLE fund_pool_expenses DISABLE ROW LEVEL SECURITY;
ALTER TABLE fund_pool_allocations DISABLE ROW LEVEL SECURITY;
-- Enable realtime for all tables (optional, useful for live updates)
BEGIN;
-- Add tables to the publication for realtime
-- Note: run these separately if the publication doesn't exist
COMMIT;