diff --git a/web/app/dashboard/channels/page.tsx b/web/app/dashboard/channels/page.tsx index ae93e4c..d377d20 100644 --- a/web/app/dashboard/channels/page.tsx +++ b/web/app/dashboard/channels/page.tsx @@ -1,18 +1,15 @@ "use client" -import { useCallback, useEffect, useState } from "react" import { Building2Icon, MessagesSquareIcon, MessageSquareMoreIcon, - MoreHorizontalIcon, - PlusIcon, - RefreshCwIcon, - SearchIcon, - Trash2Icon, } from "lucide-react" import { toast } from "sonner" +import { DashboardCrudPage } from "@/components/dashboard/crud" +import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" import { createChannel, deleteChannel, @@ -21,39 +18,11 @@ import { updateChannelStatus, type AdminChannel, type CreateAdminChannelPayload, - type PageResult, } from "@/lib/api/admin" -import { - DashboardPage, - DashboardTableShell, - DashboardTableStateRow, - DashboardToolbar, -} from "@/components/dashboard-page" -import { OptionCombobox } from "@/components/option-combobox" -import { EditDialog } from "./_components/edit" -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 { ListPagination } from "@/components/list-pagination" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { Status, StatusLabels } from "@/lib/generated/enums" import { getEnumOptions } from "@/lib/enums" -import { ButtonGroup } from "@/components/ui/button-group" -import { Switch } from "@/components/ui/switch" +import { Status, StatusLabels } from "@/lib/generated/enums" import { useI18n } from "@/i18n/provider" +import { EditDialog } from "./_components/edit" function getChannelTypeLabel(channelType: string, t: (key: string) => string) { if (channelType === "wechat_mp") { @@ -87,45 +56,6 @@ function ChannelIcon({ channelType }: { channelType: string }) { export default function DashboardChannelsPage() { const t = useI18n() - const [nameInput, setNameInput] = useState("") - const [channelIdInput, setChannelIdInput] = useState("") - const [channelTypeInput, setChannelTypeInput] = useState("all") - const [statusInput, setStatusInput] = useState("all") - const [name, setName] = useState("") - const [channelId, setChannelId] = useState("") - const [channelType, setChannelType] = useState("all") - 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 [dialogOpen, setDialogOpen] = useState(false) - const [editingItem, setEditingItem] = useState(null) - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }) - - const loadData = useCallback(async () => { - setLoading(true) - try { - const data = await fetchChannels({ - name: name.trim() || undefined, - channelId: channelId.trim() || undefined, - channelType: channelType === "all" ? undefined : channelType, - status: status === "all" ? undefined : status, - page, - limit, - }) - setResult(data) - } catch (error) { - toast.error(error instanceof Error ? error.message : t("channel.loadFailed")) - } finally { - setLoading(false) - } - }, [channelId, channelType, limit, name, page, status, t]) - const statusOptions = [ { value: "all", label: t("status.all") }, ...getEnumOptions(StatusLabels).map((option) => ({ @@ -133,7 +63,6 @@ export default function DashboardChannelsPage() { label: getStatusLabel(option.value as Status, t), })), ] - const channelTypeOptions = [ { value: "all", label: t("channel.allTypes") }, { value: "web", label: t("channel.typeWeb") }, @@ -141,251 +70,159 @@ export default function DashboardChannelsPage() { { value: "wxwork_kf", label: t("channel.typeWxworkKf") }, ] - useEffect(() => { - void loadData() - }, [loadData]) - - function applyFilters() { - setName(nameInput) - setChannelId(channelIdInput) - setChannelType(channelTypeInput) - setStatus(statusInput) - setPage(1) - } - - function handleFilterKeyDown(event: React.KeyboardEvent) { - if (event.key !== "Enter") { - return - } - event.preventDefault() - applyFilters() - } - - function openCreateDialog() { - setEditingItem(null) - setDialogOpen(true) - } - - function openEditDialog(item: AdminChannel) { - setEditingItem(item) - setDialogOpen(true) - } - - async function handleSubmit(payload: CreateAdminChannelPayload) { - if (saving) { - return - } - setSaving(true) - try { - if (editingItem) { - await updateChannel({ id: editingItem.id, ...payload }) - toast.success(t("channel.updated", { name: payload.name })) - } else { - const created = await createChannel(payload) - toast.success(t("channel.created", { name: created.name })) - } - setDialogOpen(false) - setEditingItem(null) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("channel.saveFailed")) - } finally { - setSaving(false) - } - } - - async function handleToggleStatus(item: AdminChannel) { - setActionLoadingId(item.id) - try { - const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok - await updateChannelStatus(item.id, nextStatus) - toast.success(t(nextStatus === Status.Ok ? "channel.statusEnabled" : "channel.statusDisabled", { name: item.name })) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("channel.statusUpdateFailed")) - } finally { - setActionLoadingId(null) - } - } - - async function handleDelete(item: AdminChannel) { - setActionLoadingId(item.id) - try { - await deleteChannel(item.id) - toast.success(t("channel.deleted", { name: item.name })) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("channel.deleteFailed")) - } finally { - setActionLoadingId(null) - } - } - return ( - <> - - - - - - } - > - setNameInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("channel.filterName")} - className="w-full sm:w-56" - /> -
- - setChannelIdInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("channel.filterChannelId")} - className="pl-9" - /> -
-
- -
-
- -
- -
- - setPage(nextPage)} - onLimitChange={(nextLimit) => { - setLimit(nextLimit) - setPage(1) - }} - /> - } - > - - - - {t("channel.columnChannel")} - {t("channel.columnType")} - ChannelID - {t("channel.columnAgent")} - {t("channel.columnStatus")} - {t("channel.columnActions")} - - - - {loading || result.results.length === 0 ? ( - - ) : null} - {result.results.map((item) => ( - - -
-
- -
-
-
{item.name}
-
{getChannelTypeLabel(item.channelType, t)}
-
-
-
- - {getChannelTypeLabel(item.channelType, t)} - - {item.channelId || "-"} - {item.aiAgentName || "-"} - -
- void handleToggleStatus(item)} - aria-label={t("channel.toggleStatus", { name: item.name })} - /> - - {getStatusLabel(item.status as Status, t)} - -
-
- - - - - } - aria-label={t("channel.moreActions", { name: item.name })} - > - - - - void handleDelete(item)} - > - - {t("channel.delete")} - - - - - -
- ))} -
-
-
-
- - - + + filters={[ + { + name: "name", + label: t("channel.filterName"), + placeholder: t("channel.filterName"), + defaultValue: "", + trim: true, + className: "w-full sm:w-56", + }, + { + name: "channelId", + label: t("channel.filterChannelId"), + placeholder: t("channel.filterChannelId"), + defaultValue: "", + trim: true, + className: "w-full sm:w-72", + }, + { + name: "channelType", + label: t("channel.allTypes"), + type: "select", + defaultValue: "all", + allValue: "all", + options: channelTypeOptions, + className: "w-full sm:w-40", + }, + { + name: "status", + label: t("status.all"), + type: "select", + defaultValue: "all", + allValue: "all", + options: statusOptions, + className: "w-full sm:w-36", + }, + ]} + columns={[ + { + key: "channel", + label: t("channel.columnChannel"), + render: (item) => ( +
+
+ +
+
+
{item.name}
+
+ {getChannelTypeLabel(item.channelType, t)} +
+
+
+ ), + }, + { + key: "type", + label: t("channel.columnType"), + render: (item) => ( + + {getChannelTypeLabel(item.channelType, t)} + + ), + }, + { + key: "channelId", + label: "ChannelID", + render: (item) => ( + {item.channelId || "-"} + ), + }, + { + key: "agent", + label: t("channel.columnAgent"), + render: (item) => item.aiAgentName || "-", + }, + { + key: "status", + label: t("channel.columnStatus"), + render: (item, { actionLoading, reload, setActionLoadingId }) => ( +
+ { + void (async () => { + setActionLoadingId(item.id) + try { + const nextStatus = + item.status === Status.Ok ? Status.Disabled : Status.Ok + await updateChannelStatus(item.id, nextStatus) + toast.success( + t( + nextStatus === Status.Ok + ? "channel.statusEnabled" + : "channel.statusDisabled", + { name: item.name } + ) + ) + await reload() + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t("channel.statusUpdateFailed") + ) + } finally { + setActionLoadingId(null) + } + })() + }} + aria-label={t("channel.toggleStatus", { name: item.name })} + /> + + {getStatusLabel(item.status as Status, t)} + +
+ ), + }, + ]} + fetchList={fetchChannels} + getItemId={(item) => item.id} + createItem={createChannel} + updateItem={(item, payload) => updateChannel({ id: item.id, ...payload })} + deleteItem={(item) => deleteChannel(item.id)} + renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( + + )} + labels={{ + refresh: t("channel.refresh"), + create: t("channel.new"), + query: t("channel.query"), + loading: t("channel.loading"), + empty: t("channel.empty"), + actions: t("channel.columnActions"), + edit: t("channel.edit"), + delete: t("channel.delete"), + processing: t("channel.processing"), + moreActions: (item) => t("channel.moreActions", { name: item.name }), + loadFailed: t("channel.loadFailed"), + saveFailed: t("channel.saveFailed"), + deleteFailed: t("channel.deleteFailed"), + created: (payload) => t("channel.created", { name: payload.name }), + updated: (_item, payload) => t("channel.updated", { name: payload.name }), + deleted: (item) => t("channel.deleted", { name: item.name }), + }} + /> ) } diff --git a/web/app/dashboard/companies/page.tsx b/web/app/dashboard/companies/page.tsx index 31c3ccc..bcb8a8b 100644 --- a/web/app/dashboard/companies/page.tsx +++ b/web/app/dashboard/companies/page.tsx @@ -1,44 +1,11 @@ "use client" -import { - BanIcon, - CheckCircle2Icon, - MoreHorizontalIcon, - PlusIcon, - RefreshCwIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react" -import { useCallback, useEffect, useState } from "react" +import { BanIcon, CheckCircle2Icon } from "lucide-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 { DashboardCrudPage } from "@/components/dashboard/crud" import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { ButtonGroup } from "@/components/ui/button-group" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Input } from "@/components/ui/input" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { type PageResult } from "@/lib/api/admin" +import { DropdownMenuItem } from "@/components/ui/dropdown-menu" import { createCompany, deleteCompany, @@ -65,42 +32,6 @@ function getStatusLabel(status: Status, t: (key: string) => string) { export default function DashboardCompaniesPage() { 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 [editingItem, setEditingItem] = useState(null) - const [result, setResult] = useState>({ - results: [], - page: { page: 1, limit: 20, total: 0 }, - }) - - const loadData = useCallback(async () => { - setLoading(true) - try { - const data = await fetchCompanies({ - name: name.trim() || undefined, - code: code.trim() || undefined, - status: statusFilter === "all" ? undefined : statusFilter, - page, - limit, - }) - setResult(data) - } catch (error) { - toast.error(error instanceof Error ? error.message : t("company.loadFailed")) - } finally { - setLoading(false) - } - }, [code, limit, name, page, statusFilter, t]) - const listStatusOptions = [ { value: "all", label: t("status.all") }, ...getEnumOptions(StatusLabels) @@ -111,261 +42,165 @@ export default function DashboardCompaniesPage() { })), ] - 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: AdminCompany) { - setEditingItem(item) - setDialogOpen(true) - } - - function handleDialogOpenChange(open: boolean) { - if (saving) return - if (!open) setEditingItem(null) - setDialogOpen(open) - } - - async function handleSubmit(payload: CreateAdminCompanyPayload) { - if (saving) return - setSaving(true) - try { - if (editingItem) { - await updateCompany({ id: editingItem.id, ...payload }) - toast.success(t("company.updated", { name: editingItem.name })) - } else { - await createCompany(payload) - toast.success(t("company.created", { name: payload.name })) - } - setDialogOpen(false) - setEditingItem(null) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("company.saveFailed")) - } finally { - setSaving(false) - } - } - - async function handleToggleStatus(item: AdminCompany) { - setActionLoadingId(item.id) - try { - const nextStatus = item.status === 0 ? 1 : 0 - await updateCompanyStatus(item.id, nextStatus) - toast.success(t(nextStatus === 0 ? "company.enabled" : "company.disabled", { name: item.name })) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("company.statusUpdateFailed")) - } finally { - setActionLoadingId(null) - } - } - - async function handleDelete(item: AdminCompany) { - setActionLoadingId(item.id) - try { - await deleteCompany(item.id) - toast.success(t("company.deleted", { name: item.name })) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("company.deleteFailed")) - } finally { - setActionLoadingId(null) - } - } - return ( - <> - - + filters={[ + { + name: "name", + label: t("company.filterName"), + placeholder: t("company.filterName"), + defaultValue: "", + trim: true, + className: "w-full sm:w-72", + }, + { + name: "code", + label: t("company.filterCode"), + placeholder: t("company.filterCode"), + 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", + }, + ]} + columns={[ + { + key: "id", + label: "ID", + className: "w-20", + render: (item) => item.id, + }, + { + key: "name", + label: t("company.columnName"), + render: (item) => {item.name}, + }, + { + key: "code", + label: t("company.columnCode"), + render: (item) => ( + {item.code || "-"} + ), + }, + { + key: "customerCount", + label: t("company.columnCustomerCount"), + className: "w-28", + render: (item) => item.customerCount, + }, + { + key: "status", + label: t("company.columnStatus"), + className: "w-24", + render: (item) => ( + + {StatusLabels[item.status as Status] + ? getStatusLabel(item.status as Status, t) + : t("company.unknownStatus")} + + ), + }, + { + key: "remark", + label: t("company.columnRemark"), + render: (item) => ( +
+ {item.remark || "-"} +
+ ), + }, + ]} + fetchList={fetchCompanies} + getItemId={(item) => item.id} + createItem={createCompany} + updateItem={(item, payload) => updateCompany({ id: item.id, ...payload })} + deleteItem={(item) => deleteCompany(item.id)} + canDelete={(item) => item.status !== Status.Deleted} + renderRowActions={({ item, actionLoading, reload, setActionLoadingId }) => ( + { + void (async () => { + setActionLoadingId(item.id) + try { + const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok + await updateCompanyStatus(item.id, nextStatus) + toast.success( + t(nextStatus === Status.Ok ? "company.enabled" : "company.disabled", { + name: item.name, + }) + ) + await reload() + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t("company.statusUpdateFailed") + ) + } finally { + setActionLoadingId(null) + } + })() + }} + > + {actionLoading ? ( + t("company.processing") + ) : item.status === Status.Ok ? ( <> - - + + {t("company.disable")} - } - > -
- - setNameInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("company.filterName")} - className="pl-9" - /> -
- setCodeInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("company.filterCode")} - className="w-full sm:w-44" - /> -
- -
- -
- - { - setLimit(nextLimit) - setPage(1) - }} - /> - } - > - - - - ID - {t("company.columnName")} - {t("company.columnCode")} - {t("company.columnCustomerCount")} - {t("company.columnStatus")} - {t("company.columnRemark")} - {t("company.columnActions")} - - - - {loading || result.results.length === 0 ? ( - - ) : ( - result.results.map((item) => { - const actionLoading = actionLoadingId === item.id - return ( - - {item.id} - {item.name} - {item.code || "-"} - {item.customerCount} - - - {StatusLabels[item.status as Status] ? getStatusLabel(item.status as Status, t) : t("company.unknownStatus")} - - - -
{item.remark || "-"}
-
- - - - - - } - aria-label={t("company.moreActions", { name: item.name })} - > - - - - void handleToggleStatus(item)} - > - {actionLoading ? ( - t("company.processing") - ) : item.status === Status.Ok ? ( - <> - - {t("company.disable")} - - ) : ( - <> - - {t("company.enable")} - - )} - - void handleDelete(item)} - > - - {t("company.delete")} - - - - - -
- ) - }) - )} -
-
-
-
- - - + ) : ( + <> + + {t("company.enable")} + + )} + + )} + renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( + + )} + labels={{ + refresh: t("company.refresh"), + create: t("company.new"), + query: t("company.query"), + loading: t("company.loading"), + empty: t("company.empty"), + actions: t("company.columnActions"), + edit: t("company.edit"), + delete: t("company.delete"), + processing: t("company.processing"), + moreActions: (item) => t("company.moreActions", { name: item.name }), + loadFailed: t("company.loadFailed"), + saveFailed: t("company.saveFailed"), + deleteFailed: t("company.deleteFailed"), + created: (payload) => t("company.created", { name: payload.name }), + updated: (item) => t("company.updated", { name: item.name }), + deleted: (item) => t("company.deleted", { name: item.name }), + }} + /> ) } diff --git a/web/app/dashboard/quick-replies/page.tsx b/web/app/dashboard/quick-replies/page.tsx index 6d1874b..3b47303 100644 --- a/web/app/dashboard/quick-replies/page.tsx +++ b/web/app/dashboard/quick-replies/page.tsx @@ -1,42 +1,11 @@ "use client" -import { useCallback, useEffect, useState } from "react" -import { - FileTextIcon, - MoreHorizontalIcon, - PlusIcon, - RefreshCwIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react" +import { FileTextIcon, RefreshCwIcon } from "lucide-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 { DashboardCrudPage } from "@/components/dashboard/crud" import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { ButtonGroup } from "@/components/ui/button-group" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Input } from "@/components/ui/input" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { DropdownMenuItem } from "@/components/ui/dropdown-menu" import { createQuickReply, deleteQuickReply, @@ -44,7 +13,6 @@ import { updateQuickReply, type AdminQuickReply, type CreateAdminQuickReplyPayload, - type PageResult, } from "@/lib/api/admin" import { getEnumOptions } from "@/lib/enums" import { Status, StatusLabels } from "@/lib/generated/enums" @@ -63,42 +31,6 @@ function getStatusLabel(status: Status, t: (key: string) => string) { export default function DashboardQuickRepliesPage() { const t = useI18n() - 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 [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 loadData = useCallback(async () => { - setLoading(true) - try { - const data = await fetchQuickReplies({ - title: 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("quickReply.loadFailed")) - } finally { - setLoading(false) - } - }, [groupName, keyword, limit, page, statusFilter, t]) - const listStatusOptions = [ { value: "all", label: t("status.all") }, ...getEnumOptions(StatusLabels) @@ -109,277 +41,156 @@ export default function DashboardQuickRepliesPage() { })), ] - 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) - } - - function openCreateDialog() { - setEditingItem(null) - setDialogOpen(true) - } - - function openEditDialog(item: AdminQuickReply) { - setEditingItem(item) - setDialogOpen(true) - } - - function handleDialogOpenChange(open: boolean) { - if (saving) { - return - } - if (!open) { - setEditingItem(null) - } - setDialogOpen(open) - } - - async function handleSubmit(payload: CreateAdminQuickReplyPayload) { - if (saving) { - return - } - - setSaving(true) - try { - if (editingItem) { - await updateQuickReply({ - id: editingItem.id, - ...payload, - }) - toast.success(t("quickReply.updated", { title: editingItem.title })) - } else { - await createQuickReply(payload) - toast.success(t("quickReply.created", { title: payload.title })) - } - setDialogOpen(false) - setEditingItem(null) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("quickReply.saveFailed")) - } finally { - setSaving(false) - } - } - - async function handleToggleStatus(item: AdminQuickReply) { - setActionLoadingId(item.id) - try { - const nextStatus = - item.status === Status.Ok ? Status.Disabled : Status.Ok - await updateQuickReply({ - id: item.id, - groupName: item.groupName, - title: item.title, - content: item.content, - sortNo: item.sortNo, - status: nextStatus, - }) - toast.success( - t(nextStatus === Status.Ok ? "quickReply.enabled" : "quickReply.disabled", { title: item.title }) - ) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("quickReply.statusUpdateFailed")) - } finally { - setActionLoadingId(null) - } - } - - async function handleDelete(item: AdminQuickReply) { - setActionLoadingId(item.id) - try { - await deleteQuickReply(item.id) - toast.success(t("quickReply.deleted", { title: item.title })) - await loadData() - } catch (error) { - toast.error(error instanceof Error ? error.message : t("quickReply.deleteFailed")) - } finally { - setActionLoadingId(null) - } - } - return ( - <> - - - - - - } + + filters={[ + { + name: "title", + label: t("quickReply.filterTitle"), + placeholder: t("quickReply.filterTitle"), + defaultValue: "", + trim: true, + className: "w-full sm:w-72", + }, + { + name: "groupName", + label: t("quickReply.filterGroup"), + placeholder: t("quickReply.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", + }, + ]} + columns={[ + { + key: "quickReply", + label: t("quickReply.columnQuickReply"), + render: (item) => ( +
+
+ +
+
+
{item.title}
+
+ {item.content} +
+
+
+ ), + }, + { + key: "groupName", + label: t("quickReply.columnGroup"), + render: (item) => {item.groupName}, + }, + { + key: "status", + label: t("quickReply.columnStatus"), + render: (item) => ( + + {getStatusLabel(item.status as Status, t)} + + ), + }, + { + key: "sortNo", + label: t("quickReply.columnSort"), + render: (item) => item.sortNo, + }, + { + key: "createdBy", + label: t("quickReply.columnCreator"), + render: (item) => item.createdBy || "-", + }, + ]} + fetchList={fetchQuickReplies} + getItemId={(item) => item.id} + createItem={createQuickReply} + updateItem={(item, payload) => updateQuickReply({ id: item.id, ...payload })} + deleteItem={(item) => deleteQuickReply(item.id)} + renderRowActions={({ item, actionLoading, reload, setActionLoadingId }) => ( + { + void (async () => { + setActionLoadingId(item.id) + try { + const nextStatus = + item.status === Status.Ok ? Status.Disabled : Status.Ok + await updateQuickReply({ + id: item.id, + groupName: item.groupName, + title: item.title, + content: item.content, + sortNo: item.sortNo, + status: nextStatus, + }) + toast.success( + t( + nextStatus === Status.Ok + ? "quickReply.enabled" + : "quickReply.disabled", + { title: item.title } + ) + ) + await reload() + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t("quickReply.statusUpdateFailed") + ) + } finally { + setActionLoadingId(null) + } + })() + }} > -
- - setKeywordInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("quickReply.filterTitle")} - className="pl-9" - /> -
- setGroupNameInput(event.target.value)} - onKeyDown={handleFilterKeyDown} - placeholder={t("quickReply.filterGroup")} - className="w-full sm:w-44" - /> -
- -
- -
- { - setLimit(nextLimit) - setPage(1) - }} - /> - } - > - - - - {t("quickReply.columnQuickReply")} - {t("quickReply.columnGroup")} - {t("quickReply.columnStatus")} - {t("quickReply.columnSort")} - {t("quickReply.columnCreator")} - {t("quickReply.columnActions")} - - - - {result.results.map((item) => ( - - -
-
- -
-
-
{item.title}
-
- {item.content} -
-
-
-
- - {item.groupName} - - - - {getStatusLabel(item.status as Status, t)} - - - {item.sortNo} - {item.createdBy || "-"} - - - - - } - aria-label={t("quickReply.moreActions", { title: item.title })} - > - - - - void handleToggleStatus(item)}> - - {actionLoadingId === item.id - ? t("quickReply.processing") - : item.status === Status.Ok - ? t("quickReply.disable") - : t("quickReply.enable")} - - void handleDelete(item)} - className="text-destructive focus:text-destructive" - > - - {actionLoadingId === item.id ? t("quickReply.deleting") : t("quickReply.delete")} - - - - - -
- ))} - {loading || result.results.length === 0 ? ( - - ) : null} -
-
-
-
- - + + {actionLoading + ? t("quickReply.processing") + : item.status === Status.Ok + ? t("quickReply.disable") + : t("quickReply.enable")} + + )} + renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => ( + + )} + labels={{ + refresh: t("quickReply.refresh"), + create: t("quickReply.new"), + query: t("quickReply.query"), + loading: t("quickReply.loading"), + empty: t("quickReply.empty"), + actions: t("quickReply.columnActions"), + edit: t("quickReply.edit"), + delete: t("quickReply.delete"), + processing: t("quickReply.processing"), + moreActions: (item) => + t("quickReply.moreActions", { title: item.title }), + loadFailed: t("quickReply.loadFailed"), + saveFailed: t("quickReply.saveFailed"), + deleteFailed: t("quickReply.deleteFailed"), + created: (payload) => t("quickReply.created", { title: payload.title }), + updated: (item) => t("quickReply.updated", { title: item.title }), + deleted: (item) => t("quickReply.deleted", { title: item.title }), + }} + /> ) } diff --git a/web/components/dashboard/crud/dashboard-crud-page.tsx b/web/components/dashboard/crud/dashboard-crud-page.tsx new file mode 100644 index 0000000..1eb09a1 --- /dev/null +++ b/web/components/dashboard/crud/dashboard-crud-page.tsx @@ -0,0 +1,415 @@ +"use client" + +import type { ReactNode } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" +import { + MoreHorizontalIcon, + PlusIcon, + RefreshCwIcon, + SearchIcon, + Trash2Icon, +} from "lucide-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 { 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 { + buildDashboardCrudQuery, + normalizeDashboardCrudPageResult, + type DashboardCrudPageResult, + type DashboardCrudQueryFilter, + type DashboardCrudQueryValue, +} from "./dashboard-crud-utils" + +type DashboardCrudFilter = + DashboardCrudQueryFilter & { + label: string + placeholder?: string + defaultValue: TValue + type?: "text" | "select" + className?: string + options?: ReadonlyArray<{ value: string; label: string }> + } + +type DashboardCrudColumn = { + key: string + label: ReactNode + className?: string + render: (item: TItem, context: DashboardCrudRowActionContext) => ReactNode +} + +type DashboardCrudDialogProps = { + open: boolean + saving: boolean + item: TItem | null + itemId: number | null + onOpenChange: (open: boolean) => void + onSubmit: (payload: TPayload) => Promise +} + +type DashboardCrudRowActionContext = { + item: TItem + actionLoading: boolean + actionLoadingId: number | null + reload: () => Promise + setActionLoadingId: (id: number | null) => void +} + +type DashboardCrudPageProps = { + filters: DashboardCrudFilter[] + columns: DashboardCrudColumn[] + fetchList: ( + query: Record + ) => Promise> + renderEditDialog: (props: DashboardCrudDialogProps) => ReactNode + getItemId: (item: TItem) => number + createItem: (payload: TPayload) => Promise + updateItem: (item: TItem, payload: TPayload) => Promise + deleteItem?: (item: TItem) => Promise + canDelete?: (item: TItem) => boolean + renderRowActions?: (context: DashboardCrudRowActionContext) => ReactNode + pageSize?: number + labels: { + refresh: string + create: string + query: string + loading: string + empty: string + actions: string + edit: string + delete: string + processing: string + moreActions: (item: TItem) => string + loadFailed: string + saveFailed: string + deleteFailed: string + created: (item: TPayload) => string + updated: (item: TItem, payload: TPayload) => string + deleted?: (item: TItem) => string + } +} + +export function DashboardCrudPage({ + filters, + columns, + fetchList, + renderEditDialog, + getItemId, + createItem, + updateItem, + deleteItem, + canDelete, + renderRowActions, + pageSize = 20, + labels, +}: DashboardCrudPageProps) { + const initialFilters = useMemo( + () => + Object.fromEntries( + filters.map((filter) => [filter.name, filter.defaultValue]) + ) as Record, + [filters] + ) + const [draftFilters, setDraftFilters] = useState(initialFilters) + const [appliedFilters, setAppliedFilters] = useState(initialFilters) + const [page, setPage] = useState(1) + const [limit, setLimit] = useState(pageSize) + 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: pageSize, total: 0 }, + }) + + useEffect(() => { + setDraftFilters(initialFilters) + setAppliedFilters(initialFilters) + }, [initialFilters]) + + const loadData = useCallback(async () => { + 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 : labels.loadFailed) + } finally { + setLoading(false) + } + }, [appliedFilters, fetchList, filters, labels.loadFailed, limit, page]) + + useEffect(() => { + void loadData() + }, [loadData]) + + function applyFilters() { + setAppliedFilters(draftFilters) + setPage(1) + } + + function handleFilterKeyDown(event: React.KeyboardEvent) { + if (event.key !== "Enter") return + event.preventDefault() + applyFilters() + } + + function openCreateDialog() { + setEditingItem(null) + setDialogOpen(true) + } + + function openEditDialog(item: TItem) { + setEditingItem(item) + setDialogOpen(true) + } + + function handleDialogOpenChange(open: boolean) { + if (saving) return + if (!open) setEditingItem(null) + setDialogOpen(open) + } + + async function handleSubmit(payload: TPayload) { + if (saving) return + setSaving(true) + try { + if (editingItem) { + await updateItem(editingItem, payload) + toast.success(labels.updated(editingItem, payload)) + } else { + await createItem(payload) + toast.success(labels.created(payload)) + } + setDialogOpen(false) + setEditingItem(null) + await loadData() + } catch (error) { + toast.error(error instanceof Error ? error.message : labels.saveFailed) + } finally { + setSaving(false) + } + } + + async function handleDelete(item: TItem) { + if (!deleteItem) return + const id = getItemId(item) + setActionLoadingId(id) + try { + await deleteItem(item) + toast.success(labels.deleted?.(item) ?? labels.delete) + await loadData() + } catch (error) { + toast.error(error instanceof Error ? error.message : labels.deleteFailed) + } finally { + setActionLoadingId(null) + } + } + + const colSpan = columns.length + 1 + + return ( + <> + + + + + + } + > + {filters.map((filter) => { + const value = draftFilters[filter.name] + if (filter.type === "select") { + return ( +
+ + setDraftFilters((current) => ({ + ...current, + [filter.name]: nextValue, + })) + } + placeholder={filter.placeholder ?? filter.label} + options={[...(filter.options ?? [])]} + /> +
+ ) + } + + return ( +
+ + setDraftFilters((current) => ({ + ...current, + [filter.name]: event.target.value, + })) + } + onKeyDown={handleFilterKeyDown} + placeholder={filter.placeholder ?? filter.label} + /> +
+ ) + })} + +
+ + { + if (nextPage < 1 || nextPage === page) return + setPage(nextPage) + }} + onLimitChange={(nextLimit) => { + setLimit(nextLimit) + setPage(1) + }} + /> + } + > + + + + {columns.map((column) => ( + + {column.label} + + ))} + + {labels.actions} + + + + + {result.results.map((item) => { + const id = getItemId(item) + const actionLoading = actionLoadingId === id + return ( + + {columns.map((column) => ( + + {column.render(item, { + item, + actionLoading, + actionLoadingId, + reload: loadData, + setActionLoadingId, + })} + + ))} + + + + {renderRowActions || deleteItem ? ( + + } + aria-label={labels.moreActions(item)} + > + + + + {renderRowActions?.({ + item, + actionLoading, + actionLoadingId, + reload: loadData, + setActionLoadingId, + })} + {deleteItem ? ( + void handleDelete(item)} + disabled={canDelete ? !canDelete(item) : false} + className="text-destructive focus:text-destructive" + > + + {actionLoading ? labels.processing : labels.delete} + + ) : null} + + + ) : null} + + + + ) + })} + {loading || result.results.length === 0 ? ( + + ) : null} + +
+
+
+ {renderEditDialog({ + open: dialogOpen, + saving, + item: editingItem, + itemId: editingItem ? getItemId(editingItem) : null, + onOpenChange: handleDialogOpenChange, + onSubmit: handleSubmit, + })} + + ) +} diff --git a/web/components/dashboard/crud/dashboard-crud-utils.test.mjs b/web/components/dashboard/crud/dashboard-crud-utils.test.mjs new file mode 100644 index 0000000..100b5ab --- /dev/null +++ b/web/components/dashboard/crud/dashboard-crud-utils.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import ts from "typescript" +import { readFile } from "node:fs/promises" +import vm from "node:vm" + +function plain(value) { + return JSON.parse(JSON.stringify(value)) +} + +async function loadModule() { + const source = await readFile( + new URL("./dashboard-crud-utils.ts", import.meta.url), + "utf8" + ) + const compiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.CommonJS, + }, + fileName: "dashboard-crud-utils.ts", + }) + const sandbox = { + exports: {}, + module: { exports: {} }, + } + sandbox.exports = sandbox.module.exports + vm.runInNewContext(compiled.outputText, sandbox) + return sandbox.module.exports +} + +describe("buildDashboardCrudQuery", () => { + it("trims text filters and omits empty values", async () => { + const { buildDashboardCrudQuery } = await loadModule() + const query = buildDashboardCrudQuery({ + values: { + title: " hello ", + groupName: " ", + }, + filters: [ + { name: "title", trim: true }, + { name: "groupName", trim: true }, + ], + page: 2, + limit: 50, + }) + + assert.deepEqual(plain(query), { + title: "hello", + page: 2, + limit: 50, + }) + }) + + it("omits configured all values and parses numbers", async () => { + const { buildDashboardCrudQuery } = await loadModule() + const query = buildDashboardCrudQuery({ + values: { + status: "all", + companyId: "42", + }, + filters: [ + { name: "status", allValue: "all" }, + { name: "companyId", allValue: "0", valueType: "number" }, + ], + page: 1, + limit: 20, + }) + + assert.deepEqual(plain(query), { + companyId: 42, + page: 1, + limit: 20, + }) + }) +}) + +describe("normalizeDashboardCrudPageResult", () => { + it("returns a stable empty page when the API result is missing", async () => { + const { normalizeDashboardCrudPageResult } = await loadModule() + assert.deepEqual(plain(normalizeDashboardCrudPageResult(null, 3, 10)), { + results: [], + page: { + page: 3, + limit: 10, + total: 0, + }, + }) + }) +}) diff --git a/web/components/dashboard/crud/dashboard-crud-utils.ts b/web/components/dashboard/crud/dashboard-crud-utils.ts new file mode 100644 index 0000000..ff50063 --- /dev/null +++ b/web/components/dashboard/crud/dashboard-crud-utils.ts @@ -0,0 +1,74 @@ +export type DashboardCrudQueryValue = string | number | undefined + +export type DashboardCrudQueryFilter = { + name: string + trim?: boolean + allValue?: string | number + valueType?: "string" | "number" +} + +export type DashboardCrudPageResult = { + results: T[] + page: { + page: number + limit: number + total: number + } +} + +export function buildDashboardCrudQuery({ + values, + filters, + page, + limit, +}: { + values: Record + filters: DashboardCrudQueryFilter[] + page: number + limit: number +}): Record { + const query: Record = {} + + filters.forEach((filter) => { + const rawValue = values[filter.name] + const value = + filter.trim && typeof rawValue === "string" ? rawValue.trim() : rawValue + + if ( + value === undefined || + value === "" || + (filter.allValue !== undefined && String(value) === String(filter.allValue)) + ) { + return + } + + if (filter.valueType === "number") { + const numberValue = Number(value) + if (Number.isFinite(numberValue)) { + query[filter.name] = numberValue + } + return + } + + query[filter.name] = value + }) + + query.page = page + query.limit = limit + return query +} + +export function normalizeDashboardCrudPageResult( + result: Partial> | null | undefined, + page: number, + limit: number +): DashboardCrudPageResult { + return { + results: Array.isArray(result?.results) ? result.results : [], + page: { + page: result?.page?.page ?? page, + limit: result?.page?.limit ?? limit, + total: result?.page?.total ?? 0, + }, + } +} diff --git a/web/components/dashboard/crud/index.ts b/web/components/dashboard/crud/index.ts new file mode 100644 index 0000000..9e65978 --- /dev/null +++ b/web/components/dashboard/crud/index.ts @@ -0,0 +1,5 @@ +export { DashboardCrudPage } from "./dashboard-crud-page" +export type { + DashboardCrudPageResult, + DashboardCrudQueryValue, +} from "./dashboard-crud-utils"