From cc3e80a3cc5c051a9c93561e45d32a9ae8c64eff Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 29 Apr 2026 20:01:28 +0800 Subject: [PATCH] feat: add batch schedule dialog --- .../_components/batch-schedule-dialog.tsx | 533 ++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx diff --git a/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx b/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx new file mode 100644 index 0000000..a1da59a --- /dev/null +++ b/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx @@ -0,0 +1,533 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { ArrowLeftIcon, CheckIcon, Loader2Icon, XIcon } from "lucide-react" +import { toast } from "sonner" + +import { OptionCombobox } from "@/components/option-combobox" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Textarea } from "@/components/ui/textarea" +import { + fetchAgentTeamsAll, + generateAgentTeamScheduleBatch, + previewAgentTeamScheduleBatch, + type AdminAgentTeam, + type AdminAgentTeamScheduleBatchPreview, + type BatchAdminAgentTeamSchedulePayload, +} from "@/lib/api/admin" +import { cn } from "@/lib/utils" + +type BatchScheduleDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: (created: number) => void | Promise +} + +const weekdayOptions = [ + { value: 1, label: "周一" }, + { value: 2, label: "周二" }, + { value: 3, label: "周三" }, + { value: 4, label: "周四" }, + { value: 5, label: "周五" }, + { value: 6, label: "周六" }, + { value: 7, label: "周日" }, +] + +function todayDateValue() { + const today = new Date() + const year = today.getFullYear() + const month = String(today.getMonth() + 1).padStart(2, "0") + const day = String(today.getDate()).padStart(2, "0") + return `${year}-${month}-${day}` +} + +function defaultFormState() { + const today = todayDateValue() + return { + selectedTeamIds: [] as number[], + startDate: today, + endDate: today, + weekdays: [1, 2, 3, 4, 5], + startTime: "09:00", + endTime: "18:00", + remark: "", + } +} + +type BatchFormState = ReturnType +type DialogStep = "form" | "preview" + +function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload { + return { + teamIds: [...form.selectedTeamIds], + startDate: form.startDate, + endDate: form.endDate, + weekdays: [...form.weekdays], + startTime: form.startTime, + endTime: form.endTime, + remark: form.remark.trim(), + } +} + +function getWeekdayLabel(value: number) { + return weekdayOptions.find((option) => option.value === value)?.label ?? `周${value}` +} + +function validateForm(form: BatchFormState) { + const today = todayDateValue() + if (form.selectedTeamIds.length === 0) { + return "请选择至少一个客服组" + } + if (!form.startDate || !form.endDate) { + return "请选择日期范围" + } + if (form.startDate < today) { + return "开始日期不能早于今天" + } + if (form.endDate < form.startDate) { + return "结束日期不能早于开始日期" + } + if (form.weekdays.length === 0) { + return "请选择至少一个星期" + } + if (!form.startTime || !form.endTime) { + return "请选择开始和结束时间" + } + if (form.endTime <= form.startTime) { + return "结束时间必须晚于开始时间" + } + return "" +} + +export function BatchScheduleDialog({ + open, + onOpenChange, + onSuccess, +}: BatchScheduleDialogProps) { + const [teams, setTeams] = useState([]) + const [form, setForm] = useState(defaultFormState) + const [step, setStep] = useState("form") + const [preview, setPreview] = useState(null) + const [previewPayload, setPreviewPayload] = useState(null) + const [loadingTeams, setLoadingTeams] = useState(false) + const [previewing, setPreviewing] = useState(false) + const [submitting, setSubmitting] = useState(false) + const openRef = useRef(open) + const previewRequestIdRef = useRef(0) + const busy = loadingTeams || previewing || submitting + + const teamOptions = useMemo( + () => + teams + .filter((team) => !form.selectedTeamIds.includes(team.id)) + .map((team) => ({ value: String(team.id), label: team.name })), + [form.selectedTeamIds, teams] + ) + + const selectedTeams = useMemo(() => { + const teamMap = new Map(teams.map((team) => [team.id, team])) + return form.selectedTeamIds.map((teamId) => teamMap.get(teamId)).filter(Boolean) as AdminAgentTeam[] + }, [form.selectedTeamIds, teams]) + + const selectedWeekdays = useMemo( + () => new Set(form.weekdays), + [form.weekdays] + ) + + const hasConflict = preview?.conflict === true + + useEffect(() => { + openRef.current = open + }, [open]) + + useEffect(() => { + if (!open) { + previewRequestIdRef.current += 1 + setForm(defaultFormState()) + setStep("form") + setPreview(null) + setPreviewPayload(null) + setPreviewing(false) + setSubmitting(false) + return + } + + let ignore = false + async function loadTeams() { + setLoadingTeams(true) + try { + const data = await fetchAgentTeamsAll() + if (!ignore) { + setTeams(data) + } + } catch (error) { + if (!ignore) { + toast.error(error instanceof Error ? error.message : "加载客服组选项失败") + } + } finally { + if (!ignore) { + setLoadingTeams(false) + } + } + } + + void loadTeams() + return () => { + ignore = true + } + }, [open]) + + function updateForm(values: Partial) { + previewRequestIdRef.current += 1 + setForm((current) => ({ ...current, ...values })) + setPreview(null) + setPreviewPayload(null) + setPreviewing(false) + setStep("form") + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen && busy) { + return + } + onOpenChange(nextOpen) + } + + function handleTeamSelect(value: string) { + const teamId = Number(value) + if (!Number.isFinite(teamId) || form.selectedTeamIds.includes(teamId)) { + return + } + updateForm({ selectedTeamIds: [...form.selectedTeamIds, teamId] }) + } + + function removeTeam(teamId: number) { + updateForm({ + selectedTeamIds: form.selectedTeamIds.filter((selectedTeamId) => selectedTeamId !== teamId), + }) + } + + function toggleWeekday(weekday: number) { + const nextWeekdays = selectedWeekdays.has(weekday) + ? form.weekdays.filter((value) => value !== weekday) + : [...form.weekdays, weekday].sort((a, b) => a - b) + updateForm({ weekdays: nextWeekdays }) + } + + async function handlePreview() { + const validationMessage = validateForm(form) + if (validationMessage) { + toast.error(validationMessage) + return + } + + const payload = buildPayload(form) + const requestId = previewRequestIdRef.current + 1 + previewRequestIdRef.current = requestId + setPreviewing(true) + try { + const data = await previewAgentTeamScheduleBatch(payload) + if (!openRef.current || previewRequestIdRef.current !== requestId) { + return + } + setPreview(data) + setPreviewPayload(payload) + setStep("preview") + } catch (error) { + if (openRef.current && previewRequestIdRef.current === requestId) { + toast.error(error instanceof Error ? error.message : "预览批量排班失败") + } + } finally { + if (openRef.current && previewRequestIdRef.current === requestId) { + setPreviewing(false) + } + } + } + + async function handleSubmit() { + if (!preview || !previewPayload) { + toast.error("请先预览批量排班") + return + } + if (preview.conflict) { + toast.error("存在冲突排班,不能提交") + return + } + + const payload = previewPayload + setSubmitting(true) + try { + const data = await generateAgentTeamScheduleBatch(payload) + toast.success(`已创建 ${data.created} 条客服组排班`) + setPreview(null) + setPreviewPayload(null) + onOpenChange(false) + try { + await onSuccess(data.created) + } catch (error) { + toast.error(error instanceof Error ? `排班已生成,但刷新列表失败:${error.message}` : "排班已生成,但刷新列表失败") + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "生成批量排班失败") + } finally { + setSubmitting(false) + } + } + + return ( + + + + 批量排班 + + 选择客服组、日期范围和工作时间,先预览后生成排班。 + + + +
+ {step === "form" ? ( +
+
+ +
+
+ +
+
+ {selectedTeams.length > 0 ? ( +
+ {selectedTeams.map((team) => ( + + {team.name} + + + ))} +
+ ) : ( +
尚未选择客服组
+ )} +
+ +
+
+ + updateForm({ startDate: event.target.value })} + /> +
+
+ + updateForm({ endDate: event.target.value })} + /> +
+
+ +
+ +
+ {weekdayOptions.map((option) => { + const selected = selectedWeekdays.has(option.value) + return ( + + ) + })} +
+
+ +
+
+ + updateForm({ startTime: event.target.value })} + /> +
+
+ + updateForm({ endTime: event.target.value })} + /> +
+
+ +
+ +