diff --git a/web/app/dashboard/agent-team-schedules/page.tsx b/web/app/dashboard/agent-team-schedules/page.tsx index 95f857d..3990aa8 100644 --- a/web/app/dashboard/agent-team-schedules/page.tsx +++ b/web/app/dashboard/agent-team-schedules/page.tsx @@ -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("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(null) @@ -97,10 +81,6 @@ export default function DashboardAgentTeamSchedulesPage() { const [dialogDefaults, setDialogDefaults] = useState | null>(null) const [teams, setTeams] = useState([]) const [calendarItems, setCalendarItems] = useState([]) - const [result, setResult] = useState>({ - 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( + () => [ + { + 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[]>( + () => [ + { + key: "team", + label: t("agentTeamSchedule.team"), + render: (item) => ( +
+
+ {item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })} +
+
+ {t("agentTeamSchedule.teamId", { id: item.teamId })} +
+
+ ), + }, + { + key: "timeRange", + label: t("agentTeamSchedule.timeRange"), + render: (item) => ( + <> +
{formatDateTime(item.startAt)}
+
+ {formatDateTime(item.endAt)} +
+ + ), + }, + ], + [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) { @@ -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() { ) : (
-
- - - - {t("agentTeamSchedule.team")} - {t("agentTeamSchedule.timeRange")} - {t("agentTeamSchedule.actions")} - - - - {result.results.map((item) => ( - - -
-
- -
-
-
{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}
-
{t("agentTeamSchedule.teamId", { id: item.teamId })}
-
-
-
- -
{formatDateTime(item.startAt)}
-
{formatDateTime(item.endAt)}
-
- - - - - } - aria-label={t("agentTeamSchedule.moreActions", { name: item.startAt })} - > - - - - void handleDelete(item)} - className="text-destructive focus:text-destructive" - > - - {actionLoadingId === item.id ? t("agentTeamSchedule.deleting") : t("agentTeamSchedule.delete")} - - - - - -
- ))} - {!loading && result.results.length === 0 ? ( - - - {t("agentTeamSchedule.emptyRows")} - - - ) : null} -
-
-
- { - setLimit(nextLimit) - setPage(1) + + 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 }) => ( + + )} + 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"), }} />
diff --git a/web/app/dashboard/agents/page.tsx b/web/app/dashboard/agents/page.tsx index 7288803..4526ae1 100644 --- a/web/app/dashboard/agents/page.tsx +++ b/web/app/dashboard/agents/page.tsx @@ -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(null); - const [teams, setTeams] = useState([]); 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(null); - const [dialogOpen, setDialogOpen] = useState(false); - const [editingItem, setEditingItem] = useState( - 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( + () => [ + { + 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>({ - 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) { - 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[]>( + () => [ + { + key: "agent", + label: t("agentProfile.columnAgent"), + render: (item) => ( +
+
+ {item.avatar ? ( + {item.displayName} + ) : ( + + )} +
+
+
{item.displayName}
+
+ {item.nickname || + item.username || + t("agentProfile.userFallback", { id: item.userId })} +
+
+ {t("agentProfile.agentCode", { code: item.agentCode })} +
+
+
+ ), + }, + { + key: "rules", + label: t("agentProfile.columnRules"), + render: (item) => ( + <> + + {getStatusLabel(item.serviceStatus, t)} + +
+ {t("agentProfile.capacityPriority", { + capacity: item.maxConcurrentCount, + priority: item.priorityLevel, + })} +
+ + ), + }, + { + key: "dispatch", + label: t("agentProfile.columnDispatch"), + render: (item) => ( +
+ + {item.autoAssignEnabled + ? t("agentProfile.autoAssign") + : t("agentProfile.noAutoAssign")} + + + {item.receiveOfflineMessage + ? t("agentProfile.offlineReceive") + : t("agentProfile.noOfflineReceive")} + +
+ ), + }, + { + key: "recent", + label: t("agentProfile.columnRecent"), + render: (item) => ( + <> +
+ {t("agentProfile.onlineAt", { + time: formatDateTime(item.lastOnlineAt), + })} +
+
+ {t("agentProfile.statusAt", { + time: formatDateTime(item.lastStatusAt), + })} +
+ + ), + }, + ], + [t], + ); return ( - <> -
-
+
+ +
+
+ +
+
+
+ + 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, + }) => ( + + )} + 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 }), + }} />
-
- -
-
-
- - - {t("agentProfile.new")} - - } - > -
- - - setDisplayNameInput(event.target.value) - } - onKeyDown={handleFilterKeyDown} - placeholder={t("agentProfile.filterDisplayName")} - className="pl-9" - /> -
- setAgentCodeInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("agentProfile.filterAgentCode")} - className="w-full sm:w-44" - /> -
- setStatusFilterInput(value ?? "all")} - /> -
- -
- { - setLimit(nextLimit); - setPage(1); - }} - /> - } - > - - - - {t("agentProfile.columnAgent")} - {t("agentProfile.columnRules")} - {t("agentProfile.columnDispatch")} - {t("agentProfile.columnRecent")} - - {t("agentProfile.columnActions")} - - - - - {result.results.map((item) => ( - - -
-
- {item.avatar ? ( - {item.displayName} - ) : ( - - )} -
-
-
- {item.displayName} -
-
- {item.nickname || - item.username || - t("agentProfile.userFallback", { id: item.userId })} -
-
- {t("agentProfile.agentCode", { code: item.agentCode })} -
-
-
-
- - - {getStatusLabel(item.serviceStatus, t)} - -
- {t("agentProfile.capacityPriority", { - capacity: item.maxConcurrentCount, - priority: item.priorityLevel, - })} -
-
- -
- - {item.autoAssignEnabled - ? t("agentProfile.autoAssign") - : t("agentProfile.noAutoAssign")} - - - {item.receiveOfflineMessage - ? t("agentProfile.offlineReceive") - : t("agentProfile.noOfflineReceive")} - -
-
- -
- {t("agentProfile.onlineAt", { time: formatDateTime(item.lastOnlineAt) })} -
-
- {t("agentProfile.statusAt", { time: formatDateTime(item.lastStatusAt) })} -
-
- - - - - - } - aria-label={t("agentProfile.moreActions", { name: item.displayName })} - > - - - - void handleDelete(item)} - className="text-destructive focus:text-destructive" - > - - {actionLoadingId === item.id - ? t("agentProfile.deleting") - : t("agentProfile.delete")} - - - - - -
- ))} - {loading || result.results.length === 0 ? ( - - ) : null} -
-
-
-
-
- - +
); } diff --git a/web/app/dashboard/knowledge/_components/document-list.tsx b/web/app/dashboard/knowledge/_components/document-list.tsx index 941bcf7..d4fe960 100644 --- a/web/app/dashboard/knowledge/_components/document-list.tsx +++ b/web/app/dashboard/knowledge/_components/document-list.tsx @@ -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>({}); 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>({ - 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(() => [ + { + 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) => { + 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({ + 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) { + function handleFilterKeyDown(event: KeyboardEvent) { if (event.key !== "Enter") { return; } @@ -348,15 +327,15 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
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" />
{ - setPage(nextPage); - void loadData({ page: nextPage }); - }} - onLimitChange={(nextLimit) => { - setLimit(nextLimit); - setPage(1); - void loadData({ limit: nextLimit, page: 1 }); - }} + onPageChange={handlePageChange} + onLimitChange={handleLimitChange} />
diff --git a/web/app/dashboard/knowledge/_components/faq-list.tsx b/web/app/dashboard/knowledge/_components/faq-list.tsx index 81c80b8..5030b8f 100644 --- a/web/app/dashboard/knowledge/_components/faq-list.tsx +++ b/web/app/dashboard/knowledge/_components/faq-list.tsx @@ -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) => 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 - >({}); - const [dialogOpen, setDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }); + const [crudActionState, setCrudActionState] = + useState(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( + () => [ + { + 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[]>( + () => [ + { + key: "question", + label: t("knowledge.question"), + className: "max-w-sm", + render: (item) => ( + <> +
{item.question}
+
+ {item.answer} +
+ + ), + }, + { + 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 ( <>
-
-
- - setKeywordInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - applyFilters(); + + 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: , + 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, + }) => ( + -
- setIndexStatusFilterInput(value ?? "all")} - options={indexStatusOptions} - placeholder={t("knowledge.allIndexStatus")} - searchPlaceholder={t("knowledge.searchStatus")} - emptyText={t("knowledge.emptyStatus")} - /> - -
- -
-
- - - - {t("knowledge.question")} - {t("knowledge.indexStatus")} - {t("knowledge.similarQuestions")} - {t("knowledge.updatedAt")} - {t("knowledge.actions")} - - - - {result.results.map((item) => ( - - -
{item.question}
-
- {item.answer} -
-
- {renderIndexStatusBadge(item, t)} - - {Array.isArray(item.similarQuestions) - ? item.similarQuestions.length - : 0} - - {formatDateTime(item.updatedAt)} - - - - - } - aria-label={t("knowledge.moreActions", { name: item.question })} - > - - - - void handleBuildIndex(item)} - > - - {actionLoadingMap[item.id]?.rebuildIndex - ? t("knowledge.rebuilding") - : t("knowledge.rebuildIndex")} - - void handleDelete(item)} - > - - {actionLoadingMap[item.id]?.delete - ? t("knowledge.deleting") - : t("knowledge.delete")} - - - - - -
- ))} - {!loading && result.results.length === 0 ? ( - - - {t("knowledge.emptyFAQ")} - - - ) : null} -
-
-
-
- - { - 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"), }} />
- { - if (!open) { - setEditingItem(null); - } - setDialogOpen(open); - }} - onSubmit={handleSubmit} - /> - { + crudActionState?.onRefresh(); + }} /> ); diff --git a/web/app/dashboard/tickets/page.tsx b/web/app/dashboard/tickets/page.tsx index 413c561..462c25c 100644 --- a/web/app/dashboard/tickets/page.tsx +++ b/web/app/dashboard/tickets/page.tsx @@ -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([]) const [summary, setSummary] = useState(emptySummary) - const [quickView, setQuickView] = useState("all") - const [keyword, setKeyword] = useState("") - const [assigneeId, setAssigneeId] = useState("0") - const [tagId, setTagId] = useState("0") - const [staleHours, setStaleHours] = useState("24") const [assigneeOptions, setAssigneeOptions] = useState([]) const [tagOptions, setTagOptions] = useState([]) - const [loading, setLoading] = useState(false) const [selectedTicketId, setSelectedTicketId] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [savingCreate, setSavingCreate] = useState(false) - const loadSeqRef = useRef(0) + const listReloadRef = useRef<() => Promise>(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( + () => [ + { + 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) => { + 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[]>( + () => [ + { + key: "ticket", + label: t("ticket.columnTicket"), + render: (ticket) => ( +
+
{ticket.title}
+
+ {ticket.ticketNo} + + {ticket.customer?.name || + (ticket.customerId + ? t("ticket.customerFallback", { id: ticket.customerId }) + : t("ticket.noCustomer"))} + + {sourceLabel(ticket.source, t)} + {ticket.channel ? {ticket.channel} : null} +
+ {ticket.tags && ticket.tags.length > 0 ? ( +
+ {ticket.tags.slice(0, 3).map((tag) => ( + + {tag.name} + + ))} + {ticket.tags.length > 3 ? ( + + +{ticket.tags.length - 3} + + ) : null} +
+ ) : null} +
+ ), + }, + { + key: "status", + label: t("ticket.columnStatus"), + className: "w-28", + render: (ticket) => , + }, + { + 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) => ( + + ), + }, + ], + [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 ( - -
- {quickViews.map((view) => ( - - ))} -
- - - - - - } - > - setKeyword(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - void loadData() - } - }} - /> -
- -
-
- -
-
- -
- - - -
- - - - - - {t("ticket.columnTicket")} - {t("ticket.columnStatus")} - {t("ticket.columnAssignee")} - {t("ticket.columnUpdated")} - {t("ticket.columnActions")} - - - - {tickets.length === 0 ? ( - - ) : ( - tickets.map((ticket) => ( - - -
-
{ticket.title}
-
- {ticket.ticketNo} - {ticket.customer?.name || (ticket.customerId ? t("ticket.customerFallback", { id: ticket.customerId }) : t("ticket.noCustomer"))} - {sourceLabel(ticket.source, t)} - {ticket.channel ? {ticket.channel} : null} -
- {ticket.tags && ticket.tags.length > 0 ? ( -
- {ticket.tags.slice(0, 3).map((tag) => ( - - {tag.name} - - ))} - {ticket.tags.length > 3 ? ( - - +{ticket.tags.length - 3} - - ) : null} -
- ) : null} -
-
- - - - - {assigneeLabel(ticket, t)} - - - {ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "-"} - - - - -
- )) - )} -
-
-
+ <> + + filters={filters} + fetchList={fetchList} + columns={columns} + getItemId={(ticket) => ticket.id} + pageSize={50} + renderToolbarActions={(context) => { + listReloadRef.current = context.reload + return ( + <> + + + + ) + }} + labels={{ + refresh: t("ticket.refresh"), + query: t("ticket.query"), + loading: t("ticket.loadingRows"), + empty: t("ticket.emptyRows"), + loadFailed: t("ticket.loadFailed"), + }} + /> listReloadRef.current()} /> -
+ ) } diff --git a/web/app/dashboard/users/page.tsx b/web/app/dashboard/users/page.tsx index d5b3a92..9c5fdbc 100644 --- a/web/app/dashboard/users/page.tsx +++ b/web/app/dashboard/users/page.tsx @@ -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(null) const [actionLoadingId, setActionLoadingId] = useState(null) - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, + const filters = useMemo( + () => [ + { + name: "username", + label: t("user.filterUsername"), + defaultValue: "", + trim: true, + }, + ], + [t], + ) + const fetchList = useCallback( + (query: Record) => + fetchUsers({ + username: typeof query.username === "string" ? query.username : undefined, + page: Number(query.page), + limit: Number(query.limit), + }), + [], + ) + const list = useDashboardPagedList({ + 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) { @@ -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() { setCreatingOpen(true)} disabled={loading}> + @@ -320,24 +311,26 @@ export default function DashboardUsersPage() {
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" />
-
@@ -355,7 +348,7 @@ export default function DashboardUsersPage() { - {result.results.map((item) => ( + {list.result.results.map((item) => (
@@ -455,10 +448,10 @@ export default function DashboardUsersPage() { ))} - {loading || result.results.length === 0 ? ( + {list.loading || list.result.results.length === 0 ? ( diff --git a/web/components/dashboard/crud/dashboard-crud-page.tsx b/web/components/dashboard/crud/dashboard-crud-page.tsx index 3efaf07..5d0be34 100644 --- a/web/components/dashboard/crud/dashboard-crud-page.tsx +++ b/web/components/dashboard/crud/dashboard-crud-page.tsx @@ -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 = DashboardCrudActionRule & { run: (context: DashboardCrudRowActionContext) => Promise | void } +export type DashboardCrudActionState = { + onRefresh: () => void + onCreate: () => void + loading: boolean +} + export type DashboardCrudPageProps = { filters: DashboardCrudFilter[] columns: DashboardCrudColumn[] @@ -142,6 +148,7 @@ export type DashboardCrudPageProps = { getItemId: (item: TItem) => number createItem: (payload: TPayload) => Promise updateItem: (item: TItem, payload: TPayload) => Promise + canEdit?: (item: TItem) => boolean deleteItem?: (item: TItem) => Promise canDelete?: (item: TItem) => boolean deleteConfirm?: false | ConfirmOptions | ((item: TItem) => ConfirmOptions) @@ -156,6 +163,11 @@ export type DashboardCrudPageProps = { 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({ getItemId, createItem, updateItem, + canEdit, deleteItem, canDelete, deleteConfirm, @@ -192,6 +205,11 @@ export function DashboardCrudPage({ renderRowActions, sort, pageSize = 20, + layout = "page", + showToolbar = true, + showToolbarActions = true, + tableShellClassName, + onActionStateChange, labels, }: DashboardCrudPageProps) { const confirm = useConfirm() @@ -231,16 +249,24 @@ export function DashboardCrudPage({ 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({ ))} - {renderRowActions || rowActions.length > 0 || deleteItem ? ( @@ -475,21 +506,23 @@ export function DashboardCrudPage({ ) } - return ( + const content = ( <> - + {showToolbar ? ( - - - + showToolbarActions ? ( + <> + + + + ) : null } > {filters.map((filter) => { @@ -525,86 +558,104 @@ export function DashboardCrudPage({ {labels.query} + ) : null} - - } - > - - - - {sortable ? : null} - {columns.map((column) => ( - - {column.label} - - ))} - - {labels.actions} + + } + > +
+ + + {sortable ? : null} + {columns.map((column) => ( + + {column.label} - - - {sortable ? ( - void handleSortEnd(event)} + ))} + + {labels.actions} + + + + {sortable ? ( + void handleSortEnd(event)} + > + String(getItemId(item)))} + strategy={verticalListSortingStrategy} > - String(getItemId(item)))} - strategy={verticalListSortingStrategy} - > - - {result.results.map((item) => - renderTableRow(item, { - sortable: true, - sortDisabled: sort?.disabled, - }) - )} - {renderStateRow()} - - - - ) : ( - - {result.results.map((item) => renderTableRow(item))} - {renderStateRow()} - - )} -
-
-
- {form ? ( - - ) : ( - renderEditDialog?.({ - open: dialogOpen, - saving, - item: editingItem, - itemId: editingItem ? getItemId(editingItem) : null, - onOpenChange: handleDialogOpenChange, - onSubmit: handleSubmit, - }) - )} + + {result.results.map((item) => + renderTableRow(item, { + sortable: true, + sortDisabled: sort?.disabled, + }) + )} + {renderStateRow()} + + + + ) : ( + + {result.results.map((item) => renderTableRow(item))} + {renderStateRow()} + + )} + + + + ) + + const dialog = form ? ( + + ) : ( + renderEditDialog?.({ + open: dialogOpen, + saving, + item: editingItem, + itemId: editingItem ? getItemId(editingItem) : null, + onOpenChange: handleDialogOpenChange, + onSubmit: handleSubmit, + }) + ) + + if (layout === "fragment") { + return ( + <> + {content} + {dialog} + + ) + } + + return ( + <> + {content} + {dialog} ) } diff --git a/web/components/dashboard/crud/index.ts b/web/components/dashboard/crud/index.ts index 5683d84..11a7222 100644 --- a/web/components/dashboard/crud/index.ts +++ b/web/components/dashboard/crud/index.ts @@ -11,6 +11,7 @@ export { export { useDashboardCrudFilters } from "./use-dashboard-crud-filters" export type { DashboardCrudColumn, + DashboardCrudActionState, DashboardCrudDialogProps, DashboardCrudFilter, DashboardCrudPageProps, diff --git a/web/components/dashboard/crud/use-dashboard-crud-filters.ts b/web/components/dashboard/crud/use-dashboard-crud-filters.ts index 898f018..fde266b 100644 --- a/web/components/dashboard/crud/use-dashboard-crud-filters.ts +++ b/web/components/dashboard/crud/use-dashboard-crud-filters.ts @@ -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, } } diff --git a/web/components/dashboard/list/dashboard-list-page.tsx b/web/components/dashboard/list/dashboard-list-page.tsx index bb97922..0d3aa6b 100644 --- a/web/components/dashboard/list/dashboard-list-page.tsx +++ b/web/components/dashboard/list/dashboard-list-page.tsx @@ -54,6 +54,7 @@ export type DashboardListRenderContext = { result: DashboardCrudPageResult loading: boolean reload: () => Promise + resetFilters: () => void } export type DashboardListPageProps = { @@ -104,6 +105,7 @@ export function DashboardListPage({ result: list.result, loading: list.loading, reload: list.loadData, + resetFilters: list.resetFilters, } function handleFilterKeyDown(event: KeyboardEvent) { diff --git a/web/components/dashboard/list/index.ts b/web/components/dashboard/list/index.ts index 6f8eeb9..0e295d6 100644 --- a/web/components/dashboard/list/index.ts +++ b/web/components/dashboard/list/index.ts @@ -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" diff --git a/web/components/dashboard/list/use-dashboard-paged-list.ts b/web/components/dashboard/list/use-dashboard-paged-list.ts index fbfef58..3af6459 100644 --- a/web/components/dashboard/list/use-dashboard-paged-list.ts +++ b/web/components/dashboard/list/use-dashboard-paged-list.ts @@ -39,7 +39,14 @@ export function useDashboardPagedList({ `${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({ setPage(1) } + function resetDraftFilters() { + resetFilters() + setPage(1) + } + return { draftFilters, setDraftFilter, @@ -115,5 +127,6 @@ export function useDashboardPagedList({ loadData, handlePageChange, handleLimitChange, + resetFilters: resetDraftFilters, } } diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 29f4de6..47247db 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -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.", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index a1144f8..9a8b020 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -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已删除",