refactor: replace DashboardPage and DashboardTableShell with DashboardListPage for notifications and permissions

- Updated DashboardNotificationsPage to utilize DashboardListPage for improved structure and functionality.
- Simplified state management and data fetching in DashboardNotificationsPage.
- Refactored DashboardPermissionsPage to use DashboardListPage, enhancing filter and pagination handling.
- Introduced DashboardListPage component to standardize list rendering with filters and pagination.
- Added utility functions for managing filters and pagination in the new DashboardListPage component.
- Improved code readability and maintainability by consolidating common logic into reusable components.
This commit is contained in:
mlogclub
2026-05-28 09:36:48 +08:00
parent b3f5824fd6
commit d64b0c36f3
10 changed files with 815 additions and 679 deletions
+89 -172
View File
@@ -1,26 +1,18 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react" import { SearchIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { import { DashboardListPage } from "@/components/dashboard/list"
DashboardPage,
DashboardTableShell,
DashboardToolbar,
} from "@/components/dashboard-page"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } 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 { AgentRunLogDetailDialog } from "./_components/detail" import { AgentRunLogDetailDialog } from "./_components/detail"
import { import {
fetchAgentRunLogs, fetchAgentRunLogs,
fetchAIAgentsAll, fetchAIAgentsAll,
type AIAgent, type AIAgent,
type AgentRunLog, type AgentRunLog,
type PageResult,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
@@ -100,27 +92,8 @@ function actionBadgeVariant(action: string) {
export default function DashboardAgentRunLogsPage() { export default function DashboardAgentRunLogsPage() {
const t = useI18n() const t = useI18n()
const [keywordInput, setKeywordInput] = useState("")
const [plannedActionInput, setPlannedActionInput] = useState("all")
const [finalActionInput, setFinalActionInput] = useState("all")
const [finalStatusInput, setFinalStatusInput] = useState("all")
const [hitlStatusInput, setHitlStatusInput] = useState("all")
const [aiAgentIdInput, setAiAgentIdInput] = useState("all")
const [keyword, setKeyword] = useState("")
const [plannedAction, setPlannedAction] = useState("all")
const [finalAction, setFinalAction] = useState("all")
const [finalStatus, setFinalStatus] = useState("all")
const [hitlStatus, setHitlStatus] = useState("all")
const [aiAgentId, setAiAgentId] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [activeLogId, setActiveLogId] = useState<number | null>(null) const [activeLogId, setActiveLogId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<AgentRunLog>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const [aiAgents, setAiAgents] = useState<AIAgent[]>([]) const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
const actionOptions = useMemo(() => getActionOptions(t), [t]) const actionOptions = useMemo(() => getActionOptions(t), [t])
const finalStatusOptions = useMemo(() => getFinalStatusOptions(t), [t]) const finalStatusOptions = useMemo(() => getFinalStatusOptions(t), [t])
@@ -137,31 +110,6 @@ export default function DashboardAgentRunLogsPage() {
[aiAgents, t] [aiAgents, t]
) )
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchAgentRunLogs({
userMessage: keyword.trim() || undefined,
plannedAction: plannedAction === "all" ? undefined : plannedAction,
finalAction: finalAction === "all" ? undefined : finalAction,
finalStatus: finalStatus === "all" ? undefined : finalStatus,
hitlStatus: hitlStatus === "all" ? undefined : hitlStatus,
aiAgentId: aiAgentId === "all" ? undefined : aiAgentId,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : t("agentRunLog.loadFailed"))
} finally {
setLoading(false)
}
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction, t])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => { useEffect(() => {
async function loadAIAgents() { async function loadAIAgents() {
try { try {
@@ -174,122 +122,84 @@ export default function DashboardAgentRunLogsPage() {
void loadAIAgents() void loadAIAgents()
}, [t]) }, [t])
function applyFilters() {
setKeyword(keywordInput)
setPlannedAction(plannedActionInput)
setFinalAction(finalActionInput)
setFinalStatus(finalStatusInput)
setHitlStatus(hitlStatusInput)
setAiAgentId(aiAgentIdInput)
setPage(1)
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
return ( return (
<> <>
<DashboardPage> <DashboardListPage<AgentRunLog>
<DashboardToolbar filters={[
actions={ {
<Button name: "userMessage",
variant="outline" label: t("agentRunLog.filterUserMessage"),
onClick={() => void loadData()} placeholder: t("agentRunLog.filterUserMessage"),
disabled={loading} defaultValue: "",
className="w-full xl:w-auto" trim: true,
> className: "min-w-0",
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> inputClassName: "pl-9",
{t("agentRunLog.refresh")} icon: <SearchIcon className="size-4" />,
</Button> },
} {
> name: "plannedAction",
<div className="relative min-w-0"> label: t("agentRunLog.plannedAction"),
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> type: "select",
<Input defaultValue: "all",
value={keywordInput} allValue: "all",
onChange={(event) => setKeywordInput(event.target.value)} options: actionOptions,
onKeyDown={handleFilterKeyDown} placeholder: t("agentRunLog.plannedAction"),
placeholder={t("agentRunLog.filterUserMessage")} searchPlaceholder: t("agentRunLog.searchAction"),
className="pl-9" emptyText: t("agentRunLog.emptyAction"),
/> className: "min-w-0",
</div> },
<div className="min-w-0"> {
<OptionCombobox name: "finalAction",
value={plannedActionInput} label: t("agentRunLog.finalAction"),
options={actionOptions} type: "select",
placeholder={t("agentRunLog.plannedAction")} defaultValue: "all",
searchPlaceholder={t("agentRunLog.searchAction")} allValue: "all",
emptyText={t("agentRunLog.emptyAction")} options: actionOptions,
onChange={(value) => setPlannedActionInput(value || "all")} placeholder: t("agentRunLog.finalAction"),
/> searchPlaceholder: t("agentRunLog.searchAction"),
</div> emptyText: t("agentRunLog.emptyAction"),
<div className="min-w-0"> className: "min-w-0",
<OptionCombobox },
value={finalActionInput} {
options={actionOptions} name: "finalStatus",
placeholder={t("agentRunLog.finalAction")} label: t("agentRunLog.finalStatus"),
searchPlaceholder={t("agentRunLog.searchAction")} type: "select",
emptyText={t("agentRunLog.emptyAction")} defaultValue: "all",
onChange={(value) => setFinalActionInput(value || "all")} allValue: "all",
/> options: finalStatusOptions,
</div> placeholder: t("agentRunLog.finalStatus"),
<div className="min-w-0"> searchPlaceholder: t("agentRunLog.searchStatus"),
<OptionCombobox emptyText: t("agentRunLog.emptyStatus"),
value={finalStatusInput} className: "min-w-0",
options={finalStatusOptions} },
placeholder={t("agentRunLog.finalStatus")} {
searchPlaceholder={t("agentRunLog.searchStatus")} name: "hitlStatus",
emptyText={t("agentRunLog.emptyStatus")} label: t("agentRunLog.hitlStatus"),
onChange={(value) => setFinalStatusInput(value || "all")} type: "select",
/> defaultValue: "all",
</div> allValue: "all",
<div className="min-w-0"> options: hitlStatusOptions,
<OptionCombobox placeholder: t("agentRunLog.hitlStatus"),
value={hitlStatusInput} searchPlaceholder: t("agentRunLog.searchHitl"),
options={hitlStatusOptions} emptyText: t("agentRunLog.emptyStatus"),
placeholder={t("agentRunLog.hitlStatus")} className: "min-w-0",
searchPlaceholder={t("agentRunLog.searchHitl")} },
emptyText={t("agentRunLog.emptyStatus")} {
onChange={(value) => setHitlStatusInput(value || "all")} name: "aiAgentId",
/> label: t("agentRunLog.selectAgent"),
</div> type: "select",
<div className="min-w-0"> defaultValue: "all",
<OptionCombobox allValue: "all",
value={aiAgentIdInput} options: aiAgentOptions,
options={aiAgentOptions} placeholder: t("agentRunLog.selectAgent"),
placeholder={t("agentRunLog.selectAgent")} searchPlaceholder: t("agentRunLog.searchAgent"),
searchPlaceholder={t("agentRunLog.searchAgent")} emptyText: t("agentRunLog.emptyAgent"),
emptyText={t("agentRunLog.emptyAgent")} className: "min-w-0",
onChange={(value) => setAiAgentIdInput(value || "all")} },
/> ]}
</div> fetchList={fetchAgentRunLogs}
<Button variant="outline" onClick={applyFilters} disabled={loading} className="w-full xl:w-auto"> renderContent={({ result, loading }) =>
<SearchIcon /> !loading && result.results.length === 0 ? (
{t("agentRunLog.query")}
</Button>
</DashboardToolbar>
<DashboardTableShell
pagination={
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={setPage}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
}
>
{!loading && result.results.length === 0 ? (
<div className="py-14 text-center text-sm text-muted-foreground"> <div className="py-14 text-center text-sm text-muted-foreground">
{t("agentRunLog.emptyRows")} {t("agentRunLog.emptyRows")}
</div> </div>
@@ -388,9 +298,16 @@ export default function DashboardAgentRunLogsPage() {
))} ))}
</div> </div>
</div> </div>
)} )
</DashboardTableShell> }
</DashboardPage> labels={{
refresh: t("agentRunLog.refresh"),
query: t("agentRunLog.query"),
loading: t("agentRunLog.loadingRows"),
empty: t("agentRunLog.emptyRows"),
loadFailed: t("agentRunLog.loadFailed"),
}}
/>
<AgentRunLogDetailDialog <AgentRunLogDetailDialog
open={detailOpen} open={detailOpen}
logId={activeLogId} logId={activeLogId}
@@ -1,13 +1,11 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { useMemo, useState } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react" import { SearchIcon } from "lucide-react"
import { toast } from "sonner"
import { import {
fetchKnowledgeRetrieveLogs, fetchKnowledgeRetrieveLogs,
type KnowledgeRetrieveLog, type KnowledgeRetrieveLog,
type PageResult,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { import {
KnowledgeAnswerStatus, KnowledgeAnswerStatus,
@@ -22,19 +20,8 @@ import {
} from "@/lib/knowledge-i18n" } from "@/lib/knowledge-i18n"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
import { ListPagination } from "@/components/list-pagination" import { DashboardListPage } from "@/components/dashboard/list"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { RetrieveLogDetailDrawer } from "./retrieve-log-detail" import { RetrieveLogDetailDrawer } from "./retrieve-log-detail"
type RetrieveLogListProps = { type RetrieveLogListProps = {
@@ -125,158 +112,108 @@ export function RetrieveLogList({
knowledgeBaseId, knowledgeBaseId,
}: RetrieveLogListProps) { }: RetrieveLogListProps) {
const t = useI18n() const t = useI18n()
const [questionInput, setQuestionInput] = useState("")
const [question, setQuestion] = useState("")
const [channel, setChannel] = useState("all")
const [scene, setScene] = useState("all")
const [answerStatus, setAnswerStatus] = useState("all")
const [chunkProvider, setChunkProvider] = useState("all")
const [rerankEnabled, setRerankEnabled] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(false)
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [selectedLogId, setSelectedLogId] = useState<number | null>(null) const [selectedLogId, setSelectedLogId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<KnowledgeRetrieveLog>>({ const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState<number | null>(null)
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const channelOptions = useMemo(() => getChannelOptions(t), [t]) const channelOptions = useMemo(() => getChannelOptions(t), [t])
const sceneOptions = useMemo(() => getSceneOptions(t), [t]) const sceneOptions = useMemo(() => getSceneOptions(t), [t])
const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t]) const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t])
const providerOptions = useMemo(() => getProviderOptions(t), [t]) const providerOptions = useMemo(() => getProviderOptions(t), [t])
const rerankOptions = useMemo(() => getRerankOptions(t), [t]) const rerankOptions = useMemo(() => getRerankOptions(t), [t])
const loadData = useCallback(async () => {
if (!knowledgeBaseId) {
setResult({ results: [], page: { page: 1, limit: 20, total: 0 } })
setLoading(false)
return
}
setLoading(true)
try {
const data = await fetchKnowledgeRetrieveLogs({
knowledgeBaseId,
question: question.trim() || undefined,
channel: channel === "all" ? undefined : channel,
scene: scene === "all" ? undefined : scene,
answerStatus: answerStatus === "all" ? undefined : Number(answerStatus),
chunkProvider: chunkProvider === "all" ? undefined : chunkProvider,
rerankEnabled: rerankEnabled === "all" ? undefined : Number(rerankEnabled),
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogsFailed"))
} finally {
setLoading(false)
}
}, [answerStatus, channel, chunkProvider, knowledgeBaseId, limit, page, question, rerankEnabled, scene, t])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
setPage(1)
setSelectedLogId(null)
setDetailOpen(false)
}, [knowledgeBaseId])
const emptyStateText = useMemo(() => {
if (!knowledgeBaseId) {
return t("knowledge.selectBaseForLogs")
}
if (loading) {
return t("knowledge.loadingRetrieveLogs")
}
return t("knowledge.emptyRetrieveLogs")
}, [knowledgeBaseId, loading, t])
function applyFilters() {
setQuestion(questionInput)
setPage(1)
}
function handleQuestionKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
function handleOpenDetail(logId: number) { function handleOpenDetail(logId: number) {
setSelectedLogId(logId) setSelectedLogId(logId)
setSelectedKnowledgeBaseId(knowledgeBaseId)
setDetailOpen(true) setDetailOpen(true)
} }
if (!knowledgeBaseId) { if (!knowledgeBaseId) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{emptyStateText} {t("knowledge.selectBaseForLogs")}
</div> </div>
) )
} }
return ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col px-6 py-4">
<div className="flex flex-col gap-3 border-b bg-background px-6 py-2"> <DashboardListPage<KnowledgeRetrieveLog>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.8fr)_repeat(5,minmax(0,0.8fr))_auto]"> layout="fragment"
<div className="relative"> filters={[
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> {
<Input name: "question",
value={questionInput} label: t("knowledge.filterQuestion"),
onChange={(event) => setQuestionInput(event.target.value)} placeholder: t("knowledge.filterQuestion"),
onKeyDown={handleQuestionKeyDown} defaultValue: "",
placeholder={t("knowledge.filterQuestion")} trim: true,
className="pl-9" className: "min-w-0 xl:min-w-[280px]",
/> inputClassName: "pl-9",
</div> icon: <SearchIcon className="size-4" />,
<OptionCombobox value={channel} options={channelOptions} placeholder={t("knowledge.selectChannel")} onChange={setChannel} /> },
<OptionCombobox value={scene} options={sceneOptions} placeholder={t("knowledge.selectScene")} onChange={setScene} /> {
<OptionCombobox value={answerStatus} options={answerStatusOptions} placeholder={t("knowledge.answerStatus")} onChange={setAnswerStatus} /> name: "channel",
<OptionCombobox value={chunkProvider} options={providerOptions} placeholder={t("knowledge.chunkStrategy")} onChange={setChunkProvider} /> label: t("knowledge.selectChannel"),
<OptionCombobox value={rerankEnabled} options={rerankOptions} placeholder="Rerank" onChange={setRerankEnabled} /> type: "select",
<Button onClick={applyFilters}>{t("knowledge.filter")}</Button> defaultValue: "all",
</div> allValue: "all",
</div> options: channelOptions,
placeholder: t("knowledge.selectChannel"),
<div className="min-h-0 flex-1 overflow-auto px-6 py-4"> },
<div className="overflow-hidden rounded-xl border"> {
<Table> name: "scene",
<TableHeader> label: t("knowledge.selectScene"),
<TableRow> type: "select",
<TableHead className="w-42">{t("knowledge.time")}</TableHead> defaultValue: "all",
<TableHead>{t("knowledge.question")}</TableHead> allValue: "all",
<TableHead className="w-28">{t("knowledge.answerStatus")}</TableHead> options: sceneOptions,
<TableHead className="w-24 text-right">{t("knowledge.hitCount")}</TableHead> placeholder: t("knowledge.selectScene"),
<TableHead className="w-24 text-right">TopScore</TableHead> },
<TableHead className="w-28">Provider</TableHead> {
<TableHead className="w-24">Rerank</TableHead> name: "answerStatus",
<TableHead className="w-24 text-right">{t("knowledge.citations")}</TableHead> label: t("knowledge.answerStatus"),
<TableHead className="w-28 text-right">{t("knowledge.duration")}</TableHead> type: "select",
</TableRow> defaultValue: "all",
</TableHeader> allValue: "all",
<TableBody> valueType: "number",
{result.results.length === 0 ? ( options: answerStatusOptions,
<TableRow> placeholder: t("knowledge.answerStatus"),
<TableCell colSpan={9} className="h-32 text-center text-muted-foreground"> },
{emptyStateText} {
</TableCell> name: "chunkProvider",
</TableRow> label: t("knowledge.chunkStrategy"),
) : ( type: "select",
result.results.map((item) => ( defaultValue: "all",
<TableRow allValue: "all",
key={item.id} options: providerOptions,
className="cursor-pointer" placeholder: t("knowledge.chunkStrategy"),
onClick={() => handleOpenDetail(item.id)} },
> {
<TableCell className="text-xs text-muted-foreground">{formatDateTime(item.createdAt)}</TableCell> name: "rerankEnabled",
<TableCell> label: "Rerank",
type: "select",
defaultValue: "all",
allValue: "all",
valueType: "number",
options: rerankOptions,
placeholder: "Rerank",
},
]}
fetchList={(query) => fetchKnowledgeRetrieveLogs({ knowledgeBaseId, ...query })}
getItemId={(item) => item.id}
getRowClassName={() => "cursor-pointer"}
onRowClick={(item) => handleOpenDetail(item.id)}
columns={[
{
key: "time",
label: t("knowledge.time"),
className: "w-42 text-xs text-muted-foreground",
render: (item) => formatDateTime(item.createdAt),
},
{
key: "question",
label: t("knowledge.question"),
render: (item) => (
<div className="space-y-1"> <div className="space-y-1">
<div className="line-clamp-2 font-medium">{item.question || "-"}</div> <div className="line-clamp-2 font-medium">{item.question || "-"}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@@ -285,45 +222,78 @@ export function RetrieveLogList({
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null} {item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
</div> </div>
</div> </div>
</TableCell> ),
<TableCell> },
{
key: "answerStatus",
label: t("knowledge.answerStatus"),
className: "w-28",
render: (item) => (
<Badge variant={getAnswerStatusVariant(item.answerStatus)}> <Badge variant={getAnswerStatusVariant(item.answerStatus)}>
{answerStatusLabel(item.answerStatus, item.answerStatusName, t)} {answerStatusLabel(item.answerStatus, item.answerStatusName, t)}
</Badge> </Badge>
</TableCell> ),
<TableCell className="text-right">{item.hitCount}</TableCell> },
<TableCell className="text-right font-mono text-xs">{item.topScore.toFixed(4)}</TableCell> {
<TableCell>{item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-"}</TableCell> key: "hitCount",
<TableCell>{item.rerankEnabled ? `${t("knowledge.yes")} (${item.rerankLimit})` : t("knowledge.no")}</TableCell> label: t("knowledge.hitCount"),
<TableCell className="text-right">{item.citationCount}</TableCell> className: "w-24 text-right",
<TableCell className="text-right">{item.latencyMs} ms</TableCell> render: (item) => item.hitCount,
</TableRow> },
)) {
)} key: "topScore",
</TableBody> label: "TopScore",
</Table> className: "w-24 text-right font-mono text-xs",
</div> render: (item) => item.topScore.toFixed(4),
</div> },
{
<div className="border-t px-6 py-4"> key: "provider",
<ListPagination label: "Provider",
page={result.page.page} className: "w-28",
total={result.page.total} render: (item) =>
limit={result.page.limit} item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-",
loading={loading} },
onPageChange={setPage} {
onLimitChange={(nextLimit) => { key: "rerank",
setLimit(nextLimit) label: "Rerank",
setPage(1) className: "w-24",
}} render: (item) =>
/> item.rerankEnabled
</div> ? `${t("knowledge.yes")} (${item.rerankLimit})`
: t("knowledge.no"),
},
{
key: "citations",
label: t("knowledge.citations"),
className: "w-24 text-right",
render: (item) => item.citationCount,
},
{
key: "duration",
label: t("knowledge.duration"),
className: "w-28 text-right",
render: (item) => `${item.latencyMs} ms`,
},
]}
labels={{
query: t("knowledge.filter"),
loading: t("knowledge.loadingRetrieveLogs"),
empty: t("knowledge.emptyRetrieveLogs"),
loadFailed: t("knowledge.loadRetrieveLogsFailed"),
}}
/>
</div> </div>
<RetrieveLogDetailDrawer <RetrieveLogDetailDrawer
open={detailOpen} open={detailOpen && selectedKnowledgeBaseId === knowledgeBaseId}
retrieveLogId={selectedLogId} retrieveLogId={selectedLogId}
onOpenChange={setDetailOpen} onOpenChange={(open) => {
setDetailOpen(open)
if (!open) {
setSelectedLogId(null)
setSelectedKnowledgeBaseId(null)
}
}}
/> />
</> </>
) )
+54 -118
View File
@@ -1,16 +1,10 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react" import { BellIcon, CheckCheckIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { import { DashboardListPage } from "@/components/dashboard/list"
DashboardPage,
DashboardTableShell,
DashboardToolbar,
} from "@/components/dashboard-page"
import { ListPagination } from "@/components/list-pagination"
import { useNotifications } from "@/components/notification-provider" import { useNotifications } from "@/components/notification-provider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -21,51 +15,18 @@ import {
type NotificationItem, type NotificationItem,
type NotificationReadStatus, type NotificationReadStatus,
} from "@/lib/api/notification" } from "@/lib/api/notification"
import type { PageResult } from "@/lib/api/admin" import { formatDateTime } from "@/lib/utils"
import { cn, formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
export default function DashboardNotificationsPage() { export default function DashboardNotificationsPage() {
const t = useI18n() const t = useI18n()
const router = useRouter() const router = useRouter()
const { refreshUnreadCount } = useNotifications() const { refreshUnreadCount } = useNotifications()
const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all") const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
const [page, setPage] = useState(1) { value: "all", label: t("notification.all") },
const [limit, setLimit] = useState(20) { value: "unread", label: t("notification.unread") },
const [loading, setLoading] = useState(true) { value: "read", label: t("notification.read") },
const [actionLoading, setActionLoading] = useState(false) ]
const [result, setResult] = useState<PageResult<NotificationItem>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const readStatusOptions = useMemo<Array<{ value: NotificationReadStatus; label: string }>>(
() => [
{ value: "all", label: t("notification.all") },
{ value: "unread", label: t("notification.unread") },
{ value: "read", label: t("notification.read") },
],
[t]
)
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchNotifications({
page,
limit,
readStatus,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : t("notification.loadFailed"))
} finally {
setLoading(false)
}
}, [limit, page, readStatus, t])
useEffect(() => {
void loadData()
}, [loadData])
async function openNotification(item: NotificationItem) { async function openNotification(item: NotificationItem) {
try { try {
@@ -81,81 +42,44 @@ export default function DashboardNotificationsPage() {
} }
} }
async function handleMarkAllRead() { async function markAllRead(reload: () => Promise<void>) {
setActionLoading(true)
try { try {
await markAllNotificationsRead() await markAllNotificationsRead()
await refreshUnreadCount() await refreshUnreadCount()
await loadData() await reload()
toast.success(t("notification.markAllReadSuccess")) toast.success(t("notification.markAllReadSuccess"))
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : t("notification.markAllReadFailed")) toast.error(
} finally { error instanceof Error ? error.message : t("notification.markAllReadFailed")
setActionLoading(false) )
} }
} }
function handleStatusChange(nextStatus: NotificationReadStatus) {
setReadStatus(nextStatus)
setPage(1)
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return
}
setPage(nextPage)
}
function handleLimitChange(nextLimit: number) {
setLimit(nextLimit)
setPage(1)
}
return ( return (
<DashboardPage> <DashboardListPage<NotificationItem>
<DashboardToolbar filters={[
actions={ {
<> name: "readStatus",
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> label: t("notification.all"),
<RefreshCwIcon className={cn(loading && "animate-spin")} /> type: "segment",
{t("notification.refresh")} defaultValue: "all",
</Button> allValue: "all",
<Button options: readStatusOptions,
variant="outline" },
onClick={() => void handleMarkAllRead()} ]}
disabled={actionLoading || result.page.total === 0} fetchList={fetchNotifications}
> renderToolbarActions={({ result, reload }) => (
<CheckCheckIcon /> <Button
{t("notification.markAllRead")} variant="outline"
</Button> onClick={() => void markAllRead(reload)}
</> disabled={result.page.total === 0}
} >
> <CheckCheckIcon />
{readStatusOptions.map((option) => ( {t("notification.markAllRead")}
<Button </Button>
key={option.value} )}
variant={option.value === readStatus ? "default" : "outline"} renderContent={({ result, loading }) =>
onClick={() => handleStatusChange(option.value)} result.results.length > 0 ? (
>
{option.label}
</Button>
))}
</DashboardToolbar>
<DashboardTableShell
pagination={
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
}
>
{result.results.length > 0 ? (
<div className="divide-y"> <div className="divide-y">
{result.results.map((item) => { {result.results.map((item) => {
const unread = !item.readAt const unread = !item.readAt
@@ -168,8 +92,14 @@ export default function DashboardNotificationsPage() {
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<BellIcon className="size-4 text-muted-foreground" /> <BellIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{item.title || t("notification.fallbackTitle")}</span> <span className="font-medium">
{unread ? <Badge>{t("notification.unread")}</Badge> : <Badge variant="outline">{t("notification.read")}</Badge>} {item.title || t("notification.fallbackTitle")}
</span>
{unread ? (
<Badge>{t("notification.unread")}</Badge>
) : (
<Badge variant="outline">{t("notification.read")}</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">
{formatDateTime(item.createdAt)} {formatDateTime(item.createdAt)}
</span> </span>
@@ -185,8 +115,14 @@ export default function DashboardNotificationsPage() {
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground"> <div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
{loading ? t("notification.loading") : t("notification.empty")} {loading ? t("notification.loading") : t("notification.empty")}
</div> </div>
)} )
</DashboardTableShell> }
</DashboardPage> labels={{
refresh: t("notification.refresh"),
loading: t("notification.loading"),
empty: t("notification.empty"),
loadFailed: t("notification.loadFailed"),
}}
/>
) )
} }
+102 -209
View File
@@ -1,33 +1,10 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { KeyRoundIcon, RouteIcon, SearchIcon } from "lucide-react"
import { KeyRoundIcon, RefreshCwIcon, RouteIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import { import { DashboardListPage } from "@/components/dashboard/list"
DashboardPage,
DashboardTableShell,
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { fetchPermissions, type AdminPermission } from "@/lib/api/admin"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
fetchPermissions,
type AdminPermission,
type PageResult,
} from "@/lib/api/admin"
import { Status } from "@/lib/generated/enums" import { Status } from "@/lib/generated/enums"
import { useAppLocale, useI18n } from "@/i18n/provider" import { useAppLocale, useI18n } from "@/i18n/provider"
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n" import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n"
@@ -35,191 +12,107 @@ import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permissi
export default function DashboardPermissionsPage() { export default function DashboardPermissionsPage() {
const t = useI18n() const t = useI18n()
const { locale } = useAppLocale() const { locale } = useAppLocale()
const [keywordInput, setKeywordInput] = useState("") const listStatusOptions = [
const [groupNameInput, setGroupNameInput] = useState("") { value: "all", label: t("status.all") },
const [statusFilterInput, setStatusFilterInput] = useState("all") { value: String(Status.Ok), label: t("status.ok") },
const [keyword, setKeyword] = useState("") { value: String(Status.Disabled), label: t("status.disabled") },
const [groupName, setGroupName] = useState("") { value: String(Status.Deleted), label: t("status.deleted") },
const [statusFilter, setStatusFilter] = useState("all") ]
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [result, setResult] = useState<PageResult<AdminPermission>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const listStatusOptions = useMemo(
() => [
{ value: "all", label: t("status.all") },
{ value: String(Status.Ok), label: t("status.ok") },
{ value: String(Status.Disabled), label: t("status.disabled") },
{ value: String(Status.Deleted), label: t("status.deleted") },
],
[t]
)
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchPermissions({
keyword: keyword.trim() || undefined,
groupName: groupName.trim() || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : t("permission.loadFailed"))
} finally {
setLoading(false)
}
}, [groupName, keyword, limit, page, statusFilter, t])
useEffect(() => {
void loadData()
}, [loadData])
function applyFilters() {
setKeyword(keywordInput)
setGroupName(groupNameInput)
setStatusFilter(statusFilterInput)
setPage(1)
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return
}
setPage(nextPage)
}
return ( return (
<DashboardPage> <DashboardListPage<AdminPermission>
<DashboardToolbar filters={[
actions={ {
<Button variant="outline" onClick={() => void loadData()} disabled={loading}> name: "keyword",
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> label: t("permission.filterKeyword"),
{t("permission.refresh")} placeholder: t("permission.filterKeyword"),
</Button> defaultValue: "",
} trim: true,
> className: "w-full sm:w-72",
<div className="relative w-full sm:w-72"> inputClassName: "pl-9",
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" /> icon: <SearchIcon className="size-4" />,
<Input },
value={keywordInput} {
onChange={(event) => setKeywordInput(event.target.value)} name: "groupName",
onKeyDown={handleFilterKeyDown} label: t("permission.filterGroup"),
placeholder={t("permission.filterKeyword")} placeholder: t("permission.filterGroup"),
className="pl-9" defaultValue: "",
/> trim: true,
</div> className: "w-full sm:w-44",
<Input },
value={groupNameInput} {
onChange={(event) => setGroupNameInput(event.target.value)} name: "status",
onKeyDown={handleFilterKeyDown} label: t("status.all"),
placeholder={t("permission.filterGroup")} type: "select",
className="w-full sm:w-44" defaultValue: "all",
/> allValue: "all",
<div className="w-full sm:w-36"> options: listStatusOptions,
<OptionCombobox className: "w-full sm:w-36",
value={statusFilterInput} },
onChange={setStatusFilterInput} ]}
placeholder={t("status.all")} fetchList={fetchPermissions}
options={[...listStatusOptions]} getItemId={(item) => item.id}
/> columns={[
</div> {
<Button variant="outline" onClick={applyFilters} disabled={loading}> key: "permission",
<SearchIcon /> label: t("permission.columnPermission"),
{t("permission.query")} render: (item) => (
</Button> <div className="flex items-center gap-3">
</DashboardToolbar> <div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<DashboardTableShell <KeyRoundIcon className="size-4" />
pagination={ </div>
<ListPagination <div>
page={result.page.page} <div className="font-medium">
total={result.page.total} {getPermissionDisplayName(item.code, item.name, locale)}
limit={limit} </div>
loading={loading} <div className="text-xs text-muted-foreground">{item.type}</div>
onPageChange={handlePageChange} </div>
onLimitChange={(nextLimit) => { </div>
setLimit(nextLimit) ),
setPage(1) },
}} {
/> key: "code",
} label: t("permission.columnCode"),
> render: (item) => <Badge variant="outline">{item.code}</Badge>,
<Table> },
<TableHeader className="bg-muted/40"> {
<TableRow> key: "group",
<TableHead>{t("permission.columnPermission")}</TableHead> label: t("permission.columnGroup"),
<TableHead>{t("permission.columnCode")}</TableHead> render: (item) => getPermissionGroupName(item.groupName, locale),
<TableHead>{t("permission.columnGroup")}</TableHead> },
<TableHead>{t("permission.columnApi")}</TableHead> {
<TableHead>{t("permission.columnStatus")}</TableHead> key: "api",
</TableRow> label: t("permission.columnApi"),
</TableHeader> render: (item) => (
<TableBody> <div className="flex items-start gap-2">
{result.results.map((item) => ( <Badge variant="secondary">{item.method || "ANY"}</Badge>
<TableRow key={item.id}> <div className="text-sm text-muted-foreground">
<TableCell> <div className="flex items-center gap-1">
<div className="flex items-center gap-3"> <RouteIcon className="size-3.5" />
<div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground"> {item.apiPath || "-"}
<KeyRoundIcon className="size-4" /> </div>
</div> </div>
<div> </div>
<div className="font-medium"> ),
{getPermissionDisplayName(item.code, item.name, locale)} },
</div> {
<div className="text-xs text-muted-foreground">{item.type}</div> key: "status",
</div> label: t("permission.columnStatus"),
</div> render: (item) => (
</TableCell> <Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
<TableCell> {getStatusLabel(item.status, t)}
<Badge variant="outline">{item.code}</Badge> </Badge>
</TableCell> ),
<TableCell>{getPermissionGroupName(item.groupName, locale)}</TableCell> },
<TableCell> ]}
<div className="flex items-start gap-2"> labels={{
<Badge variant="secondary">{item.method || "ANY"}</Badge> refresh: t("permission.refresh"),
<div className="text-sm text-muted-foreground"> query: t("permission.query"),
<div className="flex items-center gap-1"> loading: t("permission.loading"),
<RouteIcon className="size-3.5" /> empty: t("permission.empty"),
{item.apiPath || "-"} loadFailed: t("permission.loadFailed"),
</div> }}
</div> />
</div>
</TableCell>
<TableCell>
<Badge
variant={item.status === Status.Ok ? "secondary" : "outline"}
>
{getStatusLabel(item.status, t)}
</Badge>
</TableCell>
</TableRow>
))}
{loading || result.results.length === 0 ? (
<DashboardTableStateRow
colSpan={5}
loading={loading}
loadingText={t("permission.loading")}
emptyText={t("permission.empty")}
/>
) : null}
</TableBody>
</Table>
</DashboardTableShell>
</DashboardPage>
) )
} }
@@ -209,6 +209,12 @@ export function DashboardCrudPage<TItem, TPayload>({
) )
const { draftFilters, appliedFilters, setDraftFilter, applyFilters } = const { draftFilters, appliedFilters, setDraftFilter, applyFilters } =
useDashboardCrudFilters(filters) useDashboardCrudFilters(filters)
const filtersKey = filters
.map(
(filter) =>
`${filter.name}:${String(filter.defaultValue)}:${String(filter.allValue)}:${filter.trim ? "1" : "0"}:${filter.valueType ?? ""}`
)
.join("|")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [limit, setLimit] = useState(pageSize) const [limit, setLimit] = useState(pageSize)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -238,7 +244,8 @@ export function DashboardCrudPage<TItem, TPayload>({
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [appliedFilters, fetchList, filters, labels.loadFailed, limit, page]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedFilters, fetchList, filtersKey, labels.loadFailed, limit, page])
useEffect(() => { useEffect(() => {
void loadData() void loadData()
+6
View File
@@ -1,5 +1,9 @@
export { DashboardCrudPage } from "./dashboard-crud-page" export { DashboardCrudPage } from "./dashboard-crud-page"
export { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog" export { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog"
export {
buildDashboardCrudQuery,
normalizeDashboardCrudPageResult,
} from "./dashboard-crud-utils"
export { export {
createDashboardStatusColumn, createDashboardStatusColumn,
createDashboardStatusToggleAction, createDashboardStatusToggleAction,
@@ -15,6 +19,8 @@ export type {
} from "./dashboard-crud-page" } from "./dashboard-crud-page"
export type { export type {
DashboardCrudFormField, DashboardCrudFormField,
DashboardCrudFilterStateConfig,
DashboardCrudPageResult, DashboardCrudPageResult,
DashboardCrudQueryFilter,
DashboardCrudQueryValue, DashboardCrudQueryValue,
} from "./dashboard-crud-utils" } from "./dashboard-crud-utils"
@@ -10,9 +10,13 @@ import {
export function useDashboardCrudFilters( export function useDashboardCrudFilters(
filters: ReadonlyArray<DashboardCrudFilterStateConfig> filters: ReadonlyArray<DashboardCrudFilterStateConfig>
) { ) {
const defaultsKey = filters
.map((filter) => `${filter.name}:${String(filter.defaultValue)}`)
.join("|")
const initialFilters = useMemo( const initialFilters = useMemo(
() => buildDashboardCrudInitialFilters(filters), () => buildDashboardCrudInitialFilters(filters),
[filters] // eslint-disable-next-line react-hooks/exhaustive-deps
[defaultsKey]
) )
const [draftFilters, setDraftFilters] = useState(initialFilters) const [draftFilters, setDraftFilters] = useState(initialFilters)
const [appliedFilters, setAppliedFilters] = useState(initialFilters) const [appliedFilters, setAppliedFilters] = useState(initialFilters)
@@ -33,11 +37,23 @@ export function useDashboardCrudFilters(
setAppliedFilters(draftFilters) setAppliedFilters(draftFilters)
} }
function applyFilter(name: string, value: string | number | undefined) {
setDraftFilters((current) => ({
...current,
[name]: value,
}))
setAppliedFilters((current) => ({
...current,
[name]: value,
}))
}
return { return {
draftFilters, draftFilters,
appliedFilters, appliedFilters,
setDraftFilter, setDraftFilter,
setDraftFilters, setDraftFilters,
applyFilter,
applyFilters, applyFilters,
} }
} }
@@ -0,0 +1,263 @@
"use client"
import type { KeyboardEvent, ReactNode } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react"
import {
DashboardPage,
DashboardTableShell,
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import type {
DashboardCrudPageResult,
DashboardCrudQueryFilter,
} from "@/components/dashboard/crud"
import {
useDashboardPagedList,
type DashboardPagedListOptions,
} from "./use-dashboard-paged-list"
export type DashboardListFilter = DashboardCrudQueryFilter & {
label: string
placeholder?: string
defaultValue: string | number
type?: "text" | "select" | "segment"
className?: string
inputClassName?: string
options?: ReadonlyArray<{ value: string; label: string }>
searchPlaceholder?: string
emptyText?: string
icon?: ReactNode
}
export type DashboardListColumn<TItem> = {
key: string
label: ReactNode
className?: string
render: (item: TItem, context: DashboardListRenderContext<TItem>) => ReactNode
}
export type DashboardListRenderContext<TItem> = {
result: DashboardCrudPageResult<TItem>
loading: boolean
reload: () => Promise<void>
}
export type DashboardListPageProps<TItem> = {
filters?: DashboardListFilter[]
fetchList: DashboardPagedListOptions<TItem>["fetchList"]
columns?: DashboardListColumn<TItem>[]
getItemId?: (item: TItem) => string | number
renderContent?: (context: DashboardListRenderContext<TItem>) => ReactNode
renderToolbarActions?: (context: DashboardListRenderContext<TItem>) => ReactNode
getRowClassName?: (item: TItem) => string | undefined
onRowClick?: (item: TItem) => void
pageSize?: number
enabled?: boolean
layout?: "page" | "fragment"
tableShellClassName?: string
labels: {
refresh?: string
query?: string
loading: string
empty: string
loadFailed: string
}
}
export function DashboardListPage<TItem>({
filters = [],
fetchList,
columns,
getItemId,
renderContent,
renderToolbarActions,
getRowClassName,
onRowClick,
pageSize,
enabled,
layout = "page",
tableShellClassName,
labels,
}: DashboardListPageProps<TItem>) {
const list = useDashboardPagedList<TItem>({
filters,
fetchList,
pageSize,
enabled,
loadFailed: labels.loadFailed,
})
const renderContext: DashboardListRenderContext<TItem> = {
result: list.result,
loading: list.loading,
reload: list.loadData,
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") return
event.preventDefault()
list.applyFilters()
}
const content = (
<>
<DashboardToolbar
actions={
<>
{labels.refresh ? (
<Button
variant="outline"
onClick={() => void list.loadData()}
disabled={list.loading}
>
<RefreshCwIcon className={list.loading ? "animate-spin" : undefined} />
{labels.refresh}
</Button>
) : null}
{renderToolbarActions?.(renderContext)}
</>
}
>
{filters.map((filter) => {
const value = list.draftFilters[filter.name]
if (filter.type === "segment") {
return (
<div
key={filter.name}
className={filter.className ?? "flex flex-wrap gap-2"}
>
{(filter.options ?? []).map((option) => (
<Button
key={option.value}
variant={String(value) === option.value ? "default" : "outline"}
onClick={() => list.applyFilter(filter.name, option.value)}
>
{option.label}
</Button>
))}
</div>
)
}
if (filter.type === "select") {
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-40"}>
<OptionCombobox
value={String(value ?? "")}
onChange={(nextValue) =>
list.setDraftFilter(filter.name, nextValue || filter.defaultValue)
}
placeholder={filter.placeholder ?? filter.label}
searchPlaceholder={filter.searchPlaceholder}
emptyText={filter.emptyText}
options={[...(filter.options ?? [])]}
/>
</div>
)
}
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-64"}>
<div className={filter.icon ? "relative" : undefined}>
{filter.icon ? (
<div className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground">
{filter.icon}
</div>
) : null}
<Input
value={String(value ?? "")}
onChange={(event) =>
list.setDraftFilter(filter.name, event.target.value)
}
onKeyDown={handleFilterKeyDown}
placeholder={filter.placeholder ?? filter.label}
className={filter.inputClassName}
/>
</div>
</div>
)
})}
{filters.some((filter) => filter.type !== "segment") ? (
<Button variant="outline" onClick={list.applyFilters} disabled={list.loading}>
<SearchIcon />
{labels.query}
</Button>
) : null}
</DashboardToolbar>
<DashboardTableShell
className={tableShellClassName}
pagination={
<ListPagination
page={list.result.page.page}
total={list.result.page.total}
limit={list.result.page.limit}
loading={list.loading}
onPageChange={list.handlePageChange}
onLimitChange={list.handleLimitChange}
/>
}
>
{renderContent ? (
renderContent(renderContext)
) : columns ? (
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
{columns.map((column) => (
<TableHead key={column.key} className={column.className}>
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{list.result.results.map((item, index) => {
const key = getItemId ? getItemId(item) : index
return (
<TableRow
key={key}
className={getRowClassName?.(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
>
{columns.map((column) => (
<TableCell key={column.key} className={column.className}>
{column.render(item, renderContext)}
</TableCell>
))}
</TableRow>
)
})}
{list.loading || list.result.results.length === 0 ? (
<DashboardTableStateRow
colSpan={columns.length}
loading={list.loading}
loadingText={labels.loading}
emptyText={labels.empty}
/>
) : null}
</TableBody>
</Table>
) : null}
</DashboardTableShell>
</>
)
if (layout === "fragment") {
return content
}
return <DashboardPage>{content}</DashboardPage>
}
+9
View File
@@ -0,0 +1,9 @@
export { DashboardListPage } from "./dashboard-list-page"
export { useDashboardPagedList } from "./use-dashboard-paged-list"
export type {
DashboardListColumn,
DashboardListFilter,
DashboardListPageProps,
DashboardListRenderContext,
} from "./dashboard-list-page"
export type { DashboardPagedListOptions } from "./use-dashboard-paged-list"
@@ -0,0 +1,119 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import {
buildDashboardCrudQuery,
normalizeDashboardCrudPageResult,
type DashboardCrudPageResult,
type DashboardCrudFilterStateConfig,
type DashboardCrudQueryFilter,
type DashboardCrudQueryValue,
} from "@/components/dashboard/crud"
import { useDashboardCrudFilters } from "@/components/dashboard/crud"
export type DashboardPagedListFilter = DashboardCrudQueryFilter &
DashboardCrudFilterStateConfig
export type DashboardPagedListOptions<TItem> = {
filters: DashboardPagedListFilter[]
fetchList: (
query: Record<string, DashboardCrudQueryValue>
) => Promise<DashboardCrudPageResult<TItem>>
pageSize?: number
loadFailed: string
enabled?: boolean
}
export function useDashboardPagedList<TItem>({
filters,
fetchList,
pageSize = 20,
loadFailed,
enabled = true,
}: DashboardPagedListOptions<TItem>) {
const filtersKey = filters
.map(
(filter) =>
`${filter.name}:${String(filter.defaultValue)}:${String(filter.allValue)}:${filter.trim ? "1" : "0"}:${filter.valueType ?? ""}`
)
.join("|")
const { draftFilters, appliedFilters, setDraftFilter, applyFilter, applyFilters } =
useDashboardCrudFilters(filters)
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(pageSize)
const [loading, setLoading] = useState(enabled)
const [result, setResult] = useState<DashboardCrudPageResult<TItem>>({
results: [],
page: { page: 1, limit: pageSize, total: 0 },
})
const loadData = useCallback(async () => {
if (!enabled) {
setLoading(false)
setResult({ results: [], page: { page: 1, limit, total: 0 } })
return
}
setLoading(true)
try {
const data = await fetchList(
buildDashboardCrudQuery({
values: appliedFilters,
filters,
page,
limit,
})
)
setResult(normalizeDashboardCrudPageResult(data, page, limit))
} catch (error) {
toast.error(error instanceof Error ? error.message : loadFailed)
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedFilters, enabled, fetchList, filtersKey, limit, loadFailed, page])
useEffect(() => {
void loadData()
}, [loadData])
function applyDraftFilters() {
applyFilters()
setPage(1)
}
function applyDraftFilter(name: string, value: string | number | undefined) {
applyFilter(name, value)
setPage(1)
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) return
setPage(nextPage)
}
function handleLimitChange(nextLimit: number) {
if (nextLimit <= 0 || nextLimit === limit) return
setLimit(nextLimit)
setPage(1)
}
return {
draftFilters,
setDraftFilter,
applyFilter: applyDraftFilter,
applyFilters: applyDraftFilters,
page,
setPage,
limit,
setLimit,
loading,
result,
setResult,
loadData,
handlePageChange,
handleLimitChange,
}
}