refactor: migrate tickets and users pages to use new dashboard list components

- Refactored TicketsPage to utilize DashboardListPage for improved filtering and data fetching.
- Removed unnecessary state management and loading logic in favor of the new list hooks.
- Updated user management page to leverage useDashboardPagedList for cleaner pagination and filtering.
- Enhanced CRUD page to support new action state management and toolbar customization.
- Added reset filters functionality to dashboard list components.
- Updated translations for new UI elements and loading states.
This commit is contained in:
mlogclub
2026-05-28 12:38:33 +08:00
parent 0611fb0bb6
commit 3fe5a89aeb
14 changed files with 1060 additions and 1355 deletions
+115 -137
View File
@@ -2,7 +2,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import {
CalendarClockIcon,
CalendarDaysIcon,
CalendarRangeIcon,
CalendarSearchIcon,
@@ -10,32 +9,20 @@ import {
ChevronRightIcon,
LayersIcon,
ListIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "sonner"
import { ListPagination } from "@/components/list-pagination"
import {
DashboardCrudPage,
type DashboardCrudColumn,
type DashboardCrudFilter,
} from "@/components/dashboard/crud"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { OptionCombobox } from "@/components/option-combobox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
createAgentTeamSchedule,
deleteAgentTeamSchedule,
@@ -46,7 +33,6 @@ import {
type AdminAgentTeam,
type AdminAgentTeamSchedule,
type CreateAdminAgentTeamSchedulePayload,
type PageResult,
type UpdateAdminAgentTeamSchedulePayload,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
@@ -83,11 +69,9 @@ export default function DashboardAgentTeamSchedulesPage() {
const [viewMode, setViewMode] = useState<ViewMode>("month")
const [teamFilterInput, setTeamFilterInput] = useState("all")
const [teamFilter, setTeamFilter] = useState("all")
const [listReloadKey, setListReloadKey] = useState(0)
const [monthStart, setMonthStart] = useState(() => startOfMonth(new Date()))
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()))
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(false)
const [calendarLoading, setCalendarLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
@@ -97,10 +81,6 @@ export default function DashboardAgentTeamSchedulesPage() {
const [dialogDefaults, setDialogDefaults] = useState<Partial<CreateAdminAgentTeamSchedulePayload> | null>(null)
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [calendarItems, setCalendarItems] = useState<AdminAgentTeamSchedule[]>([])
const [result, setResult] = useState<PageResult<AdminAgentTeamSchedule>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const visibleTeams = useMemo(() => {
if (teamFilter === "all") {
@@ -109,23 +89,56 @@ export default function DashboardAgentTeamSchedulesPage() {
return teams.filter((team) => String(team.id) === teamFilter)
}, [teamFilter, teams])
const refreshing = viewMode === "list" ? loading || calendarLoading : calendarLoading
const refreshing = calendarLoading
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 : t("agentTeamSchedule.loadFailed"))
} finally {
setLoading(false)
}
}, [limit, page, t, teamFilter])
const listFilters = useMemo<DashboardCrudFilter[]>(
() => [
{
name: "teamId",
label: t("agentTeamSchedule.filterTeam"),
defaultValue: teamFilter,
allValue: "all",
type: "select",
options: [
{ value: "all", label: t("agentTeamSchedule.allTeams") },
...teams.map((team) => ({ value: String(team.id), label: team.name })),
],
},
],
[t, teamFilter, teams],
)
const listColumns = useMemo<DashboardCrudColumn<AdminAgentTeamSchedule>[]>(
() => [
{
key: "team",
label: t("agentTeamSchedule.team"),
render: (item) => (
<div className="min-w-0">
<div className="font-medium">
{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}
</div>
<div className="text-xs text-muted-foreground">
{t("agentTeamSchedule.teamId", { id: item.teamId })}
</div>
</div>
),
},
{
key: "timeRange",
label: t("agentTeamSchedule.timeRange"),
render: (item) => (
<>
<div className="text-sm">{formatDateTime(item.startAt)}</div>
<div className="text-sm text-muted-foreground">
{formatDateTime(item.endAt)}
</div>
</>
),
},
],
[t],
)
const loadCalendarData = useCallback(async () => {
setCalendarLoading(true)
@@ -155,36 +168,23 @@ export default function DashboardAgentTeamSchedulesPage() {
}, [t])
const refreshActiveView = useCallback(async () => {
await Promise.all([
loadCalendarData(),
viewMode === "list" ? loadData() : Promise.resolve(),
])
}, [loadCalendarData, loadData, viewMode])
await loadCalendarData()
if (viewMode === "list") {
setListReloadKey((value) => value + 1)
}
}, [loadCalendarData, viewMode])
useEffect(() => {
void loadCalendarData()
}, [loadCalendarData])
useEffect(() => {
if (viewMode === "list") {
void loadData()
}
}, [loadData, viewMode])
useEffect(() => {
void loadTeams()
}, [loadTeams])
function applyFilters() {
setTeamFilter(teamFilterInput)
setPage(1)
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return
}
setPage(nextPage)
setListReloadKey((value) => value + 1)
}
function openCreateDialog(defaults?: Partial<CreateAdminAgentTeamSchedulePayload>) {
@@ -258,10 +258,6 @@ export default function DashboardAgentTeamSchedulesPage() {
}
}
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())) {
@@ -420,78 +416,60 @@ export default function DashboardAgentTeamSchedulesPage() {
</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">
<TableRow>
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
<TableHead>{t("agentTeamSchedule.timeRange")}</TableHead>
<TableHead className="w-[92px] text-right">{t("agentTeamSchedule.actions")}</TableHead>
</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 || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { 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)}>
{t("agentTeamSchedule.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={t("agentTeamSchedule.moreActions", { name: 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 ? t("agentTeamSchedule.deleting") : t("agentTeamSchedule.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
{t("agentTeamSchedule.emptyRows")}
</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)
<DashboardCrudPage<AdminAgentTeamSchedule, CreateAdminAgentTeamSchedulePayload>
key={`${teamFilter}-${listReloadKey}`}
layout="fragment"
showToolbar={false}
filters={listFilters}
columns={listColumns}
fetchList={(query) =>
fetchAgentTeamSchedules({
teamId:
typeof query.teamId === "string" ? query.teamId : undefined,
page: Number(query.page),
limit: Number(query.limit),
})
}
getItemId={(item) => item.id}
createItem={createAgentTeamSchedule}
updateItem={async (item, payload) => {
await updateAgentTeamSchedule({ id: item.id, ...payload })
await loadCalendarData()
}}
canEdit={(item) => !isHistoricalSchedule(item)}
deleteItem={async (item) => {
await deleteAgentTeamSchedule(item.id)
await loadCalendarData()
}}
canDelete={(item) => !isHistoricalSchedule(item)}
renderEditDialog={({ open, saving: dialogSaving, itemId, onOpenChange, onSubmit }) => (
<EditDialog
open={open}
saving={dialogSaving}
itemId={itemId}
defaultValues={null}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)}
labels={{
refresh: t("agentTeamSchedule.refresh"),
create: t("agentTeamSchedule.new"),
query: t("agentTeamSchedule.query"),
loading: t("agentTeamSchedule.loading"),
empty: t("agentTeamSchedule.emptyRows"),
actions: t("agentTeamSchedule.actions"),
edit: t("agentTeamSchedule.edit"),
delete: t("agentTeamSchedule.delete"),
processing: t("agentTeamSchedule.deleting"),
moreActions: (item) =>
t("agentTeamSchedule.moreActions", { name: item.startAt }),
loadFailed: t("agentTeamSchedule.loadFailed"),
saveFailed: t("agentTeamSchedule.saveFailed"),
deleteFailed: t("agentTeamSchedule.deleteFailed"),
created: () => t("agentTeamSchedule.created"),
updated: () => t("agentTeamSchedule.updated"),
deleted: () => t("agentTeamSchedule.deleted"),
}}
/>
</div>
+247 -414
View File
@@ -1,42 +1,19 @@
"use client";
import {
MoreHorizontalIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
SearchIcon,
Trash2Icon,
UserCogIcon
UserCogIcon,
} from "lucide-react";
import { useCallback, useEffect, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import { useMemo, useState } from "react";
import {
DashboardTableShell,
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page";
import { ListPagination } from "@/components/list-pagination";
DashboardCrudPage,
type DashboardCrudColumn,
type DashboardCrudFilter,
} from "@/components/dashboard/crud";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { OptionCombobox } from "@/components/option-combobox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
createAgentProfile,
deleteAgentProfile,
@@ -45,7 +22,6 @@ import {
type AdminAgentProfile,
type AdminAgentTeam,
type CreateAdminAgentProfilePayload,
type PageResult,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import { ServiceStatus } from "@/lib/generated/enums";
@@ -64,403 +40,260 @@ function getServiceStatusOptions(t: TFunction) {
}
function getStatusLabel(value: number, t: TFunction) {
return getServiceStatusOptions(t).find((item) => item.value === String(value))?.label ?? String(value);
return (
getServiceStatusOptions(t).find((item) => item.value === String(value))
?.label ?? String(value)
);
}
export default function DashboardAgentsPage() {
const t = useI18n();
const serviceStatusOptions = getServiceStatusOptions(t);
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [agentCodeInput, setAgentCodeInput] = useState("");
const [displayNameInput, setDisplayNameInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [agentCode, setAgentCode] = useState("");
const [displayName, setDisplayName] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<AdminAgentProfile | null>(
null,
function handleTeamsChange(nextTeams: AdminAgentTeam[]) {
setSelectedTeam((current) => {
if (nextTeams.length === 0) {
return null;
}
if (!current) {
return nextTeams[0];
}
return nextTeams.find((item) => item.id === current.id) ?? nextTeams[0];
});
}
const filters = useMemo<DashboardCrudFilter[]>(
() => [
{
name: "displayName",
label: t("agentProfile.filterDisplayName"),
placeholder: t("agentProfile.filterDisplayName"),
defaultValue: "",
trim: true,
className: "w-full sm:w-72",
},
{
name: "agentCode",
label: t("agentProfile.filterAgentCode"),
placeholder: t("agentProfile.filterAgentCode"),
defaultValue: "",
trim: true,
className: "w-full sm:w-44",
},
{
name: "serviceStatus",
label: t("agentProfile.allStatuses"),
type: "select",
defaultValue: "all",
allValue: "all",
options: serviceStatusOptions,
className: "w-full sm:w-36",
},
],
[serviceStatusOptions, t],
);
const [result, setResult] = useState<PageResult<AdminAgentProfile>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAgentProfiles({
teamId: selectedTeam?.id,
agentCode: agentCode.trim() || undefined,
displayName: displayName.trim() || undefined,
serviceStatus: statusFilter === "all" ? undefined : statusFilter,
page,
limit,
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("agentProfile.loadFailed"));
} finally {
setLoading(false);
}
}, [agentCode, displayName, limit, page, selectedTeam?.id, statusFilter, t]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
setPage(1);
}, [selectedTeam?.id]);
useEffect(() => {
if (teams.length === 0) {
if (selectedTeam) {
setSelectedTeam(null);
}
return;
}
if (!selectedTeam) {
setSelectedTeam(teams[0]);
return;
}
const matchedTeam = teams.find((item) => item.id === selectedTeam.id);
if (!matchedTeam) {
setSelectedTeam(teams[0]);
return;
}
if (matchedTeam !== selectedTeam) {
setSelectedTeam(matchedTeam);
}
}, [selectedTeam, teams]);
function applyFilters() {
setAgentCode(agentCodeInput);
setDisplayName(displayNameInput);
setStatusFilter(statusFilterInput);
setPage(1);
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
applyFilters();
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return;
}
setPage(nextPage);
}
function openCreateDialog() {
setEditingItem(null);
setDialogOpen(true);
}
function openEditDialog(item: AdminAgentProfile) {
setEditingItem(item);
setDialogOpen(true);
}
function handleDialogOpenChange(open: boolean) {
if (saving) {
return;
}
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}
async function handleSubmit(payload: CreateAdminAgentProfilePayload) {
if (saving) {
return;
}
setSaving(true);
try {
if (editingItem) {
await updateAgentProfile({ id: editingItem.id, ...payload });
toast.success(t("agentProfile.updated", { name: editingItem.displayName }));
} else {
await createAgentProfile(payload);
toast.success(t("agentProfile.created", { name: payload.displayName }));
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("agentProfile.saveFailed"));
} finally {
setSaving(false);
}
}
async function handleDelete(item: AdminAgentProfile) {
setActionLoadingId(item.id);
try {
await deleteAgentProfile(item.id);
toast.success(t("agentProfile.deleted", { name: item.displayName }));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("agentProfile.deleteFailed"));
} finally {
setActionLoadingId(null);
}
}
const columns = useMemo<DashboardCrudColumn<AdminAgentProfile>[]>(
() => [
{
key: "agent",
label: t("agentProfile.columnAgent"),
render: (item) => (
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-10 items-center justify-center overflow-hidden rounded-md bg-muted">
{item.avatar ? (
<img
src={item.avatar}
alt={item.displayName}
className="size-full object-cover"
/>
) : (
<UserCogIcon className="size-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div className="font-medium">{item.displayName}</div>
<div className="text-xs text-muted-foreground">
{item.nickname ||
item.username ||
t("agentProfile.userFallback", { id: item.userId })}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("agentProfile.agentCode", { code: item.agentCode })}
</div>
</div>
</div>
),
},
{
key: "rules",
label: t("agentProfile.columnRules"),
render: (item) => (
<>
<Badge variant="outline">
{getStatusLabel(item.serviceStatus, t)}
</Badge>
<div className="mt-2 text-sm text-muted-foreground">
{t("agentProfile.capacityPriority", {
capacity: item.maxConcurrentCount,
priority: item.priorityLevel,
})}
</div>
</>
),
},
{
key: "dispatch",
label: t("agentProfile.columnDispatch"),
render: (item) => (
<div className="flex flex-wrap gap-1.5">
<Badge variant={item.autoAssignEnabled ? "secondary" : "outline"}>
{item.autoAssignEnabled
? t("agentProfile.autoAssign")
: t("agentProfile.noAutoAssign")}
</Badge>
<Badge
variant={item.receiveOfflineMessage ? "secondary" : "outline"}
>
{item.receiveOfflineMessage
? t("agentProfile.offlineReceive")
: t("agentProfile.noOfflineReceive")}
</Badge>
</div>
),
},
{
key: "recent",
label: t("agentProfile.columnRecent"),
render: (item) => (
<>
<div className="text-sm">
{t("agentProfile.onlineAt", {
time: formatDateTime(item.lastOnlineAt),
})}
</div>
<div className="text-sm text-muted-foreground">
{t("agentProfile.statusAt", {
time: formatDateTime(item.lastStatusAt),
})}
</div>
</>
),
},
],
[t],
);
return (
<>
<div className="flex h-[calc(100vh-4rem)]">
<div
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
sidebarCollapsed ? "w-0" : "w-80"
}`}
<div className="flex h-[calc(100vh-4rem)]">
<div
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
sidebarCollapsed ? "w-0" : "w-80"
}`}
>
<AgentTeamSidebar
selectedTeamId={selectedTeam?.id ?? null}
onSelectTeam={setSelectedTeam}
onTeamsChange={handleTeamsChange}
/>
</div>
<div className="relative shrink-0 bg-background">
<Button
variant="outline"
size="icon"
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => setSidebarCollapsed((value) => !value)}
aria-label={
sidebarCollapsed
? t("agentProfile.expandTeams")
: t("agentProfile.collapseTeams")
}
>
<AgentTeamSidebar
selectedTeamId={selectedTeam?.id ?? null}
onSelectTeam={setSelectedTeam}
onTeamsChange={setTeams}
{sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" />
) : (
<PanelLeftCloseIcon className="size-3.5" />
)}
</Button>
</div>
<div className="min-w-0 flex-1 p-4 lg:p-6">
<div className="flex h-full flex-col gap-6">
<DashboardCrudPage<AdminAgentProfile, CreateAdminAgentProfilePayload>
key={selectedTeam?.id ?? "all"}
layout="fragment"
tableShellClassName="min-h-0"
filters={filters}
columns={columns}
fetchList={(query) =>
fetchAgentProfiles({
teamId: selectedTeam?.id,
displayName:
typeof query.displayName === "string"
? query.displayName
: undefined,
agentCode:
typeof query.agentCode === "string"
? query.agentCode
: undefined,
serviceStatus:
typeof query.serviceStatus === "string"
? query.serviceStatus
: undefined,
page: Number(query.page),
limit: Number(query.limit),
})
}
getItemId={(item) => item.id}
createItem={createAgentProfile}
updateItem={(item, payload) =>
updateAgentProfile({ id: item.id, ...payload })
}
deleteItem={(item) => deleteAgentProfile(item.id)}
renderEditDialog={({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}) => (
<EditDialog
open={open}
saving={saving}
itemId={itemId}
defaultTeamId={selectedTeam?.id ?? null}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
)}
labels={{
refresh: t("agentProfile.refresh"),
create: t("agentProfile.new"),
query: t("agentProfile.query"),
loading: t("agentProfile.loadingRows"),
empty: selectedTeam
? t("agentProfile.emptyTeamRows")
: t("agentProfile.emptyRows"),
actions: t("agentProfile.columnActions"),
edit: t("agentProfile.edit"),
delete: t("agentProfile.delete"),
processing: t("agentProfile.deleting"),
moreActions: (item) =>
t("agentProfile.moreActions", { name: item.displayName }),
loadFailed: t("agentProfile.loadFailed"),
saveFailed: t("agentProfile.saveFailed"),
deleteFailed: t("agentProfile.deleteFailed"),
created: (payload) =>
t("agentProfile.created", { name: payload.displayName }),
updated: (item) =>
t("agentProfile.updated", { name: item.displayName }),
deleted: (item) =>
t("agentProfile.deleted", { name: item.displayName }),
}}
/>
</div>
<div className="relative shrink-0 bg-background">
<Button
variant="outline"
size="icon"
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => setSidebarCollapsed((value) => !value)}
aria-label={sidebarCollapsed ? t("agentProfile.expandTeams") : t("agentProfile.collapseTeams")}
>
{sidebarCollapsed ? (
<PanelLeftOpenIcon className="size-3.5" />
) : (
<PanelLeftCloseIcon className="size-3.5" />
)}
</Button>
</div>
<div className="min-w-0 flex-1 p-4 lg:p-6">
<div className="flex h-full flex-col gap-6">
<DashboardToolbar
actions={
<Button onClick={openCreateDialog}>
<PlusIcon />
{t("agentProfile.new")}
</Button>
}
>
<div className="relative w-full sm:w-72">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={displayNameInput}
onChange={(event) =>
setDisplayNameInput(event.target.value)
}
onKeyDown={handleFilterKeyDown}
placeholder={t("agentProfile.filterDisplayName")}
className="pl-9"
/>
</div>
<Input
value={agentCodeInput}
onChange={(event) => setAgentCodeInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder={t("agentProfile.filterAgentCode")}
className="w-full sm:w-44"
/>
<div className="w-full sm:w-36">
<OptionCombobox
value={statusFilterInput}
options={serviceStatusOptions}
placeholder={t("agentProfile.allStatuses")}
searchPlaceholder={t("agentProfile.searchStatus")}
emptyText={t("agentProfile.emptyStatus")}
onChange={(value) => setStatusFilterInput(value ?? "all")}
/>
</div>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
<SearchIcon />
{t("agentProfile.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
className="min-h-0"
pagination={
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={(nextLimit) => {
setLimit(nextLimit);
setPage(1);
}}
/>
}
>
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>{t("agentProfile.columnAgent")}</TableHead>
<TableHead>{t("agentProfile.columnRules")}</TableHead>
<TableHead>{t("agentProfile.columnDispatch")}</TableHead>
<TableHead>{t("agentProfile.columnRecent")}</TableHead>
<TableHead className="w-[92px] text-right">
{t("agentProfile.columnActions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-10 items-center justify-center overflow-hidden rounded-2xl bg-muted">
{item.avatar ? (
<img
src={item.avatar}
alt={item.displayName}
className="size-full object-cover"
/>
) : (
<UserCogIcon className="size-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div className="font-medium">
{item.displayName}
</div>
<div className="text-xs text-muted-foreground">
{item.nickname ||
item.username ||
t("agentProfile.userFallback", { id: item.userId })}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("agentProfile.agentCode", { code: item.agentCode })}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{getStatusLabel(item.serviceStatus, t)}
</Badge>
<div className="mt-2 text-sm text-muted-foreground">
{t("agentProfile.capacityPriority", {
capacity: item.maxConcurrentCount,
priority: item.priorityLevel,
})}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
<Badge
variant={
item.autoAssignEnabled ? "secondary" : "outline"
}
>
{item.autoAssignEnabled
? t("agentProfile.autoAssign")
: t("agentProfile.noAutoAssign")}
</Badge>
<Badge
variant={
item.receiveOfflineMessage
? "secondary"
: "outline"
}
>
{item.receiveOfflineMessage
? t("agentProfile.offlineReceive")
: t("agentProfile.noOfflineReceive")}
</Badge>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
{t("agentProfile.onlineAt", { time: formatDateTime(item.lastOnlineAt) })}
</div>
<div className="text-sm text-muted-foreground">
{t("agentProfile.statusAt", { time: formatDateTime(item.lastStatusAt) })}
</div>
</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(item)}
>
{t("agentProfile.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" />
}
aria-label={t("agentProfile.moreActions", { name: item.displayName })}
>
<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
? t("agentProfile.deleting")
: t("agentProfile.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{loading || result.results.length === 0 ? (
<DashboardTableStateRow
colSpan={5}
loading={loading}
loadingText={t("agentProfile.loadingRows")}
emptyText={
selectedTeam
? t("agentProfile.emptyTeamRows")
: t("agentProfile.emptyRows")
}
/>
) : null}
</TableBody>
</Table>
</DashboardTableShell>
</div>
</div>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
defaultTeamId={selectedTeam?.id ?? null}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
</>
</div>
);
}
@@ -8,9 +8,13 @@ import {
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import {
useDashboardPagedList,
type DashboardPagedListFilter,
} from "@/components/dashboard/list";
import { ListPagination } from "@/components/list-pagination";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -43,7 +47,6 @@ import {
updateKnowledgeDocument,
type CreateKnowledgeDocumentPayload,
type KnowledgeDocumentListItem,
type PageResult,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import {
@@ -136,15 +139,6 @@ const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [statusFilterInput, setStatusFilterInput] = useState("all");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [indexStatusFilter, setIndexStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
const [dialogOpen, setDialogOpen] = useState(false);
@@ -156,87 +150,72 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY);
return saved === "list" || saved === "grid" ? saved : "grid";
});
const [documents, setDocuments] = useState<PageResult<KnowledgeDocumentListItem>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
const loadData = useCallback(async (options?: {
keyword?: string;
statusFilter?: string;
indexStatusFilter?: string;
page?: number;
limit?: number;
}) => {
const nextKeyword = options?.keyword ?? keyword;
const nextStatusFilter = options?.statusFilter ?? statusFilter;
const nextIndexStatusFilter = options?.indexStatusFilter ?? indexStatusFilter;
const nextPage = options?.page ?? page;
const nextLimit = options?.limit ?? limit;
const filters = useMemo<DashboardPagedListFilter[]>(() => [
{
name: "title",
defaultValue: "",
trim: true,
},
{
name: "status",
defaultValue: "all",
allValue: "all",
},
{
name: "indexStatus",
defaultValue: "all",
allValue: "all",
},
], []);
if (!knowledgeBaseId) {
setDocuments({ results: [], page: { page: 1, limit, total: 0 } });
setLoading(false);
return;
}
const fetchList = useCallback(async (query: Record<string, string | number | undefined>) => {
return fetchKnowledgeDocuments({
title: typeof query.title === "string" ? query.title : undefined,
status: typeof query.status === "string" ? query.status : undefined,
indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined,
knowledgeBaseId: knowledgeBaseId ?? 0,
page: typeof query.page === "number" ? query.page : Number(query.page ?? 1),
limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20),
});
}, [knowledgeBaseId]);
setLoading(true);
try {
const data = await fetchKnowledgeDocuments({
title: nextKeyword.trim() || undefined,
status: nextStatusFilter === "all" ? undefined : nextStatusFilter,
indexStatus: nextIndexStatusFilter === "all" ? undefined : nextIndexStatusFilter,
knowledgeBaseId,
page: nextPage,
limit: nextLimit,
});
setDocuments(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.loadDocumentsFailed"));
} finally {
setLoading(false);
}
}, [indexStatusFilter, keyword, statusFilter, knowledgeBaseId, limit, page, t]);
useEffect(() => {
void loadData();
}, [loadData]);
const {
draftFilters,
setDraftFilter,
applyFilter,
applyFilters: applyDraftFilters,
loading,
result: documents,
loadData,
handlePageChange,
handleLimitChange,
} = useDashboardPagedList<KnowledgeDocumentListItem>({
filters,
fetchList,
enabled: Boolean(knowledgeBaseId),
loadFailed: t("knowledge.loadDocumentsFailed"),
});
useEffect(() => {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode);
}, [viewMode]);
function handleStatusFilterChange(value: string | null) {
const newValue = value ?? "all";
setStatusFilterInput(newValue);
setStatusFilter(newValue);
applyFilter("status", value ?? "all");
}
function handleIndexStatusFilterChange(value: string | null) {
const newValue = value ?? "all";
setIndexStatusFilterInput(newValue);
setIndexStatusFilter(newValue);
applyFilter("indexStatus", value ?? "all");
}
function applyFilters() {
const nextKeyword = keywordInput;
const nextStatusFilter = statusFilterInput;
const nextIndexStatusFilter = indexStatusFilterInput;
setKeyword(nextKeyword);
setStatusFilter(nextStatusFilter);
setIndexStatusFilter(nextIndexStatusFilter);
setPage(1);
void loadData({
keyword: nextKeyword,
statusFilter: nextStatusFilter,
indexStatusFilter: nextIndexStatusFilter,
page: 1,
});
applyDraftFilters();
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return;
}
@@ -348,15 +327,15 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<div className="relative flex-1">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
value={String(draftFilters.title ?? "")}
onChange={(event) => setDraftFilter("title", event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder={t("knowledge.searchDocumentTitle")}
className="h-8 pl-8 text-xs"
/>
</div>
<OptionCombobox
value={statusFilterInput}
value={String(draftFilters.status ?? "all")}
onChange={handleStatusFilterChange}
options={statusOptions}
placeholder={t("knowledge.allStatus")}
@@ -364,7 +343,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
emptyText={t("knowledge.emptyStatus")}
/>
<OptionCombobox
value={indexStatusFilterInput}
value={String(draftFilters.indexStatus ?? "all")}
onChange={handleIndexStatusFilterChange}
options={indexStatusOptions}
placeholder={t("knowledge.allIndexStatus")}
@@ -544,15 +523,8 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
limit={documents.page.limit}
total={documents.page.total}
loading={loading}
onPageChange={(nextPage) => {
setPage(nextPage);
void loadData({ page: nextPage });
}}
onLimitChange={(nextLimit) => {
setLimit(nextLimit);
setPage(1);
void loadData({ limit: nextLimit, page: 1 });
}}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
@@ -1,33 +1,16 @@
"use client";
import {
MoreHorizontalIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { ListPagination } from "@/components/list-pagination";
import {
DashboardCrudPage,
type DashboardCrudActionState,
type DashboardCrudColumn,
type DashboardCrudFilter,
} from "@/components/dashboard/crud";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { OptionCombobox } from "@/components/option-combobox";
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
@@ -42,11 +25,8 @@ import {
updateKnowledgeFAQ,
type CreateKnowledgeFAQPayload,
type KnowledgeFAQ,
type PageResult,
} from "@/lib/api/admin";
import {
KnowledgeDocumentIndexStatus,
} from "@/lib/generated/enums";
import { KnowledgeDocumentIndexStatus } from "@/lib/generated/enums";
import { useI18n } from "@/i18n/provider";
import { formatDateTime } from "@/lib/utils";
import { FAQEditDialog } from "./faq-edit";
@@ -57,11 +37,8 @@ type FAQListProps = {
onActionStateChange?: (state: FAQListActionState) => void;
};
export type FAQListActionState = {
onRefresh: () => void;
onCreate: () => void;
export type FAQListActionState = DashboardCrudActionState & {
onImport: () => void;
loading: boolean;
importing: boolean;
};
@@ -70,16 +47,31 @@ type TFunction = (key: string, values?: Record<string, string | number>) => stri
function getIndexStatusOptions(t: TFunction) {
return [
{ value: "all", label: t("knowledge.allIndexStatus") },
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
{
value: KnowledgeDocumentIndexStatus.Pending,
label: t("knowledge.indexPending"),
},
{
value: KnowledgeDocumentIndexStatus.Indexed,
label: t("knowledge.indexIndexed"),
},
{
value: KnowledgeDocumentIndexStatus.Failed,
label: t("knowledge.indexFailed"),
},
];
}
function getIndexStatusLabel(status: string, t: TFunction) {
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
if (status === KnowledgeDocumentIndexStatus.Pending) {
return t("knowledge.indexPending");
}
if (status === KnowledgeDocumentIndexStatus.Indexed) {
return t("knowledge.indexIndexed");
}
if (status === KnowledgeDocumentIndexStatus.Failed) {
return t("knowledge.indexFailed");
}
return status || "-";
}
@@ -127,153 +119,82 @@ export function FAQList({
onActionStateChange,
}: FAQListProps) {
const t = useI18n();
const [keywordInput, setKeywordInput] = useState("");
const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all");
const [keyword, setKeyword] = useState("");
const [indexStatusFilter, setIndexStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [importing, setImporting] = useState(false);
const [actionLoadingMap, setActionLoadingMap] = useState<
Record<number, { rebuildIndex: boolean; delete: boolean }>
>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<KnowledgeFAQ | null>(null);
const [result, setResult] = useState<PageResult<KnowledgeFAQ>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
const [crudActionState, setCrudActionState] =
useState<DashboardCrudActionState | null>(null);
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
const loadData = useCallback(
async (options?: {
keyword?: string;
indexStatusFilter?: string;
page?: number;
limit?: number;
}) => {
const nextKeyword = options?.keyword ?? keyword;
const nextIndexStatusFilter =
options?.indexStatusFilter ?? indexStatusFilter;
const nextPage = options?.page ?? page;
const nextLimit = options?.limit ?? limit;
if (!knowledgeBaseId) {
setResult({
results: [],
page: { page: 1, limit: 20, total: 0 },
});
useEffect(() => {
if (!crudActionState) {
return;
}
setLoading(true);
try {
const data = await fetchKnowledgeFAQs({
knowledgeBaseId,
question: nextKeyword.trim() || undefined,
indexStatus:
nextIndexStatusFilter === "all" ? undefined : nextIndexStatusFilter,
page: nextPage,
limit: nextLimit,
});
setResult(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.loadFAQFailed"));
} finally {
setLoading(false);
}
},
[indexStatusFilter, keyword, knowledgeBaseId, limit, page, t],
);
useEffect(() => {
void loadData();
}, [knowledgeBaseId, loadData]);
useEffect(() => {
onActionStateChange?.({
onRefresh: () => void loadData(),
onCreate: () => {
setEditingItem(null);
setDialogOpen(true);
},
...crudActionState,
onImport: () => setImportDialogOpen(true),
loading,
importing,
});
}, [importing, loadData, loading, onActionStateChange]);
}, [crudActionState, importing, onActionStateChange]);
function applyFilters() {
const nextKeyword = keywordInput;
const nextIndexStatusFilter = indexStatusFilterInput;
setKeyword(nextKeyword);
setIndexStatusFilter(nextIndexStatusFilter);
setPage(1);
void loadData({
keyword: nextKeyword,
indexStatusFilter: nextIndexStatusFilter,
page: 1,
});
}
const filters = useMemo<DashboardCrudFilter[]>(
() => [
{
name: "question",
label: t("knowledge.searchFAQ"),
placeholder: t("knowledge.searchFAQ"),
defaultValue: "",
trim: true,
className: "max-w-md flex-1",
},
{
name: "indexStatus",
label: t("knowledge.allIndexStatus"),
type: "select",
defaultValue: "all",
allValue: "all",
options: indexStatusOptions,
className: "w-full sm:w-48",
},
],
[indexStatusOptions, t],
);
async function handleSubmit(payload: CreateKnowledgeFAQPayload) {
setSaving(true);
try {
if (editingItem) {
await updateKnowledgeFAQ({ id: editingItem.id, ...payload });
} else {
await createKnowledgeFAQ(payload);
}
setDialogOpen(false);
setEditingItem(null);
await loadData();
toast.success(t("knowledge.faqSaved"));
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed"));
} finally {
setSaving(false);
}
}
async function handleDelete(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], delete: true },
}));
try {
await deleteKnowledgeFAQ(item.id);
toast.success(t("knowledge.faqDeleted"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed"));
} finally {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], delete: false },
}));
}
}
async function handleBuildIndex(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], rebuildIndex: true },
}));
try {
await buildKnowledgeFAQIndex(item.id);
toast.success(t("knowledge.faqIndexRebuilt"));
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed"));
} finally {
setActionLoadingMap((prev) => ({
...prev,
[item.id]: { ...prev[item.id], rebuildIndex: false },
}));
}
}
const columns = useMemo<DashboardCrudColumn<KnowledgeFAQ>[]>(
() => [
{
key: "question",
label: t("knowledge.question"),
className: "max-w-sm",
render: (item) => (
<>
<div className="font-medium">{item.question}</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.answer}
</div>
</>
),
},
{
key: "indexStatus",
label: t("knowledge.indexStatus"),
render: (item) => renderIndexStatusBadge(item, t),
},
{
key: "similarQuestions",
label: t("knowledge.similarQuestions"),
render: (item) =>
Array.isArray(item.similarQuestions)
? item.similarQuestions.length
: 0,
},
{
key: "updatedAt",
label: t("knowledge.updatedAt"),
render: (item) => formatDateTime(item.updatedAt),
},
],
[t],
);
if (!knowledgeBaseId) {
return (
@@ -286,161 +207,99 @@ export function FAQList({
return (
<>
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex items-center gap-2">
<div className="relative max-w-md flex-1">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
applyFilters();
<DashboardCrudPage<KnowledgeFAQ, CreateKnowledgeFAQPayload>
key={knowledgeBaseId}
layout="fragment"
showToolbarActions={false}
filters={filters}
columns={columns}
fetchList={(query) =>
fetchKnowledgeFAQs({
knowledgeBaseId,
question:
typeof query.question === "string" ? query.question : undefined,
indexStatus:
typeof query.indexStatus === "string"
? query.indexStatus
: undefined,
page: Number(query.page),
limit: Number(query.limit),
})
}
getItemId={(item) => item.id}
createItem={createKnowledgeFAQ}
updateItem={(item, payload) =>
updateKnowledgeFAQ({ id: item.id, ...payload })
}
deleteItem={(item) => deleteKnowledgeFAQ(item.id)}
rowActions={[
{
key: "rebuild-index",
icon: <WrenchIcon />,
label: t("knowledge.rebuildIndex"),
run: async ({ item, reload }) => {
try {
await buildKnowledgeFAQIndex(item.id);
toast.success(t("knowledge.faqIndexRebuilt"));
await reload();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("knowledge.faqIndexRebuildFailed"),
);
}
}}
placeholder={t("knowledge.searchFAQ")}
className="pl-9"
},
},
]}
renderEditDialog={({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}) => (
<FAQEditDialog
open={open}
saving={saving}
itemId={itemId}
knowledgeBaseId={knowledgeBaseId}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
</div>
<OptionCombobox
value={indexStatusFilterInput}
onChange={(value) => setIndexStatusFilterInput(value ?? "all")}
options={indexStatusOptions}
placeholder={t("knowledge.allIndexStatus")}
searchPlaceholder={t("knowledge.searchStatus")}
emptyText={t("knowledge.emptyStatus")}
/>
<Button
variant="outline"
onClick={applyFilters}
disabled={loading}
>
{t("knowledge.query")}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border">
<div className="h-full overflow-auto">
<table className="w-full min-w-max caption-bottom text-sm">
<TableHeader>
<TableRow>
<TableHead>{t("knowledge.question")}</TableHead>
<TableHead>{t("knowledge.indexStatus")}</TableHead>
<TableHead>{t("knowledge.similarQuestions")}</TableHead>
<TableHead>{t("knowledge.updatedAt")}</TableHead>
<TableHead className="w-20 text-right">{t("knowledge.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
<TableRow key={item.id}>
<TableCell className="max-w-sm">
<div className="font-medium">{item.question}</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.answer}
</div>
</TableCell>
<TableCell>{renderIndexStatusBadge(item, t)}</TableCell>
<TableCell>
{Array.isArray(item.similarQuestions)
? item.similarQuestions.length
: 0}
</TableCell>
<TableCell>{formatDateTime(item.updatedAt)}</TableCell>
<TableCell className="w-20 text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingItem(item);
setDialogOpen(true);
}}
>
{t("knowledge.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={t("knowledge.moreActions", { name: item.question })}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => void handleBuildIndex(item)}
>
<WrenchIcon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.rebuildIndex
? t("knowledge.rebuilding")
: t("knowledge.rebuildIndex")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => void handleDelete(item)}
>
<Trash2Icon className="mr-2 size-4" />
{actionLoadingMap[item.id]?.delete
? t("knowledge.deleting")
: t("knowledge.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
))}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-12 text-center text-sm text-muted-foreground"
>
{t("knowledge.emptyFAQ")}
</TableCell>
</TableRow>
) : null}
</TableBody>
</table>
</div>
</div>
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
onPageChange={(nextPage: number) => {
setPage(nextPage);
void loadData({ page: nextPage });
}}
onLimitChange={(next: number) => {
setLimit(next);
setPage(1);
void loadData({ limit: next, page: 1 });
)}
onActionStateChange={setCrudActionState}
labels={{
refresh: t("knowledge.refreshFAQ"),
create: t("knowledge.newFAQ"),
query: t("knowledge.query"),
loading: t("knowledge.loadingFAQ"),
empty: t("knowledge.emptyFAQ"),
actions: t("knowledge.actions"),
edit: t("knowledge.edit"),
delete: t("knowledge.delete"),
processing: t("knowledge.deleting"),
moreActions: (item) =>
t("knowledge.moreActions", { name: item.question }),
loadFailed: t("knowledge.loadFAQFailed"),
saveFailed: t("knowledge.faqSaveFailed"),
deleteFailed: t("knowledge.faqDeleteFailed"),
created: () => t("knowledge.faqSaved"),
updated: () => t("knowledge.faqSaved"),
deleted: () => t("knowledge.faqDeleted"),
}}
/>
</div>
<FAQEditDialog
open={dialogOpen}
saving={saving}
itemId={editingItem?.id ?? null}
knowledgeBaseId={knowledgeBaseId}
onOpenChange={(open) => {
if (!open) {
setEditingItem(null);
}
setDialogOpen(open);
}}
onSubmit={handleSubmit}
/>
<FAQImportDialog
open={importDialogOpen}
knowledgeBaseId={knowledgeBaseId}
importing={importing}
onOpenChange={setImportDialogOpen}
onImportingChange={setImporting}
onImported={loadData}
onImported={async () => {
crudActionState?.onRefresh();
}}
/>
</>
);
+240 -250
View File
@@ -1,28 +1,18 @@
"use client"
import { PlusIcon, RefreshCcwIcon, SearchIcon, SearchXIcon } from "lucide-react"
import { PlusIcon, SearchXIcon } from "lucide-react"
import { useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import {
DashboardPage,
DashboardTableShell,
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page"
import { OptionCombobox, type ComboboxOption } from "@/components/option-combobox"
DashboardListPage,
type DashboardListColumn,
type DashboardListFilter,
} from "@/components/dashboard/list"
import { type ComboboxOption } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
fetchAgentProfilesAll,
fetchTagsAll,
@@ -40,7 +30,7 @@ import {
type TicketSummary,
} from "@/lib/api/ticket"
import { useI18n } from "@/i18n/provider"
import { cn, formatDateTime } from "@/lib/utils"
import { formatDateTime } from "@/lib/utils"
import { EditDialog } from "./_components/edit"
import { TicketDetailDialog } from "./_components/ticket-detail-dialog"
import { TicketStatusBadge } from "./_components/ticket-status-badge"
@@ -118,93 +108,236 @@ function assigneeLabel(ticket: TicketItem, t: TFunction) {
return t("ticket.unassigned")
}
function quickViewLabel(label: string, count: number) {
return `${label} ${count}`
}
export default function TicketsPage() {
const t = useI18n()
const searchParams = useSearchParams()
const [tickets, setTickets] = useState<TicketItem[]>([])
const [summary, setSummary] = useState<TicketSummary>(emptySummary)
const [quickView, setQuickView] = useState<QuickViewKey>("all")
const [keyword, setKeyword] = useState("")
const [assigneeId, setAssigneeId] = useState("0")
const [tagId, setTagId] = useState("0")
const [staleHours, setStaleHours] = useState("24")
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([])
const [tagOptions, setTagOptions] = useState<ComboboxOption[]>([])
const [loading, setLoading] = useState(false)
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [savingCreate, setSavingCreate] = useState(false)
const loadSeqRef = useRef(0)
const listReloadRef = useRef<() => Promise<void>>(async () => undefined)
const quickViews = useMemo(
() =>
[
{ key: "all", label: t("ticket.quickAll"), count: summary.all },
{ key: "pending", label: t("ticket.quickPending"), count: summary.pending },
{ key: "in_progress", label: t("ticket.quickInProgress"), count: summary.inProgress },
{ key: "done", label: t("ticket.quickDone"), count: summary.done },
{ key: "unassigned", label: t("ticket.quickUnassigned"), count: summary.unassigned },
{ key: "mine", label: t("ticket.quickMine"), count: summary.mine },
{ key: "stale", label: t("ticket.quickStale"), count: summary.stale },
] satisfies Array<{ key: QuickViewKey; label: string; count: number }>,
[summary, t],
)
const assigneeAllOption = useMemo(() => getAssigneeAllOption(t), [t])
const tagAllOption = useMemo(() => getTagAllOption(t), [t])
const staleHourOptions = useMemo(() => getStaleHourOptions(t), [t])
const loadData = useCallback(async () => {
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
setLoading(true)
try {
const staleThreshold = Number(staleHours)
const query: TicketListQuery = {
page: 1,
limit: 50,
keyword: keyword.trim() || undefined,
currentAssigneeId: assigneeId !== "0" ? Number(assigneeId) : undefined,
tagId: tagId !== "0" ? Number(tagId) : undefined,
const quickViews = useMemo(
() =>
[
{
value: "all",
label: quickViewLabel(t("ticket.quickAll"), summary.all),
},
{
value: "pending",
label: quickViewLabel(t("ticket.quickPending"), summary.pending),
},
{
value: "in_progress",
label: quickViewLabel(
t("ticket.quickInProgress"),
summary.inProgress,
),
},
{
value: "done",
label: quickViewLabel(t("ticket.quickDone"), summary.done),
},
{
value: "unassigned",
label: quickViewLabel(
t("ticket.quickUnassigned"),
summary.unassigned,
),
},
{
value: "mine",
label: quickViewLabel(t("ticket.quickMine"), summary.mine),
},
{
value: "stale",
label: quickViewLabel(t("ticket.quickStale"), summary.stale),
},
] satisfies Array<{ value: QuickViewKey; label: string }>,
[summary, t],
)
const filters = useMemo<DashboardListFilter[]>(
() => [
{
name: "quickView",
label: t("ticket.quickAll"),
type: "segment",
defaultValue: "all",
allValue: "all",
options: quickViews,
className: "flex w-full flex-wrap gap-2",
},
{
name: "keyword",
label: t("ticket.searchPlaceholder"),
placeholder: t("ticket.searchPlaceholder"),
defaultValue: "",
trim: true,
className: "w-full sm:w-72",
},
{
name: "currentAssigneeId",
label: t("ticket.allAssignees"),
type: "select",
defaultValue: "0",
allValue: "0",
valueType: "number",
options: assigneeOptions,
className: "w-full sm:w-44",
},
{
name: "tagId",
label: t("ticket.allTags"),
type: "select",
defaultValue: "0",
allValue: "0",
valueType: "number",
options: tagOptions,
className: "w-full sm:w-44",
},
{
name: "staleHours",
label: t("ticket.staleThreshold"),
type: "select",
defaultValue: "24",
options: staleHourOptions,
className: "w-full sm:w-40",
},
],
[assigneeOptions, quickViews, staleHourOptions, tagOptions, t],
)
const fetchList = useCallback(
async (query: Record<string, string | number | boolean | string[] | number[] | undefined>) => {
const quickView = String(query.quickView ?? "all") as QuickViewKey
const staleThreshold = Number(query.staleHours ?? 24)
const ticketQuery: TicketListQuery = {
page: Number(query.page),
limit: Number(query.limit),
keyword: typeof query.keyword === "string" ? query.keyword : undefined,
currentAssigneeId:
typeof query.currentAssigneeId === "number"
? query.currentAssigneeId
: undefined,
tagId: typeof query.tagId === "number" ? query.tagId : undefined,
}
if (quickView === "pending" || quickView === "in_progress" || quickView === "done") {
query.status = quickView as TicketStatus
if (
quickView === "pending" ||
quickView === "in_progress" ||
quickView === "done"
) {
ticketQuery.status = quickView as TicketStatus
}
if (quickView === "unassigned") {
query.unassigned = 1
ticketQuery.unassigned = 1
}
if (quickView === "mine") {
query.mine = 1
ticketQuery.mine = 1
}
if (quickView === "stale") {
query.staleHours = staleThreshold
ticketQuery.staleHours = staleThreshold
}
const [ticketData, summaryData] = await Promise.all([
fetchTickets(query),
fetchTickets(ticketQuery),
fetchTicketSummary({ staleHours: staleThreshold }),
])
if (loadSeqRef.current !== seq) {
return
}
setTickets(Array.isArray(ticketData.results) ? ticketData.results : [])
setSummary(summaryData ?? emptySummary)
} catch (error) {
if (loadSeqRef.current !== seq) {
return
}
toast.error(error instanceof Error ? error.message : t("ticket.loadFailed"))
} finally {
if (loadSeqRef.current === seq) {
setLoading(false)
}
}
}, [assigneeId, keyword, quickView, staleHours, tagId, t])
return ticketData
},
[],
)
useEffect(() => {
void loadData()
}, [loadData])
const columns = useMemo<DashboardListColumn<TicketItem>[]>(
() => [
{
key: "ticket",
label: t("ticket.columnTicket"),
render: (ticket) => (
<div className="min-w-0 space-y-1">
<div className="truncate text-sm font-medium">{ticket.title}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{ticket.ticketNo}</span>
<span>
{ticket.customer?.name ||
(ticket.customerId
? t("ticket.customerFallback", { id: ticket.customerId })
: t("ticket.noCustomer"))}
</span>
<span>{sourceLabel(ticket.source, t)}</span>
{ticket.channel ? <span>{ticket.channel}</span> : null}
</div>
{ticket.tags && ticket.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{ticket.tags.slice(0, 3).map((tag) => (
<Badge key={tag.id} variant="outline" className="px-1.5 py-0 text-[11px]">
{tag.name}
</Badge>
))}
{ticket.tags.length > 3 ? (
<Badge variant="outline" className="px-1.5 py-0 text-[11px]">
+{ticket.tags.length - 3}
</Badge>
) : null}
</div>
) : null}
</div>
),
},
{
key: "status",
label: t("ticket.columnStatus"),
className: "w-28",
render: (ticket) => <TicketStatusBadge status={ticket.status} />,
},
{
key: "assignee",
label: t("ticket.columnAssignee"),
className: "max-w-36 truncate text-sm text-muted-foreground",
render: (ticket) => assigneeLabel(ticket, t),
},
{
key: "updatedAt",
label: t("ticket.columnUpdated"),
className: "w-40 text-sm text-muted-foreground",
render: (ticket) =>
ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "-",
},
{
key: "actions",
label: t("ticket.columnActions"),
className: "w-24 text-right",
render: (ticket) => (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSelectedTicketId(ticket.id)
setDetailOpen(true)
}}
>
{t("ticket.detail")}
</Button>
),
},
],
[t],
)
useEffect(() => {
const ticketId = Number(searchParams.get("ticketId"))
@@ -242,21 +375,13 @@ export default function TicketsPage() {
}
}, [assigneeAllOption, tagAllOption, t])
function resetFilters() {
setQuickView("all")
setKeyword("")
setAssigneeId("0")
setTagId("0")
setStaleHours("24")
}
async function handleCreateTicket(payload: CreateTicketPayload) {
setSavingCreate(true)
try {
await createTicket(payload)
toast.success(t("ticket.created"))
setCreateOpen(false)
await loadData()
await listReloadRef.current()
} catch (error) {
toast.error(error instanceof Error ? error.message : t("ticket.createFailed"))
} finally {
@@ -265,171 +390,36 @@ export default function TicketsPage() {
}
return (
<DashboardPage>
<div className="flex flex-wrap gap-2">
{quickViews.map((view) => (
<button
key={view.key}
type="button"
className={cn(
"inline-flex min-w-0 items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition",
quickView === view.key
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
onClick={() => setQuickView(view.key)}
>
<span className="truncate font-medium">{view.label}</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
quickView === view.key ? "bg-primary-foreground/20" : "bg-muted text-muted-foreground",
)}
>
{view.count}
</span>
</button>
))}
</div>
<DashboardToolbar
actions={
<>
<Button type="button" variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCcwIcon className={cn("size-4", loading ? "animate-spin" : "")} />
{t("ticket.refresh")}
</Button>
<Button type="button" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
{t("ticket.newTicket")}
</Button>
</>
}
>
<Input
className="w-full sm:w-72"
placeholder={t("ticket.searchPlaceholder")}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void loadData()
}
}}
/>
<div className="w-full sm:w-44">
<OptionCombobox
value={assigneeId}
onChange={setAssigneeId}
placeholder={t("ticket.allAssignees")}
options={assigneeOptions}
/>
</div>
<div className="w-full sm:w-44">
<OptionCombobox
value={tagId}
onChange={setTagId}
placeholder={t("ticket.allTags")}
options={tagOptions}
/>
</div>
<div className="w-full sm:w-40">
<OptionCombobox
value={staleHours}
onChange={setStaleHours}
placeholder={t("ticket.staleThreshold")}
options={staleHourOptions}
/>
</div>
<Button type="button" variant="outline" onClick={resetFilters}>
<SearchXIcon className="size-4" />
{t("ticket.reset")}
</Button>
<Button type="button" variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCcwIcon className={cn("size-4", loading ? "animate-spin" : "")} />
{t("ticket.refresh")}
</Button>
<Button type="button" onClick={() => void loadData()} disabled={loading}>
<SearchIcon className="size-4" />
{t("ticket.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("ticket.columnTicket")}</TableHead>
<TableHead className="w-28">{t("ticket.columnStatus")}</TableHead>
<TableHead className="w-36">{t("ticket.columnAssignee")}</TableHead>
<TableHead className="w-40">{t("ticket.columnUpdated")}</TableHead>
<TableHead className="w-24 text-right">{t("ticket.columnActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.length === 0 ? (
<DashboardTableStateRow
colSpan={5}
loading={loading}
loadingText={t("ticket.loadingRows")}
emptyText={t("ticket.emptyRows")}
/>
) : (
tickets.map((ticket) => (
<TableRow key={ticket.id}>
<TableCell>
<div className="min-w-0 space-y-1">
<div className="truncate text-sm font-medium">{ticket.title}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{ticket.ticketNo}</span>
<span>{ticket.customer?.name || (ticket.customerId ? t("ticket.customerFallback", { id: ticket.customerId }) : t("ticket.noCustomer"))}</span>
<span>{sourceLabel(ticket.source, t)}</span>
{ticket.channel ? <span>{ticket.channel}</span> : null}
</div>
{ticket.tags && ticket.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{ticket.tags.slice(0, 3).map((tag) => (
<Badge key={tag.id} variant="outline" className="px-1.5 py-0 text-[11px]">
{tag.name}
</Badge>
))}
{ticket.tags.length > 3 ? (
<Badge variant="outline" className="px-1.5 py-0 text-[11px]">
+{ticket.tags.length - 3}
</Badge>
) : null}
</div>
) : null}
</div>
</TableCell>
<TableCell>
<TicketStatusBadge status={ticket.status} />
</TableCell>
<TableCell className="max-w-36 truncate text-sm text-muted-foreground">
{assigneeLabel(ticket, t)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "-"}
</TableCell>
<TableCell className="text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSelectedTicketId(ticket.id)
setDetailOpen(true)
}}
>
{t("ticket.detail")}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</DashboardTableShell>
<>
<DashboardListPage<TicketItem>
filters={filters}
fetchList={fetchList}
columns={columns}
getItemId={(ticket) => ticket.id}
pageSize={50}
renderToolbarActions={(context) => {
listReloadRef.current = context.reload
return (
<>
<Button type="button" variant="outline" onClick={context.resetFilters}>
<SearchXIcon className="size-4" />
{t("ticket.reset")}
</Button>
<Button type="button" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
{t("ticket.newTicket")}
</Button>
</>
)
}}
labels={{
refresh: t("ticket.refresh"),
query: t("ticket.query"),
loading: t("ticket.loadingRows"),
empty: t("ticket.emptyRows"),
loadFailed: t("ticket.loadFailed"),
}}
/>
<EditDialog
open={createOpen}
@@ -447,8 +437,8 @@ export default function TicketsPage() {
setSelectedTicketId(null)
}
}}
onChanged={loadData}
onChanged={() => listReloadRef.current()}
/>
</DashboardPage>
</>
)
}
+49 -56
View File
@@ -1,6 +1,6 @@
"use client"
import { type KeyboardEvent, useCallback, useEffect, useState } from "react"
import { type KeyboardEvent, useCallback, useMemo, useState } from "react"
import {
KeyRoundIcon,
MoreHorizontalIcon,
@@ -17,6 +17,10 @@ import {
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page"
import {
useDashboardPagedList,
type DashboardListFilter,
} from "@/components/dashboard/list"
import { ListPagination } from "@/components/list-pagination"
import {
assignUserRoles,
@@ -30,7 +34,6 @@ import {
type AdminRole,
type AdminUser,
type CreateAdminUserPayload,
type PageResult,
type ResetPasswordResult,
type UpdateAdminUserPayload,
} from "@/lib/api/admin"
@@ -65,11 +68,6 @@ import {
export default function DashboardUsersPage() {
const t = useI18n()
const { locale } = useAppLocale()
const [keywordInput, setKeywordInput] = useState("")
const [keyword, setKeyword] = useState("")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [creatingOpen, setCreatingOpen] = useState(false)
const [savingCreate, setSavingCreate] = useState(false)
const [initialPassword, setInitialPassword] = useState<{
@@ -88,34 +86,34 @@ export default function DashboardUsersPage() {
const [resetPasswordResult, setResetPasswordResult] =
useState<ResetPasswordResult | null>(null)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<AdminUser>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
const filters = useMemo<DashboardListFilter[]>(
() => [
{
name: "username",
label: t("user.filterUsername"),
defaultValue: "",
trim: true,
},
],
[t],
)
const fetchList = useCallback(
(query: Record<string, string | number | boolean | string[] | number[] | undefined>) =>
fetchUsers({
username: typeof query.username === "string" ? query.username : undefined,
page: Number(query.page),
limit: Number(query.limit),
}),
[],
)
const list = useDashboardPagedList<AdminUser>({
filters,
fetchList,
loadFailed: t("user.loadFailed"),
})
const loadUsers = useCallback(async () => {
setLoading(true)
try {
const data = await fetchUsers({
username: keyword.trim() || undefined,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : t("user.loadFailed"))
} finally {
setLoading(false)
}
}, [keyword, limit, page, t])
useEffect(() => {
void loadUsers()
}, [loadUsers])
function applyFilters() {
setKeyword(keywordInput)
setPage(1)
list.applyFilters()
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
@@ -152,18 +150,11 @@ export default function DashboardUsersPage() {
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return
}
setPage(nextPage)
list.handlePageChange(nextPage)
}
function handleLimitChange(nextLimit: number) {
if (nextLimit <= 0 || nextLimit === limit) {
return
}
setLimit(nextLimit)
setPage(1)
list.handleLimitChange(nextLimit)
}
function handleEditDrawerOpenChange(open: boolean) {
@@ -198,7 +189,7 @@ export default function DashboardUsersPage() {
username: result.user.username,
password: result.password,
})
await loadUsers()
await list.loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : t("user.createFailed"))
} finally {
@@ -227,7 +218,7 @@ export default function DashboardUsersPage() {
await updateUser(payload)
toast.success(t("user.updated", { username: editingUser?.username || t("user.fallbackUser") }))
setEditingUser(null)
await loadUsers()
await list.loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : t("user.updateFailed"))
} finally {
@@ -247,7 +238,7 @@ export default function DashboardUsersPage() {
setAssigningRolesUser(null)
setAssignRoleOptions([])
setAssignRoleIds([])
await loadUsers()
await list.loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : t("user.saveRolesFailed"))
} finally {
@@ -298,7 +289,7 @@ export default function DashboardUsersPage() {
status: nextStatus === Status.Ok ? t("user.enabled") : t("user.disabled"),
})
)
await loadUsers()
await list.loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : t("user.statusUpdateFailed"))
} finally {
@@ -311,7 +302,7 @@ export default function DashboardUsersPage() {
<DashboardPage>
<DashboardToolbar
actions={
<Button onClick={() => setCreatingOpen(true)} disabled={loading}>
<Button onClick={() => setCreatingOpen(true)} disabled={list.loading}>
<PlusIcon />
{t("user.addUser")}
</Button>
@@ -320,24 +311,26 @@ export default function DashboardUsersPage() {
<div className="relative w-full sm:w-72">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
value={String(list.draftFilters.username ?? "")}
onChange={(event) =>
list.setDraftFilter("username", event.target.value)
}
onKeyDown={handleFilterKeyDown}
placeholder={t("user.filterUsername")}
className="pl-9"
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<Button variant="outline" onClick={applyFilters} disabled={list.loading}>
{t("user.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
pagination={
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading}
page={list.result.page.page}
total={list.result.page.total}
limit={list.result.page.limit}
loading={list.loading}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
@@ -355,7 +348,7 @@ export default function DashboardUsersPage() {
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => (
{list.result.results.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="flex items-center gap-3">
@@ -455,10 +448,10 @@ export default function DashboardUsersPage() {
</TableCell>
</TableRow>
))}
{loading || result.results.length === 0 ? (
{list.loading || list.result.results.length === 0 ? (
<DashboardTableStateRow
colSpan={6}
loading={loading}
loading={list.loading}
loadingText={t("user.loadingRows")}
emptyText={t("user.emptyRows")}
/>
@@ -1,7 +1,7 @@
"use client"
import type { ReactNode } from "react"
import { useState } from "react"
import { useCallback, useEffect, useState } from "react"
import {
closestCenter,
DndContext,
@@ -111,6 +111,12 @@ export type DashboardCrudRowAction<TItem> = DashboardCrudActionRule<TItem> & {
run: (context: DashboardCrudRowActionContext<TItem>) => Promise<void> | void
}
export type DashboardCrudActionState = {
onRefresh: () => void
onCreate: () => void
loading: boolean
}
export type DashboardCrudPageProps<TItem, TPayload> = {
filters: DashboardCrudFilter[]
columns: DashboardCrudColumn<TItem>[]
@@ -142,6 +148,7 @@ export type DashboardCrudPageProps<TItem, TPayload> = {
getItemId: (item: TItem) => number
createItem: (payload: TPayload) => Promise<unknown>
updateItem: (item: TItem, payload: TPayload) => Promise<unknown>
canEdit?: (item: TItem) => boolean
deleteItem?: (item: TItem) => Promise<unknown>
canDelete?: (item: TItem) => boolean
deleteConfirm?: false | ConfirmOptions | ((item: TItem) => ConfirmOptions)
@@ -156,6 +163,11 @@ export type DashboardCrudPageProps<TItem, TPayload> = {
handleLabel: string
}
pageSize?: number
layout?: "page" | "fragment"
showToolbar?: boolean
showToolbarActions?: boolean
tableShellClassName?: string
onActionStateChange?: (state: DashboardCrudActionState) => void
labels: {
refresh: string
create: string
@@ -185,6 +197,7 @@ export function DashboardCrudPage<TItem, TPayload>({
getItemId,
createItem,
updateItem,
canEdit,
deleteItem,
canDelete,
deleteConfirm,
@@ -192,6 +205,11 @@ export function DashboardCrudPage<TItem, TPayload>({
renderRowActions,
sort,
pageSize = 20,
layout = "page",
showToolbar = true,
showToolbarActions = true,
tableShellClassName,
onActionStateChange,
labels,
}: DashboardCrudPageProps<TItem, TPayload>) {
const confirm = useConfirm()
@@ -231,16 +249,24 @@ export function DashboardCrudPage<TItem, TPayload>({
handleApplyFilters()
}
function openCreateDialog() {
const openCreateDialog = useCallback(() => {
setEditingItem(null)
setDialogOpen(true)
}
}, [])
function openEditDialog(item: TItem) {
setEditingItem(item)
setDialogOpen(true)
}
useEffect(() => {
onActionStateChange?.({
onRefresh: () => void loadData(),
onCreate: openCreateDialog,
loading,
})
}, [loadData, loading, onActionStateChange, openCreateDialog])
function handleDialogOpenChange(open: boolean) {
if (saving) return
if (!open) setEditingItem(null)
@@ -379,7 +405,12 @@ export function DashboardCrudPage<TItem, TPayload>({
))}
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openEditDialog(item)}>
<Button
variant="outline"
size="sm"
disabled={canEdit ? !canEdit(item) : false}
onClick={() => openEditDialog(item)}
>
{labels.edit}
</Button>
{renderRowActions || rowActions.length > 0 || deleteItem ? (
@@ -475,21 +506,23 @@ export function DashboardCrudPage<TItem, TPayload>({
)
}
return (
const content = (
<>
<DashboardPage>
{showToolbar ? (
<DashboardToolbar
actions={
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : undefined} />
{labels.refresh}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{labels.create}
</Button>
</>
showToolbarActions ? (
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : undefined} />
{labels.refresh}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{labels.create}
</Button>
</>
) : null
}
>
{filters.map((filter) => {
@@ -525,86 +558,104 @@ export function DashboardCrudPage<TItem, TPayload>({
{labels.query}
</Button>
</DashboardToolbar>
) : null}
<DashboardTableShell
pagination={
<ListPagination
page={result.page.page}
total={result.page.total}
limit={list.limit}
loading={loading}
onPageChange={list.handlePageChange}
onLimitChange={list.handleLimitChange}
/>
}
>
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
{sortable ? <TableHead className="w-10" /> : null}
{columns.map((column) => (
<TableHead key={column.key} className={column.className}>
{column.label}
</TableHead>
))}
<TableHead className="w-[92px] text-right">
{labels.actions}
<DashboardTableShell
className={tableShellClassName}
pagination={
<ListPagination
page={result.page.page}
total={result.page.total}
limit={list.limit}
loading={loading}
onPageChange={list.handlePageChange}
onLimitChange={list.handleLimitChange}
/>
}
>
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
{sortable ? <TableHead className="w-10" /> : null}
{columns.map((column) => (
<TableHead key={column.key} className={column.className}>
{column.label}
</TableHead>
</TableRow>
</TableHeader>
{sortable ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleSortEnd(event)}
))}
<TableHead className="w-[92px] text-right">
{labels.actions}
</TableHead>
</TableRow>
</TableHeader>
{sortable ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleSortEnd(event)}
>
<SortableContext
items={result.results.map((item) => String(getItemId(item)))}
strategy={verticalListSortingStrategy}
>
<SortableContext
items={result.results.map((item) => String(getItemId(item)))}
strategy={verticalListSortingStrategy}
>
<TableBody>
{result.results.map((item) =>
renderTableRow(item, {
sortable: true,
sortDisabled: sort?.disabled,
})
)}
{renderStateRow()}
</TableBody>
</SortableContext>
</DndContext>
) : (
<TableBody>
{result.results.map((item) => renderTableRow(item))}
{renderStateRow()}
</TableBody>
)}
</Table>
</DashboardTableShell>
</DashboardPage>
{form ? (
<DashboardCrudFormDialog
open={dialogOpen}
saving={saving}
item={editingItem}
itemId={editingItem ? getItemId(editingItem) : null}
fields={form.fields}
fetchDetail={form.fetchDetail}
transformSubmitValues={form.transformSubmitValues}
labels={form.labels}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
) : (
renderEditDialog?.({
open: dialogOpen,
saving,
item: editingItem,
itemId: editingItem ? getItemId(editingItem) : null,
onOpenChange: handleDialogOpenChange,
onSubmit: handleSubmit,
})
)}
<TableBody>
{result.results.map((item) =>
renderTableRow(item, {
sortable: true,
sortDisabled: sort?.disabled,
})
)}
{renderStateRow()}
</TableBody>
</SortableContext>
</DndContext>
) : (
<TableBody>
{result.results.map((item) => renderTableRow(item))}
{renderStateRow()}
</TableBody>
)}
</Table>
</DashboardTableShell>
</>
)
const dialog = form ? (
<DashboardCrudFormDialog
open={dialogOpen}
saving={saving}
item={editingItem}
itemId={editingItem ? getItemId(editingItem) : null}
fields={form.fields}
fetchDetail={form.fetchDetail}
transformSubmitValues={form.transformSubmitValues}
labels={form.labels}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
) : (
renderEditDialog?.({
open: dialogOpen,
saving,
item: editingItem,
itemId: editingItem ? getItemId(editingItem) : null,
onOpenChange: handleDialogOpenChange,
onSubmit: handleSubmit,
})
)
if (layout === "fragment") {
return (
<>
{content}
{dialog}
</>
)
}
return (
<>
<DashboardPage>{content}</DashboardPage>
{dialog}
</>
)
}
+1
View File
@@ -11,6 +11,7 @@ export {
export { useDashboardCrudFilters } from "./use-dashboard-crud-filters"
export type {
DashboardCrudColumn,
DashboardCrudActionState,
DashboardCrudDialogProps,
DashboardCrudFilter,
DashboardCrudPageProps,
@@ -48,6 +48,11 @@ export function useDashboardCrudFilters(
}))
}
function resetFilters() {
setDraftFilters(initialFilters)
setAppliedFilters(initialFilters)
}
return {
draftFilters,
appliedFilters,
@@ -55,5 +60,6 @@ export function useDashboardCrudFilters(
setDraftFilters,
applyFilter,
applyFilters,
resetFilters,
}
}
@@ -54,6 +54,7 @@ export type DashboardListRenderContext<TItem> = {
result: DashboardCrudPageResult<TItem>
loading: boolean
reload: () => Promise<void>
resetFilters: () => void
}
export type DashboardListPageProps<TItem> = {
@@ -104,6 +105,7 @@ export function DashboardListPage<TItem>({
result: list.result,
loading: list.loading,
reload: list.loadData,
resetFilters: list.resetFilters,
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
+4 -1
View File
@@ -6,4 +6,7 @@ export type {
DashboardListPageProps,
DashboardListRenderContext,
} from "./dashboard-list-page"
export type { DashboardPagedListOptions } from "./use-dashboard-paged-list"
export type {
DashboardPagedListFilter,
DashboardPagedListOptions,
} from "./use-dashboard-paged-list"
@@ -39,7 +39,14 @@ export function useDashboardPagedList<TItem>({
`${filter.name}:${String(filter.defaultValue)}:${String(filter.allValue)}:${filter.trim ? "1" : "0"}:${filter.valueType ?? ""}`
)
.join("|")
const { draftFilters, appliedFilters, setDraftFilter, applyFilter, applyFilters } =
const {
draftFilters,
appliedFilters,
setDraftFilter,
applyFilter,
applyFilters,
resetFilters,
} =
useDashboardCrudFilters(filters)
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(pageSize)
@@ -100,6 +107,11 @@ export function useDashboardPagedList<TItem>({
setPage(1)
}
function resetDraftFilters() {
resetFilters()
setPage(1)
}
return {
draftFilters,
setDraftFilter,
@@ -115,5 +127,6 @@ export function useDashboardPagedList<TItem>({
loadData,
handlePageChange,
handleLimitChange,
resetFilters: resetDraftFilters,
}
}
+2
View File
@@ -1177,6 +1177,7 @@
"saveFailed": "Could not save the agent profile.",
"deleted": "Agent profile deleted: {name}",
"deleteFailed": "Could not delete the agent profile.",
"refresh": "Refresh",
"expandTeams": "Expand team list",
"collapseTeams": "Collapse team list",
"new": "New",
@@ -1785,6 +1786,7 @@
"content": "Content",
"contentPlaceholder": "Enter document content",
"loadFAQFailed": "Could not load FAQs.",
"loadingFAQ": "Loading FAQs...",
"faqSaved": "FAQ saved.",
"faqSaveFailed": "Could not save the FAQ.",
"faqDeleted": "FAQ deleted.",
+2
View File
@@ -1177,6 +1177,7 @@
"saveFailed": "保存客服档案失败",
"deleted": "已删除客服档案:{name}",
"deleteFailed": "删除客服档案失败",
"refresh": "刷新",
"expandTeams": "展开客服组列表",
"collapseTeams": "折叠客服组列表",
"new": "新建",
@@ -1785,6 +1786,7 @@
"content": "内容",
"contentPlaceholder": "请输入文档内容",
"loadFAQFailed": "加载FAQ失败",
"loadingFAQ": "正在加载FAQ...",
"faqSaved": "FAQ已保存",
"faqSaveFailed": "保存FAQ失败",
"faqDeleted": "FAQ已删除",