5ddd93038c
- 新增资金池分配、余额、配置和支出相关的 API 路由 - 添加资金池支出对话框和页面组件 - 更新相关依赖,支持新功能 这些更改为资金池管理提供了完整的功能支持。
284 lines
13 KiB
TypeScript
284 lines
13 KiB
TypeScript
'use client'
|
|
|
|
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 { DatePicker } from '@/components/date-picker'
|
|
import { format } from 'date-fns'
|
|
import type { ConsumptionRecord, Waiter, Reservation, CommissionItem, ConsumptionState } from '@/lib/types'
|
|
|
|
interface ConsumptionDialogProps {
|
|
open: boolean
|
|
onOpenChange: (v: boolean) => void
|
|
onSave: (record: Omit<ConsumptionRecord, 'id'>) => void
|
|
editRecord?: ConsumptionRecord | null
|
|
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, defaultValues }: ConsumptionDialogProps) {
|
|
const [customerName, setCustomerName] = useState('')
|
|
const [date, setDate] = useState(todayStr)
|
|
const [time, setTime] = useState(nowStr)
|
|
const [waiterId, setWaiterId] = useState('')
|
|
const [amount, setAmount] = useState('')
|
|
const [state, setState] = useState<ConsumptionState>('待分账')
|
|
|
|
useEffect(() => {
|
|
if (editRecord) {
|
|
setCustomerName(editRecord.customerName)
|
|
setDate(editRecord.date)
|
|
setTime(editRecord.time)
|
|
setWaiterId(editRecord.waiterId)
|
|
setAmount(String(editRecord.amount))
|
|
setState(editRecord.state || '待分账')
|
|
} else 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)
|
|
setTime(nowStr)
|
|
setWaiterId('')
|
|
setAmount('')
|
|
setState('待分账')
|
|
}
|
|
}, [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([
|
|
fetch('/api/waiter').then((r) => r.json()),
|
|
fetch('/api/reservation').then((r) => r.json()),
|
|
fetch('/api/commission').then((r) => r.json()),
|
|
])
|
|
.then(([w, r, c]) => {
|
|
setWaiters(w)
|
|
setReservations(r)
|
|
setCommissions(c)
|
|
})
|
|
.catch(() => {})
|
|
}, [open])
|
|
|
|
const customerNames = useMemo(() => {
|
|
const names = new Set(reservations.map((r) => r.customerName))
|
|
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)
|
|
}, [customerName, reservations])
|
|
|
|
const selectedWaiter = useMemo(() => waiters.find((w) => w.id === waiterId), [waiterId, waiters])
|
|
const numAmount = Number(amount) || 0
|
|
|
|
const handleSelectName = (name: string) => {
|
|
setCustomerName(name)
|
|
setShowSuggestions(false)
|
|
const record = reservations.find((r) => r.customerName === name)
|
|
if (record) {
|
|
setDate(record.date)
|
|
setTime(record.time)
|
|
}
|
|
}
|
|
|
|
const handleSave = () => {
|
|
if (!customerName || !waiterId || !amount) return
|
|
const waiter = waiters.find((w) => w.id === waiterId)
|
|
if (!waiter) return
|
|
const amt = Number(amount)
|
|
if (isNaN(amt) || amt <= 0) return
|
|
onSave({
|
|
customerName,
|
|
date,
|
|
time,
|
|
waiterId: waiter.id,
|
|
waiterName: waiter.name,
|
|
commissionRate: waiter.commissionRate,
|
|
amount: amt,
|
|
waiterEarnings: (amt * waiter.commissionRate) / 100,
|
|
adminEarnings: commissions.reduce((s, c) => s + (amt * c.rate) / 100, 0),
|
|
state,
|
|
})
|
|
onOpenChange(false)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{editRecord ? '编辑消费记录' : '新增消费记录'}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* Commission cards grid at top */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Card>
|
|
<CardContent className="p-3">
|
|
<div className="text-xs text-muted-foreground">
|
|
{selectedWaiter ? selectedWaiter.name : '服务员'}
|
|
</div>
|
|
<div className="text-lg font-semibold mt-0.5">
|
|
¥
|
|
{selectedWaiter && numAmount > 0
|
|
? ((numAmount * selectedWaiter.commissionRate) / 100).toLocaleString()
|
|
: '0'}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{selectedWaiter ? `比例 ${selectedWaiter.commissionRate}%` : '未选择'}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
{commissions.map((c) => {
|
|
const earn = numAmount > 0 ? (numAmount * c.rate) / 100 : 0
|
|
return (
|
|
<Card key={c.id}>
|
|
<CardContent className="p-3">
|
|
<div className="text-xs text-muted-foreground">{c.name}</div>
|
|
<div className="text-lg font-semibold mt-0.5">¥{earn.toLocaleString()}</div>
|
|
<div className="text-xs text-muted-foreground">比例 {c.rate}%</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-4 items-center gap-4 relative">
|
|
<Label className="text-right">顾客姓名 *</Label>
|
|
<div className="col-span-3 relative">
|
|
<Input
|
|
ref={nameInputRef}
|
|
value={customerName}
|
|
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 && (
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<div className="col-start-2 col-span-3 text-xs text-muted-foreground bg-muted rounded px-3 py-1.5">
|
|
最近预约: {selectedNameData.date} {selectedNameData.time}
|
|
{selectedNameData.package ? ` · ${selectedNameData.package}` : ''}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label className="text-right">日期 *</Label>
|
|
<div className="col-span-3">
|
|
<DatePicker
|
|
value={date ? new Date(date + 'T00:00:00') : undefined}
|
|
onChange={(d) => {
|
|
if (d) setDate(format(d, 'yyyy-MM-dd'))
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label className="text-right">时间 *</Label>
|
|
<Input
|
|
className="col-span-3"
|
|
type="time"
|
|
value={time}
|
|
onChange={(e) => setTime(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label className="text-right">服务员 *</Label>
|
|
<div className="col-span-3">
|
|
<Select value={waiterId} onValueChange={setWaiterId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="请选择服务员" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{waiters.map((w) => (
|
|
<SelectItem key={w.id} value={w.id}>
|
|
{w.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label className="text-right">消费金额 *</Label>
|
|
<div className="col-span-3 flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
placeholder="0"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">元</span>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label className="text-right">状态</Label>
|
|
<div className="col-span-3">
|
|
<Select value={state} onValueChange={(v) => setState(v as ConsumptionState)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="待分账">待分账</SelectItem>
|
|
<SelectItem value="已分账">已分账</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
取消
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={!customerName || !waiterId || !amount}>
|
|
保存
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|