From 6a3c641ff9387b2e2259fc29e14773338d10a411 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Thu, 28 May 2026 09:52:47 +0800 Subject: [PATCH] refactor: migrate customer and skill definition pages to use DashboardCrudPage component - Replaced existing customer and skill definition page implementations with DashboardCrudPage for improved structure and functionality. - Simplified state management and data fetching logic. - Enhanced filtering and column definitions for better user experience. - Updated translations to include new labels and processing messages. --- web/app/dashboard/ai-agents/page.tsx | 767 ++++++----------- web/app/dashboard/ai-configs/page.tsx | 869 ++++++-------------- web/app/dashboard/customers/page.tsx | 574 +++++-------- web/app/dashboard/skill-definition/page.tsx | 681 +++++---------- web/messages/en-US.json | 3 + web/messages/zh-CN.json | 3 + 6 files changed, 888 insertions(+), 2009 deletions(-) diff --git a/web/app/dashboard/ai-agents/page.tsx b/web/app/dashboard/ai-agents/page.tsx index ddcd7f5..3497222 100644 --- a/web/app/dashboard/ai-agents/page.tsx +++ b/web/app/dashboard/ai-agents/page.tsx @@ -1,67 +1,16 @@ "use client"; -import { - closestCenter, - DndContext, - KeyboardSensor, - MouseSensor, - TouchSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - BotMessageSquareIcon, - GripVerticalIcon, - MoreHorizontalIcon, - PlusIcon, - PowerIcon, - RefreshCwIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react"; -import { - useCallback, - useEffect, - useState, - type CSSProperties, -} from "react"; -import { toast } from "sonner"; +import { BotMessageSquareIcon, PowerIcon } from "lucide-react"; +import { useMemo } from "react"; import { - DashboardPage, - DashboardTableShell, - DashboardTableStateRow, - DashboardToolbar, -} from "@/components/dashboard-page"; -import { ListPagination } from "@/components/list-pagination"; -import { OptionCombobox } from "@/components/option-combobox"; + DashboardCrudPage, + createDashboardStatusColumn, + createDashboardStatusToggleAction, + type DashboardCrudColumn, + type DashboardCrudFilter, +} from "@/components/dashboard/crud"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { createAIAgent, deleteAIAgent, @@ -71,13 +20,10 @@ import { updateAIAgentStatus, type AIAgent, type CreateAIAgentPayload, - type PageResult, } from "@/lib/api/admin"; import { IMConversationServiceMode, Status } from "@/lib/generated/enums"; import { useI18n } from "@/i18n/provider"; -import { cn } from "@/lib/utils"; import { EditDialog } from "./_components/edit"; -import { ButtonGroup } from "@/components/ui/button-group"; type TFunction = (key: string, values?: Record) => string; @@ -110,488 +56,237 @@ function getServiceModeLabel(mode: number, t: TFunction) { } } -type SortableAIAgentRowProps = { - item: AIAgent; - disabled: boolean; - actionLoadingId: number | null; - t: TFunction; - openEditDialog: (item: AIAgent) => void; - handleToggleStatus: (item: AIAgent) => void; - handleDelete: (item: AIAgent) => void; -}; - -function SortableAIAgentRow({ - item, - disabled, - actionLoadingId, - t, - openEditDialog, - handleToggleStatus, - handleDelete, -}: SortableAIAgentRowProps) { - const knowledgeIds = item.knowledgeIds ?? []; - const knowledgeBaseNames = item.knowledgeBaseNames ?? []; - const skills = item.skills ?? []; - const directTools = item.directTools ?? []; - const directToolServerCodes = Array.from( - new Set(directTools.map((tool) => tool.serverCode).filter(Boolean)), - ); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: item.id, - disabled, - }); - - const style: CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( - - - - - -
-
- -
-
-
{item.name}
-
-
-
- - {item.aiConfigName || "-"} - - - {getServiceModeLabel(item.serviceMode, t)} - - -
- {knowledgeIds.length === 0 ? ( - - {t("aiAgent.notConfigured")} - - ) : ( - knowledgeBaseNames.map((name, index) => ( - - {name} - - )) - )} -
-
- -
- {skills.length === 0 ? ( - {t("aiAgent.ragOnly")} - ) : ( - skills.map((skill) => ( - - {skill.name} - - )) - )} -
-
- -
-
- {skills.length} Skills - {directTools.length} Tools -
-
- {directToolServerCodes.length === 0 ? ( - - {t("aiAgent.noMcpServer")} - - ) : ( - directToolServerCodes.map((serverCode) => ( - - {serverCode} - - )) - )} -
-
-
- -
- void handleToggleStatus(item)} - aria-label={t("aiAgent.toggleStatus", { name: item.name })} - /> - - {getStatusLabel(String(item.status), t)} - -
-
- - - - - - } - aria-label={t("aiAgent.moreActions", { name: item.name })} - > - - - - void handleToggleStatus(item)} - > - - {item.status === Status.Ok ? t("aiAgent.stop") : t("aiAgent.enabled")} - - void handleDelete(item)} - > - - {t("aiAgent.delete")} - - - - - -
- ); +function getNextStatus(item: AIAgent) { + return item.status === Status.Ok ? Status.Disabled : Status.Ok; } export default function DashboardAIAgentsPage() { const t = useI18n(); - const statusOptions = getStatusOptions(t); - const [nameInput, setNameInput] = useState(""); - const [statusInput, setStatusInput] = useState("all"); - const [name, setName] = useState(""); - const [status, setStatus] = useState("all"); - const [page, setPage] = useState(1); - const [limit, setLimit] = useState(20); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [actionLoadingId, setActionLoadingId] = useState(null); - const [sorting, setSorting] = useState(false); - const [dialogOpen, setDialogOpen] = useState(false); - const [editingItemId, setEditingItemId] = useState(null); - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }); + const statusOptions = useMemo(() => getStatusOptions(t), [t]); - const sensors = useSensors( - useSensor(MouseSensor, { - activationConstraint: { distance: 8 }, - }), - useSensor(TouchSensor, { - activationConstraint: { delay: 150, tolerance: 8 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), + const filters = useMemo( + () => [ + { + name: "name", + label: t("aiAgent.filterName"), + placeholder: t("aiAgent.filterName"), + defaultValue: "", + trim: true, + className: "w-full sm:w-56", + }, + { + name: "status", + label: t("aiAgent.allStatuses"), + type: "select", + defaultValue: "all", + allValue: "all", + options: statusOptions, + className: "w-full sm:w-52", + }, + ], + [statusOptions, t], ); - const loadData = useCallback(async () => { - setLoading(true); - try { - const data = await fetchAIAgents({ - name: name.trim() || undefined, - status: status === "all" ? undefined : status, - page, - limit, - }); - setResult(data); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("aiAgent.loadFailed"), - ); - } finally { - setLoading(false); - } - }, [limit, name, page, status, t]); - - useEffect(() => { - void loadData(); - }, [loadData]); - - function applyFilters() { - setName(nameInput); - setStatus(statusInput); - setPage(1); - } - - function handleFilterKeyDown(event: React.KeyboardEvent) { - if (event.key !== "Enter") { - return; - } - event.preventDefault(); - applyFilters(); - } - - function openCreateDialog() { - setEditingItemId(null); - setDialogOpen(true); - } - - function openEditDialog(item: AIAgent) { - setEditingItemId(item.id); - setDialogOpen(true); - } - - async function handleSubmit(payload: CreateAIAgentPayload) { - if (saving) { - return; - } - setSaving(true); - try { - if (editingItemId) { - await updateAIAgent({ id: editingItemId, ...payload }); - toast.success(t("aiAgent.updated", { name: payload.name })); - } else { - const created = await createAIAgent(payload); - toast.success(t("aiAgent.created", { name: created.name })); - } - setDialogOpen(false); - setEditingItemId(null); - await loadData(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("aiAgent.saveFailed"), - ); - } finally { - setSaving(false); - } - } - - async function handleToggleStatus(item: AIAgent) { - setActionLoadingId(item.id); - try { - const nextStatus = - item.status === Status.Ok ? Status.Disabled : Status.Ok; - await updateAIAgentStatus(item.id, nextStatus); - toast.success( - t("aiAgent.statusChanged", { - name: item.name, - status: nextStatus === Status.Ok ? t("aiAgent.enabled") : t("aiAgent.stop"), - }), - ); - await loadData(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("aiAgent.statusUpdateFailed"), - ); - } finally { - setActionLoadingId(null); - } - } - - async function handleDelete(item: AIAgent) { - setActionLoadingId(item.id); - try { - await deleteAIAgent(item.id); - toast.success(t("aiAgent.deleted", { name: item.name })); - await loadData(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("aiAgent.deleteFailed"), - ); - } finally { - setActionLoadingId(null); - } - } - - async function handleDragEnd(event: DragEndEvent) { - const { active, over } = event; - if (!over || active.id === over.id || sorting) { - return; - } - - const previousResults = result.results; - const oldIndex = previousResults.findIndex((item) => item.id === active.id); - const newIndex = previousResults.findIndex((item) => item.id === over.id); - if (oldIndex < 0 || newIndex < 0) { - return; - } - - const nextResults = arrayMove(previousResults, oldIndex, newIndex); - setResult((current) => ({ - ...current, - results: nextResults, - })); - setSorting(true); - - try { - await updateAIAgentSort(nextResults.map((item) => item.id)); - toast.success(t("aiAgent.sortUpdated")); - await loadData(); - } catch (error) { - setResult((current) => ({ - ...current, - results: previousResults, - })); - toast.error(error instanceof Error ? error.message : t("aiAgent.sortUpdateFailed")); - } finally { - setSorting(false); - } - } + const columns = useMemo[]>( + () => [ + { + key: "agent", + label: "Agent", + render: (item) => ( +
+
+ +
+
{item.name}
+
+ ), + }, + { + key: "aiConfig", + label: t("aiAgent.columnAiConfig"), + render: (item) => item.aiConfigName || "-", + }, + { + key: "serviceMode", + label: t("aiAgent.columnServiceMode"), + render: (item) => getServiceModeLabel(item.serviceMode, t), + }, + { + key: "knowledge", + label: t("aiAgent.columnKnowledge"), + render: (item) => { + const knowledgeIds = item.knowledgeIds ?? []; + const knowledgeBaseNames = item.knowledgeBaseNames ?? []; + return ( +
+ {knowledgeIds.length === 0 ? ( + + {t("aiAgent.notConfigured")} + + ) : ( + knowledgeBaseNames.map((name, index) => ( + + {name} + + )) + )} +
+ ); + }, + }, + { + key: "skills", + label: t("aiAgent.columnSkills"), + render: (item) => { + const skills = item.skills ?? []; + return ( +
+ {skills.length === 0 ? ( + + {t("aiAgent.ragOnly")} + + ) : ( + skills.map((skill) => ( + + {skill.name} + + )) + )} +
+ ); + }, + }, + { + key: "capabilities", + label: t("aiAgent.columnCapabilities"), + render: (item) => { + const skills = item.skills ?? []; + const directTools = item.directTools ?? []; + const directToolServerCodes = Array.from( + new Set(directTools.map((tool) => tool.serverCode).filter(Boolean)), + ); + return ( +
+
+ {skills.length} Skills + {directTools.length} Tools +
+
+ {directToolServerCodes.length === 0 ? ( + + {t("aiAgent.noMcpServer")} + + ) : ( + directToolServerCodes.map((serverCode) => ( + + {serverCode} + + )) + )} +
+
+ ); + }, + }, + createDashboardStatusColumn({ + label: t("aiAgent.columnStatus"), + getStatus: (item) => item.status, + getLabel: (status) => getStatusLabel(String(status), t), + getBadgeVariant: (status) => + status === Status.Ok ? "default" : "secondary", + isEnabled: (status) => status === Status.Ok, + toggle: { + getNextStatus, + updateStatus: (item, nextStatus) => + updateAIAgentStatus(item.id, nextStatus), + successMessage: (item, nextStatus) => + t("aiAgent.statusChanged", { + name: item.name, + status: + nextStatus === Status.Ok + ? t("aiAgent.enabled") + : t("aiAgent.stop"), + }), + errorMessage: t("aiAgent.statusUpdateFailed"), + ariaLabel: (item) => t("aiAgent.toggleStatus", { name: item.name }), + }, + }), + ], + [t], + ); return ( - <> - - - - - - } - > - setNameInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("aiAgent.filterName")} - className="w-full sm:w-56" - /> -
- -
- -
- - setPage(nextPage)} - onLimitChange={(nextLimit) => { - setLimit(nextLimit); - setPage(1); - }} - /> - } - > - - - - - - Agent - {t("aiAgent.columnAiConfig")} - {t("aiAgent.columnServiceMode")} - {t("aiAgent.columnKnowledge")} - {t("aiAgent.columnSkills")} - {t("aiAgent.columnCapabilities")} - {t("aiAgent.columnStatus")} - - {t("aiAgent.columnActions")} - - - - - {loading || result.results.length === 0 ? ( - - ) : null} - item.id)} - strategy={verticalListSortingStrategy} - > - {result.results.map((item) => ( - - ))} - - -
-
-
-
- - - + + filters={filters} + columns={columns} + fetchList={(query) => + fetchAIAgents({ + name: typeof query.name === "string" ? query.name : undefined, + status: typeof query.status === "string" ? query.status : undefined, + page: Number(query.page), + limit: Number(query.limit), + }) + } + getItemId={(item) => item.id} + createItem={createAIAgent} + updateItem={(item, payload) => updateAIAgent({ id: item.id, ...payload })} + deleteItem={(item) => deleteAIAgent(item.id)} + rowActions={[ + createDashboardStatusToggleAction({ + icon: , + label: (item) => + item.status === Status.Ok ? t("aiAgent.stop") : t("aiAgent.enabled"), + getNextStatus, + updateStatus: (item, nextStatus) => + updateAIAgentStatus(item.id, nextStatus), + successMessage: (item, nextStatus) => + t("aiAgent.statusChanged", { + name: item.name, + status: + nextStatus === Status.Ok + ? t("aiAgent.enabled") + : t("aiAgent.stop"), + }), + errorMessage: t("aiAgent.statusUpdateFailed"), + }), + ]} + sort={{ + enabled: true, + onReorder: (items) => updateAIAgentSort(items.map((item) => item.id)), + successMessage: t("aiAgent.sortUpdated"), + errorMessage: t("aiAgent.sortUpdateFailed"), + handleLabel: t("aiAgent.dragSort", { name: "" }), + }} + renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( + + )} + labels={{ + refresh: t("aiAgent.refresh"), + create: t("aiAgent.new"), + query: t("aiAgent.query"), + loading: t("aiAgent.loadingRows"), + empty: t("aiAgent.emptyRows"), + actions: t("aiAgent.columnActions"), + edit: t("aiAgent.edit"), + delete: t("aiAgent.delete"), + processing: t("aiAgent.processing"), + moreActions: (item) => t("aiAgent.moreActions", { name: item.name }), + loadFailed: t("aiAgent.loadFailed"), + saveFailed: t("aiAgent.saveFailed"), + deleteFailed: t("aiAgent.deleteFailed"), + created: (payload) => t("aiAgent.created", { name: payload.name }), + updated: (_item, payload) => t("aiAgent.updated", { name: payload.name }), + deleted: (item) => t("aiAgent.deleted", { name: item.name }), + }} + /> ); } diff --git a/web/app/dashboard/ai-configs/page.tsx b/web/app/dashboard/ai-configs/page.tsx index 8beedff..b0b9059 100644 --- a/web/app/dashboard/ai-configs/page.tsx +++ b/web/app/dashboard/ai-configs/page.tsx @@ -1,68 +1,14 @@ "use client"; -import { - closestCenter, - DndContext, - KeyboardSensor, - MouseSensor, - TouchSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - GripVerticalIcon, - MoreHorizontalIcon, - PlusIcon, - RefreshCwIcon, - SearchIcon, - Trash2Icon -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; -import { toast } from "sonner"; +import { useMemo } from "react"; import { - DashboardPage, - DashboardTableShell, - DashboardTableStateRow, - DashboardToolbar, -} from "@/components/dashboard-page"; -import { ListPagination } from "@/components/list-pagination"; + DashboardCrudPage, + createDashboardStatusColumn, + type DashboardCrudColumn, + type DashboardCrudFilter, +} from "@/components/dashboard/crud"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { ButtonGroup } from "@/components/ui/button-group"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { createAIConfig, deleteAIConfig, @@ -72,17 +18,10 @@ import { updateAIConfigStatus, type AIConfig, type CreateAIConfigPayload, - type PageResult, } from "@/lib/api/admin"; -import { - AIModelType, - AIProvider, - Status, -} from "@/lib/generated/enums"; +import { AIModelType, AIProvider, Status } from "@/lib/generated/enums"; import { useI18n } from "@/i18n/provider"; -import { cn } from "@/lib/utils"; import { EditDialog } from "./_components/edit"; -import { OptionCombobox } from "./_components/option-combobox"; type TFunction = (key: string, values?: Record) => string; @@ -99,199 +38,55 @@ function getProviderOptions(t: TFunction, includeAll = true) { const options = [ { value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") }, ]; - return includeAll ? [{ value: "all", label: t("aiConfig.allProviders") }, ...options] : options; + return includeAll + ? [{ value: "all", label: t("aiConfig.allProviders") }, ...options] + : options; } function getModelTypeOptions(t: TFunction, includeAll = true) { const options = [ { value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") }, - { value: String(AIModelType.Embedding), label: t("aiConfig.modelTypeEmbedding") }, + { + value: String(AIModelType.Embedding), + label: t("aiConfig.modelTypeEmbedding"), + }, { value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") }, ]; - return includeAll ? [{ value: "all", label: t("aiConfig.allTypes") }, ...options] : options; + return includeAll + ? [{ value: "all", label: t("aiConfig.allTypes") }, ...options] + : options; } function getStatusLabel(value: Status, t: TFunction) { - return getStatusOptions(t).find((item) => item.value === String(value))?.label ?? String(value); + return ( + getStatusOptions(t).find((item) => item.value === String(value))?.label ?? + String(value) + ); } function getProviderLabel(value: AIProvider, t: TFunction) { - return getProviderOptions(t, false).find((item) => item.value === String(value))?.label ?? String(value); + return ( + getProviderOptions(t, false).find((item) => item.value === String(value)) + ?.label ?? String(value) + ); } function getModelTypeLabel(value: AIModelType, t: TFunction) { - return getModelTypeOptions(t, false).find((item) => item.value === String(value))?.label ?? String(value); + return ( + getModelTypeOptions(t, false).find((item) => item.value === String(value)) + ?.label ?? String(value) + ); } function maskAPIKey(value: string) { const text = value.trim(); - if (!text) { - return "-"; - } - if (text.length <= 8) { - return "****"; - } + if (!text) return "-"; + if (text.length <= 8) return "****"; return `${text.slice(0, 4)}****${text.slice(-4)}`; } -type SortableAIConfigRowProps = { - item: AIConfig; - disabled: boolean; - actionLoadingId: number | null; - t: TFunction; - openEditDialog: (item: AIConfig) => void; - handleToggleStatus: (item: AIConfig) => void; - handleDelete: (item: AIConfig) => void; -}; - -function SortableAIConfigRow({ - item, - disabled, - actionLoadingId, - t, - openEditDialog, - handleToggleStatus, - handleDelete, -}: SortableAIConfigRowProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: item.id, - disabled, - }); - - const style: CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( - - - - - -
{item.name}
-
- - - {getProviderLabel(item.provider as AIProvider, t)} - - - -
- - {getModelTypeLabel(item.modelType as AIModelType, t)} - -
{item.modelName}
- {item.dimension > 0 && ( -
- {t("aiConfig.dimension", { count: item.dimension })} -
- )} -
-
- -
-
{item.baseUrl}
-
- {t("aiConfig.apiKey", { key: maskAPIKey(item.apiKey) })} -
-
-
- -
-
{t("aiConfig.contextTokens", { count: item.maxContextTokens || 0 })}
-
{t("aiConfig.outputTokens", { count: item.maxOutputTokens || 0 })}
-
- {t("aiConfig.timeoutRetry", { - timeout: item.timeoutMs, - retries: item.maxRetryCount, - })} -
-
- RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0} -
-
-
- -
- void handleToggleStatus(item)} - aria-label={t("aiConfig.toggleStatus", { name: item.name })} - /> - - {getStatusLabel(item.status as Status, t)} - -
-
- - - - - } - aria-label={t("aiConfig.moreActions", { name: item.name })} - > - - - - void handleDelete(item)} - className="text-destructive focus:text-destructive" - > - - {item.status === Status.Ok - ? t("aiConfig.deleteDisabledActive") - : actionLoadingId === item.id - ? t("aiConfig.deleting") - : t("aiConfig.delete")} - - - - - -
- ); +function getNextStatus(item: AIConfig) { + return item.status === Status.Ok ? Status.Disabled : Status.Ok; } export default function DashboardAIConfigsPage() { @@ -299,405 +94,213 @@ export default function DashboardAIConfigsPage() { const listStatusOptions = useMemo(() => getStatusOptions(t), [t]); const providerFilterOptions = useMemo(() => getProviderOptions(t), [t]); const modelTypeFilterOptions = useMemo(() => getModelTypeOptions(t), [t]); - const [keywordInput, setKeywordInput] = useState(""); - const [statusFilterInput, setStatusFilterInput] = useState("all"); - const [providerFilterInput, setProviderFilterInput] = useState("all"); - const [modelTypeFilterInput, setModelTypeFilterInput] = useState("all"); - const [keyword, setKeyword] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [providerFilter, setProviderFilter] = useState("all"); - const [modelTypeFilter, setModelTypeFilter] = useState("all"); - const [page, setPage] = useState(1); - const [limit, setLimit] = useState(20); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [actionLoadingId, setActionLoadingId] = useState(null); - const [sorting, setSorting] = useState(false); - const [dialogOpen, setDialogOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deletingItem, setDeletingItem] = useState(null); - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }); - const sensors = useSensors( - useSensor(MouseSensor, { - activationConstraint: { distance: 8 }, - }), - useSensor(TouchSensor, { - activationConstraint: { delay: 150, tolerance: 8 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), + const filters = useMemo( + () => [ + { + name: "name", + label: t("aiConfig.filterName"), + placeholder: t("aiConfig.filterName"), + defaultValue: "", + trim: true, + className: "w-full sm:w-72", + }, + { + name: "modelType", + label: t("aiConfig.allTypes"), + type: "select", + defaultValue: "all", + allValue: "all", + options: modelTypeFilterOptions, + className: "w-full sm:w-40", + }, + { + name: "provider", + label: t("aiConfig.allProviders"), + type: "select", + defaultValue: "all", + allValue: "all", + options: providerFilterOptions, + className: "w-full sm:w-40", + }, + { + name: "status", + label: t("aiConfig.allStatuses"), + type: "select", + defaultValue: "all", + allValue: "all", + options: listStatusOptions, + className: "w-full sm:w-32", + }, + ], + [listStatusOptions, modelTypeFilterOptions, providerFilterOptions, t], ); - const loadData = useCallback(async () => { - setLoading(true); - try { - const data = await fetchAIConfigs({ - name: keyword.trim() || undefined, - status: statusFilter === "all" ? undefined : statusFilter, - provider: providerFilter === "all" ? undefined : providerFilter, - modelType: modelTypeFilter === "all" ? undefined : modelTypeFilter, - page, - limit, - }); - setResult(data); - } catch (error) { - toast.error(error instanceof Error ? error.message : t("aiConfig.loadFailed")); - } finally { - setLoading(false); - } - }, [keyword, statusFilter, providerFilter, modelTypeFilter, page, limit, t]); - - useEffect(() => { - void loadData(); - }, [loadData]); - - function applyFilters() { - setKeyword(keywordInput); - setStatusFilter(statusFilterInput); - setProviderFilter(providerFilterInput); - setModelTypeFilter(modelTypeFilterInput); - 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); - } - - function handleLimitChange(nextLimit: number) { - if (nextLimit <= 0 || nextLimit === limit) { - return; - } - setLimit(nextLimit); - setPage(1); - } - - function openCreateDialog() { - setEditingItem(null); - setDialogOpen(true); - } - - function openEditDialog(item: AIConfig) { - setEditingItem(item); - setDialogOpen(true); - } - - function handleDialogOpenChange(open: boolean) { - if (saving) { - return; - } - if (!open) { - setEditingItem(null); - } - setDialogOpen(open); - } - - async function handleSubmit(payload: CreateAIConfigPayload) { - if (saving) { - return; - } - - setSaving(true); - try { - if (editingItem) { - await updateAIConfig({ id: editingItem.id, ...payload }); - toast.success(t("aiConfig.updated", { name: editingItem.name })); - } else { - await createAIConfig(payload); - toast.success(t("aiConfig.created", { name: payload.name })); - } - setDialogOpen(false); - setEditingItem(null); - await loadData(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t("aiConfig.saveFailed")); - } finally { - setSaving(false); - } - } - - async function handleToggleStatus(item: AIConfig) { - setActionLoadingId(item.id); - try { - const nextStatus = - item.status === Status.Ok - ? Status.Disabled - : Status.Ok; - await updateAIConfigStatus(item.id, nextStatus); - toast.success( - t("aiConfig.statusChanged", { - name: item.name, - status: nextStatus === Status.Ok ? t("aiConfig.enabled") : t("aiConfig.disabled"), - }), - ); - await loadData(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t("aiConfig.statusUpdateFailed")); - } finally { - setActionLoadingId(null); - } - } - - async function handleDelete(item: AIConfig) { - if (item.status === Status.Ok) { - toast.error(t("aiConfig.activeDeleteBlocked")); - return; - } - setDeletingItem(item); - setDeleteDialogOpen(true); - } - - async function handleConfirmDelete() { - if (!deletingItem) { - return; - } - const item = deletingItem; - setActionLoadingId(item.id); - try { - await deleteAIConfig(item.id); - toast.success(t("aiConfig.deleted", { name: item.name })); - setDeleteDialogOpen(false); - setDeletingItem(null); - await loadData(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t("aiConfig.deleteFailed")); - } finally { - setActionLoadingId(null); - } - } - - async function handleDragEnd(event: DragEndEvent) { - const { active, over } = event; - if (!over || active.id === over.id || sorting) { - return; - } - - const previousResults = result.results; - const oldIndex = previousResults.findIndex((item) => item.id === active.id); - const newIndex = previousResults.findIndex((item) => item.id === over.id); - if (oldIndex < 0 || newIndex < 0) { - return; - } - - const nextResults = arrayMove(previousResults, oldIndex, newIndex); - setResult((current) => ({ - ...current, - results: nextResults, - })); - setSorting(true); - - try { - await updateAIConfigSort(nextResults.map((item) => item.id)); - toast.success(t("aiConfig.sortUpdated")); - await loadData(); - } catch (error) { - setResult((current) => ({ - ...current, - results: previousResults, - })); - toast.error(error instanceof Error ? error.message : t("aiConfig.sortUpdateFailed")); - } finally { - setSorting(false); - } - } + const columns = useMemo[]>( + () => [ + { + key: "config", + label: t("aiConfig.columnConfig"), + render: (item) => ( +
{item.name}
+ ), + }, + { + key: "provider", + label: t("aiConfig.columnProvider"), + render: (item) => ( + + {getProviderLabel(item.provider as AIProvider, t)} + + ), + }, + { + key: "model", + label: t("aiConfig.columnModel"), + render: (item) => ( +
+ + {getModelTypeLabel(item.modelType as AIModelType, t)} + +
{item.modelName}
+ {item.dimension > 0 ? ( +
+ {t("aiConfig.dimension", { count: item.dimension })} +
+ ) : null} +
+ ), + }, + { + key: "access", + label: t("aiConfig.columnAccess"), + render: (item) => ( +
+
{item.baseUrl}
+
+ {t("aiConfig.apiKey", { key: maskAPIKey(item.apiKey) })} +
+
+ ), + }, + { + key: "limits", + label: t("aiConfig.columnLimits"), + render: (item) => ( +
+
+ {t("aiConfig.contextTokens", { + count: item.maxContextTokens || 0, + })} +
+
+ {t("aiConfig.outputTokens", { + count: item.maxOutputTokens || 0, + })} +
+
+ {t("aiConfig.timeoutRetry", { + timeout: item.timeoutMs, + retries: item.maxRetryCount, + })} +
+
+ RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0} +
+
+ ), + }, + createDashboardStatusColumn({ + label: t("aiConfig.columnStatus"), + getStatus: (item) => item.status, + getLabel: (status) => getStatusLabel(status as Status, t), + getBadgeVariant: (status) => + status === Status.Ok ? "default" : "outline", + isEnabled: (status) => status === Status.Ok, + toggle: { + getNextStatus, + updateStatus: (item, nextStatus) => + updateAIConfigStatus(item.id, nextStatus), + successMessage: (item, nextStatus) => + t("aiConfig.statusChanged", { + name: item.name, + status: + nextStatus === Status.Ok + ? t("aiConfig.enabled") + : t("aiConfig.disabled"), + }), + errorMessage: t("aiConfig.statusUpdateFailed"), + ariaLabel: (item) => t("aiConfig.toggleStatus", { name: item.name }), + }, + }), + ], + [t], + ); return ( - <> - - - - - - } - > -
- - setKeywordInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("aiConfig.filterName")} - className="pl-9" - /> -
-
- -
-
- -
-
- -
- -
- - - } - > - - - - - - {t("aiConfig.columnConfig")} - {t("aiConfig.columnProvider")} - {t("aiConfig.columnModel")} - {t("aiConfig.columnAccess")} - {t("aiConfig.columnLimits")} - {t("aiConfig.columnStatus")} - {t("aiConfig.columnActions")} - - - - {loading || result.results.length === 0 ? ( - - ) : ( - item.id)} - strategy={verticalListSortingStrategy} - > - {result.results.map((item) => ( - - ))} - - )} - -
-
-
-
- - - - { - if (actionLoadingId) { - return; - } - setDeleteDialogOpen(open); - if (!open) { - setDeletingItem(null); - } - }} - > - - - {t("aiConfig.confirmDeleteTitle")} - - {deletingItem - ? t("aiConfig.confirmDeleteDescription", { name: deletingItem.name }) - : t("aiConfig.deleteIrreversible")} - - - - - - - - - + + filters={filters} + columns={columns} + fetchList={(query) => + fetchAIConfigs({ + name: typeof query.name === "string" ? query.name : undefined, + status: typeof query.status === "string" ? query.status : undefined, + provider: + typeof query.provider === "string" ? query.provider : undefined, + modelType: + typeof query.modelType === "string" ? query.modelType : undefined, + page: Number(query.page), + limit: Number(query.limit), + }) + } + getItemId={(item) => item.id} + createItem={createAIConfig} + updateItem={(item, payload) => updateAIConfig({ id: item.id, ...payload })} + deleteItem={(item) => deleteAIConfig(item.id)} + canDelete={(item) => item.status !== Status.Ok} + deleteConfirm={(item) => ({ + title: t("aiConfig.confirmDeleteTitle"), + description: t("aiConfig.confirmDeleteDescription", { + name: item.name, + }), + confirmText: t("aiConfig.confirmDelete"), + cancelText: t("aiConfig.cancel"), + variant: "destructive", + })} + sort={{ + enabled: true, + onReorder: (items) => updateAIConfigSort(items.map((item) => item.id)), + successMessage: t("aiConfig.sortUpdated"), + errorMessage: t("aiConfig.sortUpdateFailed"), + handleLabel: t("aiConfig.dragSort", { name: "" }), + }} + renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( + + )} + labels={{ + refresh: t("aiConfig.refresh"), + create: t("aiConfig.new"), + query: t("aiConfig.query"), + loading: t("aiConfig.loadingRows"), + empty: t("aiConfig.emptyRows"), + actions: t("aiConfig.columnActions"), + edit: t("aiConfig.edit"), + delete: t("aiConfig.delete"), + processing: t("aiConfig.deleting"), + moreActions: (item) => t("aiConfig.moreActions", { name: item.name }), + loadFailed: t("aiConfig.loadFailed"), + saveFailed: t("aiConfig.saveFailed"), + deleteFailed: t("aiConfig.deleteFailed"), + created: (payload) => t("aiConfig.created", { name: payload.name }), + updated: (item) => t("aiConfig.updated", { name: item.name }), + deleted: (item) => t("aiConfig.deleted", { name: item.name }), + }} + /> ); } diff --git a/web/app/dashboard/customers/page.tsx b/web/app/dashboard/customers/page.tsx index f434a87..a188224 100644 --- a/web/app/dashboard/customers/page.tsx +++ b/web/app/dashboard/customers/page.tsx @@ -1,47 +1,17 @@ "use client"; -import { - BanIcon, - CheckCircle2Icon, - MoreHorizontalIcon, - PlusIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; +import { BanIcon, CheckCircle2Icon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { type CustomerFormSavePayload } from "@/components/customer-form"; import { - DashboardPage, - DashboardTableShell, - DashboardTableStateRow, - DashboardToolbar, -} from "@/components/dashboard-page"; -import { ListPagination } from "@/components/list-pagination"; -import { - OptionCombobox, - type ComboboxOption, -} from "@/components/option-combobox"; -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 { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { type PageResult } from "@/lib/api/admin"; + DashboardCrudPage, + createDashboardStatusColumn, + createDashboardStatusToggleAction, + type DashboardCrudColumn, + type DashboardCrudFilter, +} from "@/components/dashboard/crud"; +import { type ComboboxOption } from "@/components/option-combobox"; import { fetchCompanies, type AdminCompany } from "@/lib/api/company"; import { deleteCustomer, @@ -54,26 +24,16 @@ import { Gender, Status } from "@/lib/generated/enums"; import { useI18n } from "@/i18n/provider"; import { EditDialog } from "./_components/edit"; -function getLabel( - value: string, - options: ReadonlyArray<{ value: string; label: string }>, - fallback: string, -) { - return options.find((item) => item.value === value)?.label ?? fallback; +type TFunction = (key: string, values?: Record) => string; + +function getGenderText(gender: number, t: TFunction) { + if (gender === Gender.Male) return t("customerForm.genderMale"); + if (gender === Gender.Female) return t("customerForm.genderFemale"); + return t("customerForm.genderUnknown"); } export default function DashboardCustomersPage() { const t = useI18n(); - const [keywordInput, setKeywordInput] = useState(""); - const [statusFilterInput, setStatusFilterInput] = useState("all"); - const [genderFilterInput, setGenderFilterInput] = useState("all"); - const [companyFilterInput, setCompanyFilterInput] = useState("0"); - - const [keyword, setKeyword] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [genderFilter, setGenderFilter] = useState("all"); - const [companyFilter, setCompanyFilter] = useState("0"); - const [companyOptions, setCompanyOptions] = useState([ { value: "0", label: t("customer.allCompanies") }, ]); @@ -81,17 +41,6 @@ export default function DashboardCustomersPage() { {}, ); - const [page, setPage] = useState(1); - const [limit, setLimit] = useState(20); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [actionLoadingId, setActionLoadingId] = useState(null); - const [dialogOpen, setDialogOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }); const listStatusOptions = useMemo( () => [ { value: "all", label: t("status.all") }, @@ -114,337 +63,204 @@ export default function DashboardCustomersPage() { async function loadCompanies() { try { const data = await fetchCompanies({ status: 0, page: 1, limit: 500 }); - const opts: ComboboxOption[] = [ + setCompanyOptions([ { value: "0", label: t("customer.allCompanies") }, ...data.results.map((item) => ({ value: String(item.id), label: item.name, })), - ]; - setCompanyOptions(opts); + ]); const map: Record = {}; data.results.forEach((item: AdminCompany) => { map[item.id] = item.name; }); setCompanyNameMap(map); } catch { - // ignore + // Company names are optional display enrichment for this list. } } void loadCompanies(); }, [t]); - const loadData = useCallback(async () => { - setLoading(true); - try { - const data = await fetchCustomers({ - keyword: keyword.trim() || undefined, - status: statusFilter === "all" ? undefined : Number(statusFilter), - gender: genderFilter === "all" ? undefined : Number(genderFilter), - companyId: companyFilter === "0" ? undefined : Number(companyFilter), - page, - limit, - }); - setResult(data); - } catch (error) { - toast.error(error instanceof Error ? error.message : t("customer.loadFailed")); - } finally { - setLoading(false); - } - }, [companyFilter, genderFilter, keyword, limit, page, statusFilter, t]); + const filters = useMemo( + () => [ + { + name: "keyword", + label: t("customer.columnName"), + placeholder: t("customer.keywordPlaceholder"), + defaultValue: "", + trim: true, + className: "w-full sm:w-72", + }, + { + name: "gender", + label: t("customer.columnGender"), + type: "select", + defaultValue: "all", + allValue: "all", + valueType: "number", + options: genderOptions, + className: "w-full sm:w-36", + }, + { + name: "companyId", + label: t("customer.columnCompany"), + type: "select", + defaultValue: "0", + allValue: "0", + valueType: "number", + options: companyOptions, + className: "w-full sm:w-56", + }, + { + name: "status", + label: t("customer.columnStatus"), + type: "select", + defaultValue: "all", + allValue: "all", + valueType: "number", + options: listStatusOptions, + className: "w-full sm:w-36", + }, + ], + [companyOptions, genderOptions, listStatusOptions, t], + ); - useEffect(() => { - void loadData(); - }, [loadData]); - - const companyFilterLabel = useMemo(() => { - return ( - companyOptions.find((item) => item.value === companyFilterInput)?.label ?? - t("customer.allCompanies") - ); - }, [companyFilterInput, companyOptions, t]); - - function applyFilters() { - setKeyword(keywordInput); - setStatusFilter(statusFilterInput); - setGenderFilter(genderFilterInput); - setCompanyFilter(companyFilterInput); - 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); - } - - function openCreateDialog() { - setEditingItem(null); - setDialogOpen(true); - } - - function openEditDialog(item: AdminCustomer) { - setEditingItem(item); - setDialogOpen(true); - } - - function handleDialogOpenChange(open: boolean) { - if (saving) return; - if (!open) setEditingItem(null); - setDialogOpen(open); - } - - async function handleSave(payload: CustomerFormSavePayload) { - if (saving) return; - setSaving(true); - try { - await saveCustomerProfile(payload); - toast.success( - editingItem - ? t("customer.updated", { name: editingItem.name }) - : t("customer.created", { name: payload.name }), - ); - setDialogOpen(false); - setEditingItem(null); - await loadData(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t("customer.saveFailed")); - } finally { - setSaving(false); - } - } - - async function handleToggleStatus(item: AdminCustomer) { - setActionLoadingId(item.id); - try { - const nextStatus = item.status === 0 ? 1 : 0; - await updateCustomerStatus(item.id, nextStatus); - toast.success(t(nextStatus === 0 ? "customer.enabled" : "customer.disabled", { name: item.name })); - await loadData(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t("customer.statusUpdateFailed")); - } finally { - setActionLoadingId(null); - } - } - - async function handleDelete(item: AdminCustomer) { - setActionLoadingId(item.id); - try { - await deleteCustomer(item.id); - toast.success(t("customer.deleted", { name: item.name })); - await loadData(); - } catch (error) { - toast.error(error instanceof Error ? error.message : t("customer.deleteFailed")); - } finally { - setActionLoadingId(null); - } - } - - function getGenderText(gender: number) { - if (gender === Gender.Male) return t("customerForm.genderMale"); - if (gender === Gender.Female) return t("customerForm.genderFemale"); - return t("customerForm.genderUnknown"); - } + const columns = useMemo[]>( + () => [ + { + key: "id", + label: "ID", + className: "w-20", + render: (item) => item.id, + }, + { + key: "name", + label: t("customer.columnName"), + render: (item) => {item.name}, + }, + { + key: "gender", + label: t("customer.columnGender"), + className: "w-20", + render: (item) => ( + + {getGenderText(item.gender, t)} + + ), + }, + { + key: "company", + label: t("customer.columnCompany"), + render: (item) => ( + + {item.companyId > 0 + ? (companyNameMap[item.companyId] ?? String(item.companyId)) + : "-"} + + ), + }, + { + key: "mobile", + label: t("customer.columnMobile"), + render: (item) => ( + + {item.primaryMobile || "-"} + + ), + }, + { + key: "email", + label: t("customer.columnEmail"), + render: (item) => ( + + {item.primaryEmail || "-"} + + ), + }, + createDashboardStatusColumn({ + label: t("customer.columnStatus"), + className: "w-24", + getStatus: (item) => item.status, + getLabel: (status) => + status === Status.Ok ? t("status.ok") : t("status.disabled"), + getBadgeVariant: (status) => + status === Status.Ok ? "default" : "secondary", + }), + ], + [companyNameMap, t], + ); return ( - <> - - - - {t("customer.new")} - - } - > -
- - setKeywordInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("customer.keywordPlaceholder")} - className="pl-9" - /> -
- -
- setGenderFilterInput(v)} - /> -
- -
- setCompanyFilterInput(v)} - /> -
- -
- setStatusFilterInput(v)} - /> -
- - -
- - { - setLimit(nextLimit); - setPage(1); - }} - /> - } - > - - - - ID - {t("customer.columnName")} - {t("customer.columnGender")} - {t("customer.columnCompany")} - {t("customer.columnMobile")} - {t("customer.columnEmail")} - {t("customer.columnStatus")} - {t("customer.columnActions")} - - - - {loading || result.results.length === 0 ? ( - - ) : ( - result.results.map((item) => { - const actionLoading = actionLoadingId === item.id; - return ( - - {item.id} - {item.name} - - {getGenderText(item.gender)} - - - {item.companyId > 0 - ? (companyNameMap[item.companyId] ?? - String(item.companyId)) - : "-"} - - - {item.primaryMobile || "-"} - - - {item.primaryEmail || "-"} - - - - {item.status === 0 ? t("status.ok") : t("status.disabled")} - - - - - - - - } - aria-label={t("customer.moreActions", { name: item.name })} - > - - - - void handleToggleStatus(item)} - > - {actionLoadingId === item.id ? ( - t("customer.processing") - ) : item.status === 0 ? ( - <> - - {t("customer.disable")} - - ) : ( - <> - - {t("customer.enable")} - - )} - - void handleDelete(item)} - > - - {t("customer.delete")} - - - - - - - ); - }) - )} - -
-
-
- - - + + filters={filters} + columns={columns} + fetchList={(query) => + fetchCustomers({ + keyword: + typeof query.keyword === "string" ? query.keyword : undefined, + status: + typeof query.status === "number" ? query.status : undefined, + gender: + typeof query.gender === "number" ? query.gender : undefined, + companyId: + typeof query.companyId === "number" ? query.companyId : undefined, + page: Number(query.page), + limit: Number(query.limit), + }) + } + getItemId={(item) => item.id} + createItem={saveCustomerProfile} + updateItem={(_item, payload) => saveCustomerProfile(payload)} + deleteItem={(item) => deleteCustomer(item.id)} + canDelete={(item) => item.status !== Status.Deleted} + rowActions={[ + createDashboardStatusToggleAction({ + icon: (item) => + item.status === Status.Ok ? : , + label: (item) => + item.status === Status.Ok + ? t("customer.disable") + : t("customer.enable"), + disabled: (item) => item.status === Status.Deleted, + getNextStatus: (item) => + item.status === Status.Ok ? Status.Disabled : Status.Ok, + updateStatus: (item, nextStatus) => + updateCustomerStatus(item.id, nextStatus), + successMessage: (item, nextStatus) => + t(nextStatus === Status.Ok ? "customer.enabled" : "customer.disabled", { + name: item.name, + }), + errorMessage: t("customer.statusUpdateFailed"), + }), + ]} + renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( + + )} + labels={{ + refresh: t("customer.refresh"), + create: t("customer.new"), + query: t("customer.query"), + loading: t("customer.loading"), + empty: t("customer.empty"), + actions: t("customer.columnActions"), + edit: t("customer.edit"), + delete: t("customer.delete"), + processing: t("customer.processing"), + moreActions: (item) => t("customer.moreActions", { name: item.name }), + loadFailed: t("customer.loadFailed"), + saveFailed: t("customer.saveFailed"), + deleteFailed: t("customer.deleteFailed"), + created: (payload) => t("customer.created", { name: payload.name }), + updated: (item) => t("customer.updated", { name: item.name }), + deleted: (item) => t("customer.deleted", { name: item.name }), + }} + /> ); } diff --git a/web/app/dashboard/skill-definition/page.tsx b/web/app/dashboard/skill-definition/page.tsx index 3559229..da917c3 100644 --- a/web/app/dashboard/skill-definition/page.tsx +++ b/web/app/dashboard/skill-definition/page.tsx @@ -1,45 +1,16 @@ -"use client" +"use client"; -import { useCallback, useEffect, useMemo, useState } from "react" -import { - BrainCircuitIcon, - BugIcon, - MoreHorizontalIcon, - PlusIcon, - RefreshCwIcon, - RotateCcwIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react" -import { toast } from "sonner" +import { BrainCircuitIcon, BugIcon, RotateCcwIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; import { - 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 { 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 { Switch } from "@/components/ui/switch" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" + DashboardCrudPage, + createDashboardStatusColumn, + type DashboardCrudColumn, + type DashboardCrudFilter, +} from "@/components/dashboard/crud"; +import { Badge } from "@/components/ui/badge"; import { createSkillDefinition, deleteSkillDefinition, @@ -48,28 +19,21 @@ import { updateSkillDefinition, updateSkillDefinitionStatus, type CreateSkillDefinitionPayload, - type PageResult, type SkillDefinition, -} from "@/lib/api/admin" -import { useI18n } from "@/i18n/provider" -import { Status } from "@/lib/generated/enums" -import { formatDateTime } from "@/lib/utils" -import { EditDialog } from "./_components/edit" -import { DebugDialog } from "./_components/debug-dialog" +} from "@/lib/api/admin"; +import { useI18n } from "@/i18n/provider"; +import { Status } from "@/lib/generated/enums"; +import { formatDateTime } from "@/lib/utils"; +import { EditDialog } from "./_components/edit"; +import { DebugDialog } from "./_components/debug-dialog"; -type TFunction = (key: string, values?: Record) => string +type TFunction = (key: string, values?: Record) => string; function statusLabel(status: number, t: TFunction) { - if (status === Status.Ok) { - return t("skillDefinition.statusOk") - } - if (status === Status.Disabled) { - return t("skillDefinition.statusDisabled") - } - if (status === Status.Deleted) { - return t("skillDefinition.statusDeleted") - } - return String(status) + if (status === Status.Ok) return t("skillDefinition.statusOk"); + if (status === Status.Disabled) return t("skillDefinition.statusDisabled"); + if (status === Status.Deleted) return t("skillDefinition.statusDeleted"); + return String(status); } function getStatusFilterOptions(t: TFunction) { @@ -78,428 +42,223 @@ function getStatusFilterOptions(t: TFunction) { { value: String(Status.Ok), label: t("skillDefinition.statusOk") }, { value: String(Status.Disabled), label: t("skillDefinition.statusDisabled") }, { value: String(Status.Deleted), label: t("skillDefinition.statusDeleted") }, - ] + ]; } -type SkillRowProps = { - item: SkillDefinition - actionLoadingId: number | null - openEditDialog: (item: SkillDefinition) => void - openDebugDialog: (item: SkillDefinition) => void - handleToggleStatus: (item: SkillDefinition) => void - handleDelete: (item: SkillDefinition) => void - handleRestore: (item: SkillDefinition) => void - t: TFunction +function statusBadgeVariant(status: number) { + if (status === Status.Deleted) return "destructive"; + if (status === Status.Ok) return "default"; + return "outline"; } -function SkillRow({ - item, - actionLoadingId, - openEditDialog, - openDebugDialog, - handleToggleStatus, - handleDelete, - handleRestore, - t, -}: SkillRowProps) { - const isDeleted = item.status === Status.Deleted - const statusBadgeVariant = isDeleted - ? "destructive" - : item.status === Status.Ok - ? "default" - : "outline" - - return ( - - -
-
- -
-
-
-
{item.name}
- {item.code} - {t("skillDefinition.whitelistCount", { count: item.toolWhitelist.length })} - {t("skillDefinition.exampleCount", { count: item.examples.length })} -
-
-
- {item.description || t("skillDefinition.noDescription")} -
-
- {item.toolWhitelist.length > 0 ? ( -
- {item.toolWhitelist.slice(0, 3).map((toolCode) => ( - - {toolCode} - - ))} - {item.toolWhitelist.length > 3 ? ( - +{item.toolWhitelist.length - 3} - ) : null} -
- ) : null} -
-
-
- -
- void handleToggleStatus(item)} - aria-label={t("skillDefinition.toggleStatus", { name: item.name })} - /> - - {statusLabel(item.status, t)} - -
-
- -
-
{formatDateTime(item.updatedAt)}
-
- {item.updateUserName || "-"} -
-
-
- - - - - - } - aria-label={t("skillDefinition.moreActions", { name: item.name })} - > - - - - {isDeleted ? ( - void handleRestore(item)} - > - - {actionLoadingId === item.id ? t("skillDefinition.restoring") : t("skillDefinition.restore")} - - ) : ( - void handleDelete(item)} - className="text-destructive focus:text-destructive" - > - - {actionLoadingId === item.id ? t("skillDefinition.deleting") : t("skillDefinition.delete")} - - )} - - - - -
- ) +function getNextStatus(item: SkillDefinition) { + return item.status === Status.Ok ? Status.Disabled : Status.Ok; } export default function DashboardSkillsPage() { - const t = useI18n() - const [nameInput, setNameInput] = useState("") - const [codeInput, setCodeInput] = useState("") - const [statusFilterInput, setStatusFilterInput] = useState("all") - const [name, setName] = useState("") - const [code, setCode] = useState("") - const [statusFilter, setStatusFilter] = useState("all") - const [page, setPage] = useState(1) - const [limit, setLimit] = useState(20) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [actionLoadingId, setActionLoadingId] = useState(null) - const [dialogOpen, setDialogOpen] = useState(false) - const [debugDialogOpen, setDebugDialogOpen] = useState(false) - const [editingItem, setEditingItem] = useState(null) - const [debuggingItem, setDebuggingItem] = useState(null) - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }) - const statusFilterOptions = useMemo(() => getStatusFilterOptions(t), [t]) + const t = useI18n(); + const [debugDialogOpen, setDebugDialogOpen] = useState(false); + const [debuggingItem, setDebuggingItem] = useState( + null, + ); + const statusFilterOptions = useMemo(() => getStatusFilterOptions(t), [t]); - const loadData = useCallback(async () => { - setLoading(true) - try { - const data = await fetchSkillDefinitions({ - name: name.trim() || undefined, - code: code.trim() || undefined, - status: statusFilter === "all" ? undefined : Number(statusFilter), - page, - limit, - }) - setResult(data) - } catch (error) { - toast.error(error instanceof Error ? error.message : t("skillDefinition.loadFailed")) - } finally { - setLoading(false) - } - }, [name, code, statusFilter, page, limit, t]) + const filters = useMemo( + () => [ + { + name: "name", + label: t("skillDefinition.filterName"), + placeholder: t("skillDefinition.filterName"), + defaultValue: "", + trim: true, + className: "w-full sm:w-72", + }, + { + name: "code", + label: t("skillDefinition.filterCode"), + placeholder: t("skillDefinition.filterCode"), + defaultValue: "", + trim: true, + className: "w-full sm:w-56", + }, + { + name: "status", + label: t("skillDefinition.allStatus"), + type: "select", + defaultValue: "all", + allValue: "all", + valueType: "number", + options: statusFilterOptions, + className: "w-full sm:w-36", + }, + ], + [statusFilterOptions, t], + ); - useEffect(() => { - void loadData() - }, [loadData]) - - function applyFilters() { - setName(nameInput) - setCode(codeInput) - 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) - } - - function openCreateDialog() { - setEditingItem(null) - setDialogOpen(true) - } - - function openEditDialog(item: SkillDefinition) { - setEditingItem(item) - setDialogOpen(true) - } - - function openDebugDialog(item: SkillDefinition) { - setDebuggingItem(item) - setDebugDialogOpen(true) - } - - function handleDialogOpenChange(open: boolean) { - if (saving) { - return - } - if (!open) { - setEditingItem(null) - } - setDialogOpen(open) - } - - function handleDebugDialogOpenChange(open: boolean) { - if (!open) { - setDebuggingItem(null) - } - setDebugDialogOpen(open) - } - - async function handleSubmit(payload: CreateSkillDefinitionPayload) { - if (saving) { - return - } - - setSaving(true) - try { - if (editingItem) { - await updateSkillDefinition({ - id: editingItem.id, - ...payload, - }) - toast.success(t("skillDefinition.updated", { name: editingItem.name })) - } else { - await createSkillDefinition(payload) - toast.success(t("skillDefinition.created", { name: payload.name })) - } - setDialogOpen(false) - setEditingItem(null) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("skillDefinition.saveFailed")) - } finally { - setSaving(false) - } - } - - async function handleToggleStatus(item: SkillDefinition) { - if (item.status === Status.Deleted) { - return - } - - const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok - - setActionLoadingId(item.id) - try { - await updateSkillDefinitionStatus(item.id, nextStatus) - toast.success(t(nextStatus === Status.Ok ? "skillDefinition.enabled" : "skillDefinition.disabled", { name: item.name })) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("skillDefinition.statusUpdateFailed")) - } finally { - setActionLoadingId(null) - } - } - - async function handleDelete(item: SkillDefinition) { - if (item.status === Status.Deleted) { - return - } - - setActionLoadingId(item.id) - try { - await deleteSkillDefinition(item.id) - toast.success(t("skillDefinition.deleted", { name: item.name })) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("skillDefinition.deleteFailed")) - } finally { - setActionLoadingId(null) - } - } - - async function handleRestore(item: SkillDefinition) { - if (item.status !== Status.Deleted) { - return - } - - setActionLoadingId(item.id) - try { - await restoreSkillDefinition(item.id) - toast.success(t("skillDefinition.restored", { name: item.name })) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("skillDefinition.restoreFailed")) - } finally { - setActionLoadingId(null) - } - } + const columns = useMemo[]>( + () => [ + { + key: "skill", + label: "Skill", + render: (item) => ( +
+
+ +
+
+
+
{item.name}
+ {item.code} + + {t("skillDefinition.whitelistCount", { + count: item.toolWhitelist.length, + })} + + + {t("skillDefinition.exampleCount", { + count: item.examples.length, + })} + +
+
+ {item.description || t("skillDefinition.noDescription")} +
+ {item.toolWhitelist.length > 0 ? ( +
+ {item.toolWhitelist.slice(0, 3).map((toolCode) => ( + + {toolCode} + + ))} + {item.toolWhitelist.length > 3 ? ( + + +{item.toolWhitelist.length - 3} + + ) : null} +
+ ) : null} +
+
+ ), + }, + createDashboardStatusColumn({ + label: t("skillDefinition.status"), + getStatus: (item) => item.status, + getLabel: (status) => statusLabel(status, t), + getBadgeVariant: statusBadgeVariant, + isEnabled: (status) => status === Status.Ok, + toggle: { + disabled: (item) => item.status === Status.Deleted, + getNextStatus, + updateStatus: (item, nextStatus) => + updateSkillDefinitionStatus(item.id, nextStatus), + successMessage: (item, nextStatus) => + t(nextStatus === Status.Ok ? "skillDefinition.enabled" : "skillDefinition.disabled", { + name: item.name, + }), + errorMessage: t("skillDefinition.statusUpdateFailed"), + ariaLabel: (item) => + t("skillDefinition.toggleStatus", { name: item.name }), + }, + }), + { + key: "updatedAt", + label: t("skillDefinition.updatedAt"), + render: (item) => ( +
+
{formatDateTime(item.updatedAt)}
+
+ {item.updateUserName || "-"} +
+
+ ), + }, + ], + [t], + ); return ( <> - - - - - - } - > -
- - setNameInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("skillDefinition.filterName")} - className="pl-9" - /> -
- setCodeInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("skillDefinition.filterCode")} - className="w-full sm:w-56" + + filters={filters} + columns={columns} + fetchList={(query) => + fetchSkillDefinitions({ + name: typeof query.name === "string" ? query.name : undefined, + code: typeof query.code === "string" ? query.code : undefined, + status: typeof query.status === "number" ? query.status : undefined, + page: Number(query.page), + limit: Number(query.limit), + }) + } + getItemId={(item) => item.id} + createItem={createSkillDefinition} + updateItem={(item, payload) => + updateSkillDefinition({ id: item.id, ...payload }) + } + deleteItem={(item) => deleteSkillDefinition(item.id)} + canDelete={(item) => item.status !== Status.Deleted} + rowActions={[ + { + key: "debug", + icon: , + label: t("skillDefinition.debug"), + run: ({ item }) => { + setDebuggingItem(item); + setDebugDialogOpen(true); + }, + }, + { + key: "restore", + icon: , + label: t("skillDefinition.restore"), + visible: (item) => item.status === Status.Deleted, + run: async ({ item, reload }) => { + await restoreSkillDefinition(item.id); + toast.success(t("skillDefinition.restored", { name: item.name })); + await reload(); + }, + }, + ]} + renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( + -
- -
- -
- - { - setLimit(nextLimit) - setPage(1) - }} - /> - } - > - - - - Skill - {t("skillDefinition.status")} - {t("skillDefinition.updatedAt")} - {t("skillDefinition.actions")} - - - - {loading || result.results.length === 0 ? ( - - ) : null} - {result.results.map((item) => ( - - ))} - -
-
-
- - + t("skillDefinition.moreActions", { name: item.name }), + loadFailed: t("skillDefinition.loadFailed"), + saveFailed: t("skillDefinition.saveFailed"), + deleteFailed: t("skillDefinition.deleteFailed"), + created: (payload) => + t("skillDefinition.created", { name: payload.name }), + updated: (item) => t("skillDefinition.updated", { name: item.name }), + deleted: (item) => t("skillDefinition.deleted", { name: item.name }), + }} /> { + if (!open) setDebuggingItem(null); + setDebugDialogOpen(open); + }} /> - ) + ); } diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 0f5ab5e..29f4de6 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -634,6 +634,7 @@ "deleted": "Customer deleted: {name}", "deleteFailed": "Could not delete the customer.", "new": "New", + "refresh": "Refresh", "keywordPlaceholder": "Name, phone, email, company, or contact", "searchCompany": "Search companies", "query": "Search", @@ -932,6 +933,7 @@ "toggleStatus": "Toggle status for {name}", "edit": "Edit", "moreActions": "More actions for {name}", + "processing": "Working...", "stop": "Disable", "delete": "Delete", "loadingRows": "Loading AI agents...", @@ -1500,6 +1502,7 @@ "restoring": "Restoring...", "restore": "Restore", "deleting": "Deleting...", + "processing": "Working...", "delete": "Delete", "loadFailed": "Could not load skills.", "updated": "Skill updated: {name}", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 3ecaa96..a1144f8 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -634,6 +634,7 @@ "deleted": "已删除客户:{name}", "deleteFailed": "删除客户失败", "new": "新建", + "refresh": "刷新", "keywordPlaceholder": "姓名、手机、邮箱、公司、联系方式", "searchCompany": "搜索公司名称", "query": "查询", @@ -932,6 +933,7 @@ "toggleStatus": "{name} 状态切换", "edit": "编辑", "moreActions": "更多操作 {name}", + "processing": "处理中...", "stop": "停用", "delete": "删除", "loadingRows": "正在加载 AI Agent...", @@ -1500,6 +1502,7 @@ "restoring": "恢复中...", "restore": "恢复", "deleting": "删除中...", + "processing": "处理中...", "delete": "删除", "loadFailed": "加载 Skills 失败", "updated": "已更新 Skill:{name}",