diff --git a/web/app/dashboard/agent-run-logs/page.tsx b/web/app/dashboard/agent-run-logs/page.tsx index f8e16b3..7821c1e 100644 --- a/web/app/dashboard/agent-run-logs/page.tsx +++ b/web/app/dashboard/agent-run-logs/page.tsx @@ -1,26 +1,18 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" -import { RefreshCwIcon, SearchIcon } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import { SearchIcon } from "lucide-react" import { toast } from "sonner" -import { - DashboardPage, - DashboardTableShell, - DashboardToolbar, -} from "@/components/dashboard-page" -import { ListPagination } from "@/components/list-pagination" -import { OptionCombobox } from "@/components/option-combobox" +import { DashboardListPage } from "@/components/dashboard/list" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" import { AgentRunLogDetailDialog } from "./_components/detail" import { fetchAgentRunLogs, fetchAIAgentsAll, type AIAgent, type AgentRunLog, - type PageResult, } from "@/lib/api/admin" import { useI18n } from "@/i18n/provider" import { formatDateTime } from "@/lib/utils" @@ -100,27 +92,8 @@ function actionBadgeVariant(action: string) { export default function DashboardAgentRunLogsPage() { 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 [activeLogId, setActiveLogId] = useState(null) - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }) const [aiAgents, setAiAgents] = useState([]) const actionOptions = useMemo(() => getActionOptions(t), [t]) const finalStatusOptions = useMemo(() => getFinalStatusOptions(t), [t]) @@ -137,31 +110,6 @@ export default function DashboardAgentRunLogsPage() { [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(() => { async function loadAIAgents() { try { @@ -174,122 +122,84 @@ export default function DashboardAgentRunLogsPage() { void loadAIAgents() }, [t]) - function applyFilters() { - setKeyword(keywordInput) - setPlannedAction(plannedActionInput) - setFinalAction(finalActionInput) - setFinalStatus(finalStatusInput) - setHitlStatus(hitlStatusInput) - setAiAgentId(aiAgentIdInput) - setPage(1) - } - - function handleFilterKeyDown(event: React.KeyboardEvent) { - if (event.key !== "Enter") { - return - } - event.preventDefault() - applyFilters() - } - return ( <> - - void loadData()} - disabled={loading} - className="w-full xl:w-auto" - > - - {t("agentRunLog.refresh")} - - } - > -
- - setKeywordInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("agentRunLog.filterUserMessage")} - className="pl-9" - /> -
-
- setPlannedActionInput(value || "all")} - /> -
-
- setFinalActionInput(value || "all")} - /> -
-
- setFinalStatusInput(value || "all")} - /> -
-
- setHitlStatusInput(value || "all")} - /> -
-
- setAiAgentIdInput(value || "all")} - /> -
- -
- - { - setLimit(nextLimit) - setPage(1) - }} - /> - } - > - {!loading && result.results.length === 0 ? ( + + filters={[ + { + name: "userMessage", + label: t("agentRunLog.filterUserMessage"), + placeholder: t("agentRunLog.filterUserMessage"), + defaultValue: "", + trim: true, + className: "min-w-0", + inputClassName: "pl-9", + icon: , + }, + { + name: "plannedAction", + label: t("agentRunLog.plannedAction"), + type: "select", + defaultValue: "all", + allValue: "all", + options: actionOptions, + placeholder: t("agentRunLog.plannedAction"), + searchPlaceholder: t("agentRunLog.searchAction"), + emptyText: t("agentRunLog.emptyAction"), + className: "min-w-0", + }, + { + name: "finalAction", + label: t("agentRunLog.finalAction"), + type: "select", + defaultValue: "all", + allValue: "all", + options: actionOptions, + placeholder: t("agentRunLog.finalAction"), + searchPlaceholder: t("agentRunLog.searchAction"), + emptyText: t("agentRunLog.emptyAction"), + className: "min-w-0", + }, + { + name: "finalStatus", + label: t("agentRunLog.finalStatus"), + type: "select", + defaultValue: "all", + allValue: "all", + options: finalStatusOptions, + placeholder: t("agentRunLog.finalStatus"), + searchPlaceholder: t("agentRunLog.searchStatus"), + emptyText: t("agentRunLog.emptyStatus"), + className: "min-w-0", + }, + { + name: "hitlStatus", + label: t("agentRunLog.hitlStatus"), + type: "select", + defaultValue: "all", + allValue: "all", + options: hitlStatusOptions, + placeholder: t("agentRunLog.hitlStatus"), + searchPlaceholder: t("agentRunLog.searchHitl"), + emptyText: t("agentRunLog.emptyStatus"), + className: "min-w-0", + }, + { + name: "aiAgentId", + label: t("agentRunLog.selectAgent"), + type: "select", + defaultValue: "all", + allValue: "all", + options: aiAgentOptions, + placeholder: t("agentRunLog.selectAgent"), + searchPlaceholder: t("agentRunLog.searchAgent"), + emptyText: t("agentRunLog.emptyAgent"), + className: "min-w-0", + }, + ]} + fetchList={fetchAgentRunLogs} + renderContent={({ result, loading }) => + !loading && result.results.length === 0 ? (
{t("agentRunLog.emptyRows")}
@@ -388,9 +298,16 @@ export default function DashboardAgentRunLogsPage() { ))} - )} -
-
+ ) + } + labels={{ + refresh: t("agentRunLog.refresh"), + query: t("agentRunLog.query"), + loading: t("agentRunLog.loadingRows"), + empty: t("agentRunLog.emptyRows"), + loadFailed: t("agentRunLog.loadFailed"), + }} + /> (null) - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }) + const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState(null) const channelOptions = useMemo(() => getChannelOptions(t), [t]) const sceneOptions = useMemo(() => getSceneOptions(t), [t]) const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t]) const providerOptions = useMemo(() => getProviderOptions(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) { - if (event.key !== "Enter") { - return - } - event.preventDefault() - applyFilters() - } - function handleOpenDetail(logId: number) { setSelectedLogId(logId) + setSelectedKnowledgeBaseId(knowledgeBaseId) setDetailOpen(true) } if (!knowledgeBaseId) { return (
- {emptyStateText} + {t("knowledge.selectBaseForLogs")}
) } return ( <> -
-
-
-
- - setQuestionInput(event.target.value)} - onKeyDown={handleQuestionKeyDown} - placeholder={t("knowledge.filterQuestion")} - className="pl-9" - /> -
- - - - - - -
-
- -
-
- - - - {t("knowledge.time")} - {t("knowledge.question")} - {t("knowledge.answerStatus")} - {t("knowledge.hitCount")} - TopScore - Provider - Rerank - {t("knowledge.citations")} - {t("knowledge.duration")} - - - - {result.results.length === 0 ? ( - - - {emptyStateText} - - - ) : ( - result.results.map((item) => ( - handleOpenDetail(item.id)} - > - {formatDateTime(item.createdAt)} - +
+ + layout="fragment" + filters={[ + { + name: "question", + label: t("knowledge.filterQuestion"), + placeholder: t("knowledge.filterQuestion"), + defaultValue: "", + trim: true, + className: "min-w-0 xl:min-w-[280px]", + inputClassName: "pl-9", + icon: , + }, + { + name: "channel", + label: t("knowledge.selectChannel"), + type: "select", + defaultValue: "all", + allValue: "all", + options: channelOptions, + placeholder: t("knowledge.selectChannel"), + }, + { + name: "scene", + label: t("knowledge.selectScene"), + type: "select", + defaultValue: "all", + allValue: "all", + options: sceneOptions, + placeholder: t("knowledge.selectScene"), + }, + { + name: "answerStatus", + label: t("knowledge.answerStatus"), + type: "select", + defaultValue: "all", + allValue: "all", + valueType: "number", + options: answerStatusOptions, + placeholder: t("knowledge.answerStatus"), + }, + { + name: "chunkProvider", + label: t("knowledge.chunkStrategy"), + type: "select", + defaultValue: "all", + allValue: "all", + options: providerOptions, + placeholder: t("knowledge.chunkStrategy"), + }, + { + name: "rerankEnabled", + 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) => (
{item.question || "-"}
@@ -285,45 +222,78 @@ export function RetrieveLogList({ {item.knowledgeBaseName ? {item.knowledgeBaseName} : null}
- - + ), + }, + { + key: "answerStatus", + label: t("knowledge.answerStatus"), + className: "w-28", + render: (item) => ( {answerStatusLabel(item.answerStatus, item.answerStatusName, t)} - - {item.hitCount} - {item.topScore.toFixed(4)} - {item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-"} - {item.rerankEnabled ? `${t("knowledge.yes")} (${item.rerankLimit})` : t("knowledge.no")} - {item.citationCount} - {item.latencyMs} ms - - )) - )} - -
-
-
- -
- { - setLimit(nextLimit) - setPage(1) - }} - /> -
+ ), + }, + { + key: "hitCount", + label: t("knowledge.hitCount"), + className: "w-24 text-right", + render: (item) => item.hitCount, + }, + { + key: "topScore", + label: "TopScore", + className: "w-24 text-right font-mono text-xs", + render: (item) => item.topScore.toFixed(4), + }, + { + key: "provider", + label: "Provider", + className: "w-28", + render: (item) => + item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-", + }, + { + key: "rerank", + label: "Rerank", + className: "w-24", + render: (item) => + item.rerankEnabled + ? `${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"), + }} + />
{ + setDetailOpen(open) + if (!open) { + setSelectedLogId(null) + setSelectedKnowledgeBaseId(null) + } + }} /> ) diff --git a/web/app/dashboard/notifications/page.tsx b/web/app/dashboard/notifications/page.tsx index c39c4e3..84ac0b8 100644 --- a/web/app/dashboard/notifications/page.tsx +++ b/web/app/dashboard/notifications/page.tsx @@ -1,16 +1,10 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" -import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react" +import { BellIcon, CheckCheckIcon } from "lucide-react" import { toast } from "sonner" -import { - DashboardPage, - DashboardTableShell, - DashboardToolbar, -} from "@/components/dashboard-page" -import { ListPagination } from "@/components/list-pagination" +import { DashboardListPage } from "@/components/dashboard/list" import { useNotifications } from "@/components/notification-provider" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -21,51 +15,18 @@ import { type NotificationItem, type NotificationReadStatus, } from "@/lib/api/notification" -import type { PageResult } from "@/lib/api/admin" -import { cn, formatDateTime } from "@/lib/utils" +import { formatDateTime } from "@/lib/utils" import { useI18n } from "@/i18n/provider" export default function DashboardNotificationsPage() { const t = useI18n() const router = useRouter() const { refreshUnreadCount } = useNotifications() - const [readStatus, setReadStatus] = useState("all") - const [page, setPage] = useState(1) - const [limit, setLimit] = useState(20) - const [loading, setLoading] = useState(true) - const [actionLoading, setActionLoading] = useState(false) - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }) - const readStatusOptions = useMemo>( - () => [ - { 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]) + const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [ + { value: "all", label: t("notification.all") }, + { value: "unread", label: t("notification.unread") }, + { value: "read", label: t("notification.read") }, + ] async function openNotification(item: NotificationItem) { try { @@ -81,81 +42,44 @@ export default function DashboardNotificationsPage() { } } - async function handleMarkAllRead() { - setActionLoading(true) + async function markAllRead(reload: () => Promise) { try { await markAllNotificationsRead() await refreshUnreadCount() - await loadData() + await reload() toast.success(t("notification.markAllReadSuccess")) } catch (error) { - toast.error(error instanceof Error ? error.message : t("notification.markAllReadFailed")) - } finally { - setActionLoading(false) + toast.error( + error instanceof Error ? error.message : t("notification.markAllReadFailed") + ) } } - 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 ( - - - - - - } - > - {readStatusOptions.map((option) => ( - - ))} - - - - } - > - {result.results.length > 0 ? ( + + filters={[ + { + name: "readStatus", + label: t("notification.all"), + type: "segment", + defaultValue: "all", + allValue: "all", + options: readStatusOptions, + }, + ]} + fetchList={fetchNotifications} + renderToolbarActions={({ result, reload }) => ( + + )} + renderContent={({ result, loading }) => + result.results.length > 0 ? (
{result.results.map((item) => { const unread = !item.readAt @@ -168,8 +92,14 @@ export default function DashboardNotificationsPage() { >
- {item.title || t("notification.fallbackTitle")} - {unread ? {t("notification.unread")} : {t("notification.read")}} + + {item.title || t("notification.fallbackTitle")} + + {unread ? ( + {t("notification.unread")} + ) : ( + {t("notification.read")} + )} {formatDateTime(item.createdAt)} @@ -185,8 +115,14 @@ export default function DashboardNotificationsPage() {
{loading ? t("notification.loading") : t("notification.empty")}
- )} - - + ) + } + labels={{ + refresh: t("notification.refresh"), + loading: t("notification.loading"), + empty: t("notification.empty"), + loadFailed: t("notification.loadFailed"), + }} + /> ) } diff --git a/web/app/dashboard/permissions/page.tsx b/web/app/dashboard/permissions/page.tsx index a2a8644..152848c 100644 --- a/web/app/dashboard/permissions/page.tsx +++ b/web/app/dashboard/permissions/page.tsx @@ -1,33 +1,10 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" -import { KeyRoundIcon, RefreshCwIcon, RouteIcon, SearchIcon } from "lucide-react" -import { toast } from "sonner" +import { KeyRoundIcon, RouteIcon, 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 { DashboardListPage } from "@/components/dashboard/list" 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 { - fetchPermissions, - type AdminPermission, - type PageResult, -} from "@/lib/api/admin" +import { fetchPermissions, type AdminPermission } from "@/lib/api/admin" import { Status } from "@/lib/generated/enums" import { useAppLocale, useI18n } from "@/i18n/provider" import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n" @@ -35,191 +12,107 @@ import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permissi export default function DashboardPermissionsPage() { const t = useI18n() const { locale } = useAppLocale() - const [keywordInput, setKeywordInput] = useState("") - const [groupNameInput, setGroupNameInput] = useState("") - const [statusFilterInput, setStatusFilterInput] = useState("all") - const [keyword, setKeyword] = useState("") - const [groupName, setGroupName] = useState("") - const [statusFilter, setStatusFilter] = useState("all") - const [page, setPage] = useState(1) - const [limit, setLimit] = useState(20) - const [loading, setLoading] = useState(true) - const [result, setResult] = useState>({ - 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) { - if (event.key !== "Enter") { - return - } - - event.preventDefault() - applyFilters() - } - - function handlePageChange(nextPage: number) { - if (nextPage < 1 || nextPage === page) { - return - } - setPage(nextPage) - } + const listStatusOptions = [ + { 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") }, + ] return ( - - void loadData()} disabled={loading}> - - {t("permission.refresh")} - - } - > -
- - setKeywordInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("permission.filterKeyword")} - className="pl-9" - /> -
- setGroupNameInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("permission.filterGroup")} - className="w-full sm:w-44" - /> -
- -
- -
- { - setLimit(nextLimit) - setPage(1) - }} - /> - } - > - - - - {t("permission.columnPermission")} - {t("permission.columnCode")} - {t("permission.columnGroup")} - {t("permission.columnApi")} - {t("permission.columnStatus")} - - - - {result.results.map((item) => ( - - -
-
- -
-
-
- {getPermissionDisplayName(item.code, item.name, locale)} -
-
{item.type}
-
-
-
- - {item.code} - - {getPermissionGroupName(item.groupName, locale)} - -
- {item.method || "ANY"} -
-
- - {item.apiPath || "-"} -
-
-
-
- - - {getStatusLabel(item.status, t)} - - -
- ))} - {loading || result.results.length === 0 ? ( - - ) : null} -
-
-
-
+ + filters={[ + { + name: "keyword", + label: t("permission.filterKeyword"), + placeholder: t("permission.filterKeyword"), + defaultValue: "", + trim: true, + className: "w-full sm:w-72", + inputClassName: "pl-9", + icon: , + }, + { + name: "groupName", + label: t("permission.filterGroup"), + placeholder: t("permission.filterGroup"), + defaultValue: "", + trim: true, + className: "w-full sm:w-44", + }, + { + name: "status", + label: t("status.all"), + type: "select", + defaultValue: "all", + allValue: "all", + options: listStatusOptions, + className: "w-full sm:w-36", + }, + ]} + fetchList={fetchPermissions} + getItemId={(item) => item.id} + columns={[ + { + key: "permission", + label: t("permission.columnPermission"), + render: (item) => ( +
+
+ +
+
+
+ {getPermissionDisplayName(item.code, item.name, locale)} +
+
{item.type}
+
+
+ ), + }, + { + key: "code", + label: t("permission.columnCode"), + render: (item) => {item.code}, + }, + { + key: "group", + label: t("permission.columnGroup"), + render: (item) => getPermissionGroupName(item.groupName, locale), + }, + { + key: "api", + label: t("permission.columnApi"), + render: (item) => ( +
+ {item.method || "ANY"} +
+
+ + {item.apiPath || "-"} +
+
+
+ ), + }, + { + key: "status", + label: t("permission.columnStatus"), + render: (item) => ( + + {getStatusLabel(item.status, t)} + + ), + }, + ]} + labels={{ + refresh: t("permission.refresh"), + query: t("permission.query"), + loading: t("permission.loading"), + empty: t("permission.empty"), + loadFailed: t("permission.loadFailed"), + }} + /> ) } diff --git a/web/components/dashboard/crud/dashboard-crud-page.tsx b/web/components/dashboard/crud/dashboard-crud-page.tsx index 542b10b..94fa277 100644 --- a/web/components/dashboard/crud/dashboard-crud-page.tsx +++ b/web/components/dashboard/crud/dashboard-crud-page.tsx @@ -209,6 +209,12 @@ export function DashboardCrudPage({ ) const { draftFilters, appliedFilters, setDraftFilter, applyFilters } = 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 [limit, setLimit] = useState(pageSize) const [loading, setLoading] = useState(true) @@ -238,7 +244,8 @@ export function DashboardCrudPage({ } finally { 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(() => { void loadData() diff --git a/web/components/dashboard/crud/index.ts b/web/components/dashboard/crud/index.ts index 5c3924b..22f9f1b 100644 --- a/web/components/dashboard/crud/index.ts +++ b/web/components/dashboard/crud/index.ts @@ -1,5 +1,9 @@ export { DashboardCrudPage } from "./dashboard-crud-page" export { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog" +export { + buildDashboardCrudQuery, + normalizeDashboardCrudPageResult, +} from "./dashboard-crud-utils" export { createDashboardStatusColumn, createDashboardStatusToggleAction, @@ -15,6 +19,8 @@ export type { } from "./dashboard-crud-page" export type { DashboardCrudFormField, + DashboardCrudFilterStateConfig, DashboardCrudPageResult, + DashboardCrudQueryFilter, DashboardCrudQueryValue, } from "./dashboard-crud-utils" diff --git a/web/components/dashboard/crud/use-dashboard-crud-filters.ts b/web/components/dashboard/crud/use-dashboard-crud-filters.ts index b69556d..898f018 100644 --- a/web/components/dashboard/crud/use-dashboard-crud-filters.ts +++ b/web/components/dashboard/crud/use-dashboard-crud-filters.ts @@ -10,9 +10,13 @@ import { export function useDashboardCrudFilters( filters: ReadonlyArray ) { + const defaultsKey = filters + .map((filter) => `${filter.name}:${String(filter.defaultValue)}`) + .join("|") const initialFilters = useMemo( () => buildDashboardCrudInitialFilters(filters), - [filters] + // eslint-disable-next-line react-hooks/exhaustive-deps + [defaultsKey] ) const [draftFilters, setDraftFilters] = useState(initialFilters) const [appliedFilters, setAppliedFilters] = useState(initialFilters) @@ -33,11 +37,23 @@ export function useDashboardCrudFilters( setAppliedFilters(draftFilters) } + function applyFilter(name: string, value: string | number | undefined) { + setDraftFilters((current) => ({ + ...current, + [name]: value, + })) + setAppliedFilters((current) => ({ + ...current, + [name]: value, + })) + } + return { draftFilters, appliedFilters, setDraftFilter, setDraftFilters, + applyFilter, applyFilters, } } diff --git a/web/components/dashboard/list/dashboard-list-page.tsx b/web/components/dashboard/list/dashboard-list-page.tsx new file mode 100644 index 0000000..bb97922 --- /dev/null +++ b/web/components/dashboard/list/dashboard-list-page.tsx @@ -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 = { + key: string + label: ReactNode + className?: string + render: (item: TItem, context: DashboardListRenderContext) => ReactNode +} + +export type DashboardListRenderContext = { + result: DashboardCrudPageResult + loading: boolean + reload: () => Promise +} + +export type DashboardListPageProps = { + filters?: DashboardListFilter[] + fetchList: DashboardPagedListOptions["fetchList"] + columns?: DashboardListColumn[] + getItemId?: (item: TItem) => string | number + renderContent?: (context: DashboardListRenderContext) => ReactNode + renderToolbarActions?: (context: DashboardListRenderContext) => 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({ + filters = [], + fetchList, + columns, + getItemId, + renderContent, + renderToolbarActions, + getRowClassName, + onRowClick, + pageSize, + enabled, + layout = "page", + tableShellClassName, + labels, +}: DashboardListPageProps) { + const list = useDashboardPagedList({ + filters, + fetchList, + pageSize, + enabled, + loadFailed: labels.loadFailed, + }) + const renderContext: DashboardListRenderContext = { + result: list.result, + loading: list.loading, + reload: list.loadData, + } + + function handleFilterKeyDown(event: KeyboardEvent) { + if (event.key !== "Enter") return + event.preventDefault() + list.applyFilters() + } + + const content = ( + <> + + {labels.refresh ? ( + + ) : null} + {renderToolbarActions?.(renderContext)} + + } + > + {filters.map((filter) => { + const value = list.draftFilters[filter.name] + if (filter.type === "segment") { + return ( +
+ {(filter.options ?? []).map((option) => ( + + ))} +
+ ) + } + if (filter.type === "select") { + return ( +
+ + list.setDraftFilter(filter.name, nextValue || filter.defaultValue) + } + placeholder={filter.placeholder ?? filter.label} + searchPlaceholder={filter.searchPlaceholder} + emptyText={filter.emptyText} + options={[...(filter.options ?? [])]} + /> +
+ ) + } + + return ( +
+
+ {filter.icon ? ( +
+ {filter.icon} +
+ ) : null} + + list.setDraftFilter(filter.name, event.target.value) + } + onKeyDown={handleFilterKeyDown} + placeholder={filter.placeholder ?? filter.label} + className={filter.inputClassName} + /> +
+
+ ) + })} + {filters.some((filter) => filter.type !== "segment") ? ( + + ) : null} +
+ + + } + > + {renderContent ? ( + renderContent(renderContext) + ) : columns ? ( + + + + {columns.map((column) => ( + + {column.label} + + ))} + + + + {list.result.results.map((item, index) => { + const key = getItemId ? getItemId(item) : index + return ( + onRowClick(item) : undefined} + > + {columns.map((column) => ( + + {column.render(item, renderContext)} + + ))} + + ) + })} + {list.loading || list.result.results.length === 0 ? ( + + ) : null} + +
+ ) : null} +
+ + ) + + if (layout === "fragment") { + return content + } + + return {content} +} diff --git a/web/components/dashboard/list/index.ts b/web/components/dashboard/list/index.ts new file mode 100644 index 0000000..6f8eeb9 --- /dev/null +++ b/web/components/dashboard/list/index.ts @@ -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" diff --git a/web/components/dashboard/list/use-dashboard-paged-list.ts b/web/components/dashboard/list/use-dashboard-paged-list.ts new file mode 100644 index 0000000..5f0ce4f --- /dev/null +++ b/web/components/dashboard/list/use-dashboard-paged-list.ts @@ -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 = { + filters: DashboardPagedListFilter[] + fetchList: ( + query: Record + ) => Promise> + pageSize?: number + loadFailed: string + enabled?: boolean +} + +export function useDashboardPagedList({ + filters, + fetchList, + pageSize = 20, + loadFailed, + enabled = true, +}: DashboardPagedListOptions) { + 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>({ + 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, + } +}