feat(clients): selective bulk attach + new bulk detach

Inbounds page:
- AttachClientsModal now shows a per-client selection table (email,
  comment, enabled tag) with search and a live "selected of total"
  counter; all clients are pre-selected so the old "attach all"
  workflow stays a single OK click.
- New DetachClientsModal on the inbound row menu lets you pick which
  clients to remove from that inbound (records are kept so they can be
  re-attached later; for full removal use Delete).

Clients page:
- New "Attach (N)" bulk-action button + BulkAttachInboundsModal that
  attaches selected clients to one or more multi-user inbounds.
- New "Detach (N)" bulk-action button + BulkDetachInboundsModal that
  removes selected clients from chosen inbounds; (email, inbound) pairs
  where the client isn't attached are silently skipped.

Backend adds POST /panel/api/clients/bulkDetach, wrapping the existing
Detach service for each email and reporting per-email
detached/skipped/errors. ClientRecord rows are kept on detach to match
the single-client endpoint; bulkDel remains the path for full removal.
This commit is contained in:
MHSanaei
2026-05-28 11:08:52 +02:00
parent a07b68894c
commit 72b68cce22
15 changed files with 809 additions and 13 deletions

View File

@@ -10,8 +10,10 @@ import {
InboundOptionsSchema,
OnlinesSchema,
BulkAdjustResultSchema,
BulkAttachResultSchema,
BulkCreateResultSchema,
BulkDeleteResultSchema,
BulkDetachResultSchema,
DelDepletedResultSchema,
type ClientHydrate,
type ClientRecord,
@@ -20,8 +22,10 @@ import {
type ClientPageResponse,
type InboundOption,
type BulkAdjustResult,
type BulkAttachResult,
type BulkCreateResult,
type BulkDeleteResult,
type BulkDetachResult,
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
@@ -286,12 +290,28 @@ export function useClients() {
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkAttachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
return parseMsg(raw, BulkAttachResultSchema, 'clients/bulkAttach');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const detachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkDetachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
return parseMsg(raw, BulkDetachResultSchema, 'clients/bulkDetach');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const resetTrafficMut = useMutation({
mutationFn: (email: string) =>
HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
@@ -340,10 +360,20 @@ export function useClients() {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return attachMut.mutateAsync({ email, inboundIds });
}, [attachMut]);
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
return bulkAttachMut.mutateAsync({ emails, inboundIds });
}, [bulkAttachMut]);
const detach = useCallback((email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return detachMut.mutateAsync({ email, inboundIds });
}, [detachMut]);
const bulkDetach = useCallback((emails: string[], inboundIds: number[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
return bulkDetachMut.mutateAsync({ emails, inboundIds });
}, [bulkDetachMut]);
const resetTraffic = useCallback((client: ClientRecord) => {
if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
return resetTrafficMut.mutateAsync(client.email);
@@ -444,7 +474,9 @@ export function useClients() {
bulkAdjust,
bulkAssignGroup,
attach,
bulkAttach,
detach,
bulkDetach,
resetTraffic,
resetAllTraffics,
delDepleted,