feat(fund-pool): 添加资金池管理功能
- 新增资金池分配、余额、配置和支出相关的 API 路由 - 添加资金池支出对话框和页面组件 - 更新相关依赖,支持新功能 这些更改为资金池管理提供了完整的功能支持。
This commit is contained in:
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}` }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+149
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user