From 7680e27d1d84a79daa0f7c682f6aec258ac9732d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 27 May 2026 15:07:17 +0200 Subject: [PATCH] feat(clients): toolbar sort selector + preserve updated_at on unchanged rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend - New Sort dropdown in the clients toolbar covering oldest/newest, recently updated, recently online, email A↔Z, most traffic, highest remaining, expiring soonest. Default is Oldest first. - Strip per-column sorter arrows from the Table — all sorting now flows through the single dropdown, so the column headers stop competing with it. - Empty state: TeamOutlined icon, t('noData'), text-secondary color (matching the inbound/node polish). Backend - sortClients: add createdAt, updatedAt and lastOnline cases (with id tie-break for stable ordering when timestamps collide). - Fix Recently updated: SyncInbound was calling tx.Save on every client in the inbound, and GORM's autoUpdateTime tag stamped updated_at to time.Now() each time — so editing one client bumped ALL of them. After the Save, restore each row's preserved updated_at via UpdateColumn (skips hooks). The actually-edited client gets its fresh stamp from the explicit UpdateColumn at the end of Update(). - Fix periodic updated_at churn: adjustTraffics unconditionally set c["updated_at"] = now() for every client in any inbound that had a delayed-start expiry, every traffic-stats pass. Turn that into a backfill (only when the key is missing), matching the created_at treatment one line above. --- frontend/src/pages/clients/ClientsPage.css | 4 +- frontend/src/pages/clients/ClientsPage.tsx | 312 +++++++++++---------- web/service/client.go | 36 ++- web/service/inbound.go | 5 +- web/translation/en-US.json | 12 +- 5 files changed, 214 insertions(+), 155 deletions(-) diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css index 4560cd10..195d539f 100644 --- a/frontend/src/pages/clients/ClientsPage.css +++ b/frontend/src/pages/clients/ClientsPage.css @@ -178,7 +178,7 @@ .card-empty { text-align: center; padding: 40px 16px; - opacity: 0.55; + color: var(--ant-color-text-secondary); display: flex; flex-direction: column; align-items: center; @@ -188,5 +188,5 @@ .clients-empty { padding: 32px 0; text-align: center; - opacity: 0.55; + color: var(--ant-color-text-secondary); } diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index b9be924a..3f97a9d2 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -14,6 +14,7 @@ import { Pagination, Popover, Row, + Select, Space, Spin, Statistic, @@ -36,8 +37,8 @@ import { RestOutlined, RetweetOutlined, SearchOutlined, + SortAscendingOutlined, TeamOutlined, - UserOutlined, UsergroupAddOutlined, } from '@ant-design/icons'; @@ -108,6 +109,25 @@ function gbToBytes(gb: number | undefined): number { return Math.round(gb * 1024 * 1024 * 1024); } +const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [ + { value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' }, + { value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' }, + { value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' }, + { value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' }, + { value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' }, + { value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' }, + { value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' }, + { value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' }, + { value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' }, +]; + +const DEFAULT_SORT = SORT_OPTIONS[0]; + +function sortValueFor(column: string | null, order: 'ascend' | 'descend' | null): string { + if (!column || !order) return DEFAULT_SORT.value; + return `${column}:${order}`; +} + export default function ClientsPage() { const { t } = useTranslation(); const { isDark, isUltra, antdThemeConfig } = useTheme(); @@ -152,8 +172,8 @@ export default function ClientsPage() { const [filters, setFilters] = useState(initial.filters); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); - const [sortColumn, setSortColumn] = useState(null); - const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null); + const [sortColumn, setSortColumn] = useState(DEFAULT_SORT.column); + const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.order); const [currentPage, setCurrentPage] = useState(1); const [tablePageSize, setTablePageSize] = useState(25); // debouncedSearch lags behind the input so we don't spam the server on every @@ -475,151 +495,139 @@ export default function ClientsPage() { return classes.join(' '); }, [isDark, isUltra]); - const onTableChange: NonNullable['onChange']> = (pag, _filters, sorter) => { + const onTableChange: NonNullable['onChange']> = (pag) => { if (pag?.current) setCurrentPage(pag.current); if (pag?.pageSize) setTablePageSize(pag.pageSize); - const s = Array.isArray(sorter) ? sorter[0] : sorter; - setSortColumn((s?.columnKey as string) || (s?.field as string) || null); - setSortOrder((s?.order as 'ascend' | 'descend' | null) || null); }; - const columns = useMemo>(() => { - function sortableCol[number]>(col: T, key: string): T { - return { - ...col, - sorter: true, - showSorterTooltip: false, - sortOrder: sortColumn === key ? sortOrder : null, - sortDirections: ['ascend', 'descend'], - }; - } - return [ - { - title: t('pages.clients.actions'), - key: 'actions', - width: 200, - render: (_v, record) => ( - - - +