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