Files
ai-agent/web/app/dashboard/agent-team-schedules/page.tsx
T

510 lines
18 KiB
TypeScript
Raw Normal View History

2026-04-09 10:01:23 +08:00
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
2026-04-09 10:01:23 +08:00
import {
CalendarClockIcon,
CalendarDaysIcon,
CalendarRangeIcon,
CalendarSearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
2026-04-29 20:16:17 +08:00
LayersIcon,
ListIcon,
2026-04-09 10:01:23 +08:00
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "sonner"
import { ListPagination } from "@/components/list-pagination"
2026-04-09 10:01:23 +08:00
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { OptionCombobox } from "@/components/option-combobox"
2026-04-09 10:01:23 +08:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
createAgentTeamSchedule,
deleteAgentTeamSchedule,
fetchAgentTeamScheduleCalendar,
fetchAgentTeamSchedules,
fetchAgentTeamsAll,
updateAgentTeamSchedule,
type AdminAgentTeam,
type AdminAgentTeamSchedule,
type CreateAdminAgentTeamSchedulePayload,
type PageResult,
type UpdateAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
2026-04-29 20:16:17 +08:00
import { BatchScheduleDialog } from "./_components/batch-schedule-dialog"
import { ScheduleCalendar } from "./_components/calendar"
import {
addDays,
addMonths,
formatDateTimeValue,
formatMonthTitle,
formatWeekTitle,
startOfDay,
startOfMonth,
startOfMonthCalendar,
startOfWeek,
endOfMonthCalendar,
} from "./_components/calendar-date-range"
import { EditDialog } from "./_components/edit"
type ViewMode = "month" | "week" | "list"
function parseLocalDateTime(value: string) {
const ret = new Date(value.replace(" ", "T"))
return Number.isNaN(ret.getTime()) ? null : ret
}
function isHistoricalSchedule(item: AdminAgentTeamSchedule) {
const startAt = parseLocalDateTime(item.startAt)
return !!startAt && startAt < startOfDay(new Date())
}
2026-04-09 10:01:23 +08:00
export default function DashboardAgentTeamSchedulesPage() {
const [viewMode, setViewMode] = useState<ViewMode>("month")
2026-04-09 10:01:23 +08:00
const [teamFilterInput, setTeamFilterInput] = useState("all")
const [teamFilter, setTeamFilter] = useState("all")
const [monthStart, setMonthStart] = useState(() => startOfMonth(new Date()))
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()))
2026-04-09 10:01:23 +08:00
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
2026-04-30 10:16:43 +08:00
const [loading, setLoading] = useState(false)
const [calendarLoading, setCalendarLoading] = useState(true)
2026-04-09 10:01:23 +08:00
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
2026-04-29 20:16:17 +08:00
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
2026-04-09 10:01:23 +08:00
const [editingItem, setEditingItem] = useState<AdminAgentTeamSchedule | null>(null)
const [dialogDefaults, setDialogDefaults] = useState<Partial<CreateAdminAgentTeamSchedulePayload> | null>(null)
2026-04-09 10:01:23 +08:00
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [calendarItems, setCalendarItems] = useState<AdminAgentTeamSchedule[]>([])
2026-04-09 10:01:23 +08:00
const [result, setResult] = useState<PageResult<AdminAgentTeamSchedule>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const visibleTeams = useMemo(() => {
if (teamFilter === "all") {
return teams
}
return teams.filter((team) => String(team.id) === teamFilter)
}, [teamFilter, teams])
2026-04-30 10:16:43 +08:00
const refreshing = viewMode === "list" ? loading || calendarLoading : calendarLoading
2026-04-09 10:01:23 +08:00
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchAgentTeamSchedules({
teamId: teamFilter === "all" ? undefined : teamFilter,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班失败")
} finally {
setLoading(false)
}
}, [limit, page, teamFilter])
const loadCalendarData = useCallback(async () => {
setCalendarLoading(true)
const rangeStart = viewMode === "week" ? weekStart : startOfMonthCalendar(monthStart)
const rangeEnd = viewMode === "week" ? addDays(weekStart, 7) : endOfMonthCalendar(monthStart)
try {
const data = await fetchAgentTeamScheduleCalendar({
startAt: formatDateTimeValue(rangeStart),
endAt: formatDateTimeValue(rangeEnd),
teamId: teamFilter === "all" ? undefined : teamFilter,
})
setCalendarItems(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组排班日历失败")
} finally {
setCalendarLoading(false)
}
}, [monthStart, teamFilter, viewMode, weekStart])
2026-04-09 10:01:23 +08:00
const loadTeams = useCallback(async () => {
try {
const data = await fetchAgentTeamsAll()
2026-04-09 10:01:23 +08:00
setTeams(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客服组选项失败")
}
}, [])
const refreshActiveView = useCallback(async () => {
await Promise.all([
loadCalendarData(),
viewMode === "list" ? loadData() : Promise.resolve(),
])
}, [loadCalendarData, loadData, viewMode])
useEffect(() => {
void loadCalendarData()
}, [loadCalendarData])
2026-04-09 10:01:23 +08:00
useEffect(() => {
if (viewMode === "list") {
void loadData()
}
}, [loadData, viewMode])
2026-04-09 10:01:23 +08:00
useEffect(() => {
void loadTeams()
}, [loadTeams])
function applyFilters() {
setTeamFilter(teamFilterInput)
setPage(1)
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return
}
setPage(nextPage)
}
function openCreateDialog(defaults?: Partial<CreateAdminAgentTeamSchedulePayload>) {
2026-04-09 10:01:23 +08:00
setEditingItem(null)
setDialogDefaults(defaults ?? null)
2026-04-09 10:01:23 +08:00
setDialogOpen(true)
}
function openEditDialog(item: AdminAgentTeamSchedule) {
if (isHistoricalSchedule(item)) {
toast.error("不能修改历史日期的排班")
return
}
setDialogDefaults(null)
2026-04-09 10:01:23 +08:00
setEditingItem(item)
setDialogOpen(true)
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return
}
if (!open) {
setEditingItem(null)
setDialogDefaults(null)
2026-04-09 10:01:23 +08:00
}
setDialogOpen(open)
}
async function handleSubmit(payload: CreateAdminAgentTeamSchedulePayload) {
if (saving) {
return
}
setSaving(true)
try {
if (editingItem) {
await updateAgentTeamSchedule({ id: editingItem.id, ...payload })
toast.success("已更新客服组排班")
} else {
await createAgentTeamSchedule(payload)
toast.success("已创建客服组排班")
}
setDialogOpen(false)
setEditingItem(null)
setDialogDefaults(null)
await refreshActiveView()
2026-04-09 10:01:23 +08:00
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存客服组排班失败")
} finally {
setSaving(false)
}
}
2026-04-29 20:16:17 +08:00
async function handleBatchSuccess() {
await refreshActiveView()
}
async function handleDeleteById(id: number) {
setActionLoadingId(id)
2026-04-09 10:01:23 +08:00
try {
await deleteAgentTeamSchedule(id)
2026-04-09 10:01:23 +08:00
toast.success("已删除客服组排班")
setDialogOpen(false)
setEditingItem(null)
setDialogDefaults(null)
await refreshActiveView()
2026-04-09 10:01:23 +08:00
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除客服组排班失败")
} finally {
setActionLoadingId(null)
}
}
async function handleDelete(item: AdminAgentTeamSchedule) {
await handleDeleteById(item.id)
}
async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) {
const startAt = parseLocalDateTime(payload.startAt)
if (startAt && startAt < startOfDay(new Date())) {
toast.error("不能修改历史日期的排班")
return
}
setActionLoadingId(payload.id)
try {
await updateAgentTeamSchedule(payload)
toast.success("已更新客服组排班")
await loadCalendarData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新客服组排班失败")
await loadCalendarData()
} finally {
setActionLoadingId(null)
}
}
function goToToday() {
const today = new Date()
setMonthStart(startOfMonth(today))
setWeekStart(startOfWeek(today))
}
2026-04-09 10:01:23 +08:00
return (
<>
<div className="flex h-[calc(100vh-var(--header-height))] min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4 lg:p-6">
<div className="shrink-0 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-2">
<ButtonGroup>
<Button
variant={viewMode === "month" ? "default" : "outline"}
size="sm"
onClick={() => setViewMode("month")}
>
<CalendarDaysIcon />
</Button>
<Button
variant={viewMode === "week" ? "default" : "outline"}
size="sm"
onClick={() => setViewMode("week")}
>
<CalendarRangeIcon />
</Button>
<Button
variant={viewMode === "list" ? "default" : "outline"}
size="sm"
onClick={() => setViewMode("list")}
>
<ListIcon />
列表
</Button>
</ButtonGroup>
{viewMode === "month" ? (
<ButtonGroup>
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label="上一月">
<ChevronLeftIcon />
</Button>
<Button variant="outline" size="sm" onClick={() => setMonthStart(startOfMonth(new Date()))}>
本月
</Button>
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label="下一月">
<ChevronRightIcon />
</Button>
</ButtonGroup>
) : null}
{viewMode === "week" ? (
<ButtonGroup>
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label="上一周">
<ChevronLeftIcon />
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
本周
</Button>
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label="下一周">
<ChevronRightIcon />
</Button>
</ButtonGroup>
) : null}
{viewMode === "month" ? (
<div className="text-sm text-muted-foreground">{formatMonthTitle(monthStart)}</div>
) : null}
{viewMode === "week" ? (
<div className="text-sm text-muted-foreground">{formatWeekTitle(weekStart)}</div>
) : null}
{viewMode !== "list" ? (
<Button variant="outline" size="sm" onClick={goToToday}>
<CalendarSearchIcon />
今天
</Button>
) : null}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:justify-end">
<div className="w-full sm:w-48">
<OptionCombobox
value={teamFilterInput}
options={[
{ value: "all", label: "全部客服组" },
...teams.map((team) => ({ value: String(team.id), label: team.name })),
]}
placeholder="筛选客服组"
searchPlaceholder="搜索客服组"
emptyText="未找到客服组"
onChange={(value) => setTeamFilterInput(value)}
/>
</div>
2026-04-30 10:16:43 +08:00
<Button variant="outline" onClick={applyFilters} disabled={refreshing}>
<SearchIcon />
查询
</Button>
<Button
variant="outline"
onClick={() => void refreshActiveView()}
2026-04-30 10:16:43 +08:00
disabled={refreshing}
>
2026-04-30 10:16:43 +08:00
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} />
刷新
</Button>
2026-04-29 20:16:17 +08:00
<Button variant="outline" onClick={() => setBatchDialogOpen(true)}>
<LayersIcon />
批量排班
</Button>
<Button onClick={() => openCreateDialog()}>
<PlusIcon />
新建
</Button>
</div>
</div>
{viewMode === "month" || viewMode === "week" ? (
<div className="min-h-0 flex-1 overflow-auto">
<ScheduleCalendar
variant={viewMode}
monthStart={monthStart}
calendarStart={viewMode === "week" ? weekStart : startOfMonthCalendar(monthStart)}
calendarEnd={viewMode === "week" ? addDays(weekStart, 7) : endOfMonthCalendar(monthStart)}
teams={visibleTeams}
schedules={calendarItems}
loading={calendarLoading}
savingId={actionLoadingId}
onCreate={openCreateDialog}
onEdit={openEditDialog}
onMove={handleCalendarUpdate}
onResize={handleCalendarUpdate}
/>
</div>
) : (
<div className="min-h-0 flex-1 space-y-4 overflow-auto">
<div className="min-w-[720px] overflow-hidden rounded-lg border bg-background">
<Table>
<TableHeader className="bg-muted/40">
2026-04-09 10:01:23 +08:00
<TableRow>
<TableHead>客服组</TableHead>
<TableHead>时间范围</TableHead>
<TableHead className="w-[92px] text-right">操作</TableHead>
2026-04-09 10:01:23 +08:00
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
<TableRow key={item.id} className={isHistoricalSchedule(item) ? "opacity-60" : undefined}>
<TableCell>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
<CalendarClockIcon className="size-4" />
</div>
<div className="min-w-0">
<div className="font-medium">{item.teamName || `客服组#${item.teamId}`}</div>
<div className="text-xs text-muted-foreground">ID{item.teamId}</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm">{formatDateTime(item.startAt)}</div>
<div className="text-sm text-muted-foreground">{formatDateTime(item.endAt)}</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)} disabled={isHistoricalSchedule(item)}>
编辑
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.startAt}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuItem
onClick={() => void handleDelete(item)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoadingId === item.id ? "删除中..." : "删除"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
没有匹配的客服组排班
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
2026-04-09 10:01:23 +08:00
</div>
)}
2026-04-09 10:01:23 +08:00
</div>
<EditDialog
open={dialogOpen}
saving={saving || actionLoadingId === editingItem?.id}
2026-04-09 10:01:23 +08:00
itemId={editingItem?.id ?? null}
defaultValues={dialogDefaults}
2026-04-09 10:01:23 +08:00
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
onDelete={handleDeleteById}
2026-04-09 10:01:23 +08:00
/>
2026-04-29 20:16:17 +08:00
<BatchScheduleDialog
open={batchDialogOpen}
onOpenChange={setBatchDialogOpen}
onSuccess={handleBatchSuccess}
/>
2026-04-09 10:01:23 +08:00
</>
)
}